Преглед изворни кода

explorer: Implement hashrate, reorg, and search

x пре 6 месеци
родитељ
комит
592bd5fb2f
4 измењених фајлова са 363 додато и 99 уклоњено
  1. 107 12
      bin/explorer/python/explorer.py
  2. 101 35
      bin/explorer/src/db.rs
  3. 23 6
      bin/explorer/src/main.rs
  4. 132 46
      bin/explorer/src/rpc.rs

+ 107 - 12
bin/explorer/python/explorer.py

@@ -1,7 +1,23 @@
 #!/usr/bin/env python3
+# This file is part of DarkFi (https://dark.fi)
+#
+# Copyright (C) 2020-2026 Dyne.org foundation
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
 from datetime import datetime, timezone
 
-from quart import Quart, render_template, abort, g
+from quart import Quart, render_template, abort, request, redirect, url_for
 
 from rpc_client import JsonRpcPool, JsonRpcError
 
@@ -11,7 +27,7 @@ app.config.update(
     RPC_PORT="22222",
     RPC_MIN_CONNECTIONS=5,
     RPC_MAX_CONNECTIONS=50,
-    NETWORK="testnet",
+    NETWORK="Testnet",
 )
 
 # Global pool
@@ -41,14 +57,53 @@ async def shutdown():
 async def handle_rpc_error(error: JsonRpcError):
     app.logger.error(f"RPC Error: {error.code} - {error.message}")
     if error.code == -32600:
-        abort(404)
-    return await render_template("error.html", error=error.message), 500
+        return await render_template(
+            "error.html",
+            network=app.config["NETWORK"],
+            error_code="404",
+            error="The requested resource was not found"
+        ), 404
+    return await render_template(
+        "error.html",
+        network=app.config["NETWORK"],
+        error_code="500",
+        error=error.message
+    ), 500
 
 
 @app.errorhandler(ConnectionError)
 async def handle_connection_error(error):
     app.logger.error(f"RPC Connection Error: {error}")
-    return await render_template("error.html", error="Service temporarily unavailable"), 503
+    return await render_template(
+        "error.html",
+        network=app.config["NETWORK"],
+        error_code="503",
+        error="Service temporarily unavailable"
+    ), 503
+
+
+@app.errorhandler(404)
+async def handle_not_found(error):
+    return await render_template(
+        "error.html",
+        network=app.config["NETWORK"],
+        error_code="404",
+        error="Page not found"
+    ), 404
+
+
+def format_hashrate(hashrate: float) -> str:
+    """Format hashrate with appropriate unit."""
+    if hashrate >= 1e12:
+        return f"{hashrate / 1e12:.2f} TH/s"
+    elif hashrate >= 1e9:
+        return f"{hashrate / 1e9:.2f} GH/s"
+    elif hashrate >= 1e6:
+        return f"{hashrate / 1e6:.2f} MH/s"
+    elif hashrate >= 1e3:
+        return f"{hashrate / 1e3:.2f} KH/s"
+    else:
+        return f"{hashrate:.2f} H/s"
 
 
 @app.route("/")
@@ -56,11 +111,8 @@ async def index():
     current_difficulty = await rpc.call("current_difficulty", params=[])
     current_height = await rpc.call("current_height", params=[])
     latest_blocks = await rpc.call("latest_blocks", params=[20])
+    hashrate = await rpc.call("get_hashrate", params=[])
 
-    # TODO: hashrate
-    # TODO: emission
-    # TODO: mempool_txs = await rpc.call("mempool", params=[])
-    
     for block in latest_blocks:
         dt = datetime.fromtimestamp(block["timestamp"], tz=timezone.utc)
         block["timestamp"] = dt.strftime("%B %d, %Y at %I:%M %p UTC")
@@ -70,17 +122,20 @@ async def index():
         network=app.config["NETWORK"],
         current_difficulty=current_difficulty[0],
         current_height=current_height,
-        #mempool_txs=mempool_txs,
-        #mempool_txs_len=len(mempool_txs),
+        hashrate=format_hashrate(hashrate),
         latest_blocks=latest_blocks,
     )
 
 
 @app.route("/block/<int:block_height>")
 async def get_block_by_height(block_height: int):
+    if block_height < 0:
+        abort(404)
+
     current_difficulty = await rpc.call("current_difficulty", params=[])
     current_height = await rpc.call("current_height", params=[])
-    block = await rpc.call("get_block", params=[block_height])    
+    hashrate = await rpc.call("get_hashrate", params=[])
+    block = await rpc.call("get_block", params=[block_height])
 
     dt = datetime.fromtimestamp(block["timestamp"], tz=timezone.utc)
     block["timestamp"] = dt.strftime("%B %d, %Y at %I:%M %p UTC")
@@ -91,14 +146,20 @@ async def get_block_by_height(block_height: int):
         network=app.config["NETWORK"],
         current_difficulty=current_difficulty[0],
         current_height=current_height,
+        hashrate=format_hashrate(hashrate),
         block=block,
     )
 
 
 @app.route("/tx/<tx_hash>")
 async def get_tx_by_hash(tx_hash: str):
+    # Validate hex string
+    if not all(c in '0123456789abcdefABCDEF' for c in tx_hash):
+        abort(404)
+
     current_difficulty = await rpc.call("current_difficulty", params=[])
     current_height = await rpc.call("current_height", params=[])
+    hashrate = await rpc.call("get_hashrate", params=[])
     tx = await rpc.call("get_tx", params=[tx_hash])
 
     return await render_template(
@@ -106,9 +167,43 @@ async def get_tx_by_hash(tx_hash: str):
         network=app.config["NETWORK"],
         current_difficulty=current_difficulty[0],
         current_height=current_height,
+        hashrate=format_hashrate(hashrate),
         tx=tx,
     )
 
 
+@app.route("/search")
+async def search():
+    """Search for blocks by height/hash or transactions by hash."""
+    query = request.args.get("q", "").strip()
+
+    if not query:
+        return redirect(url_for("index"))
+
+    # Try to interpret as block height (integer)
+    if query.isdigit():
+        return redirect(url_for("get_block_by_height", block_height=int(query)))
+
+    # Check if it looks like a hex hash
+    if all(c in '0123456789abcdefABCDEF' for c in query):
+        # Use the search RPC to determine if it's a block or tx hash
+        try:
+            result = await rpc.call("search", params=[query])
+            if result["type"] == "block":
+                return redirect(f"/block/{result['height']}")
+            elif result["type"] == "tx":
+                return redirect(url_for("get_tx_by_hash", tx_hash=query))
+        except JsonRpcError:
+            pass
+
+    # Nothing found
+    return await render_template(
+        "error.html",
+        network=app.config["NETWORK"],
+        error_code="Not Found",
+        error=f"No block or transaction found for: {query}"
+    ), 404
+
+
 if __name__ == "__main__":
     app.run(host="127.0.0.1", port=5000, debug=True)

+ 101 - 35
bin/explorer/src/db.rs

@@ -25,6 +25,7 @@ use darkfi::{
 };
 use darkfi_sdk::crypto::schnorr::Signature;
 use darkfi_serial::{deserialize_async, serialize_async};
+use sled::{transaction::TransactionError, Transactional};
 use tapes::{BlobTape, FixedSizedTape, Persistence, TapeOpenOptions, Tapes};
 use tracing::info;
 
@@ -118,26 +119,36 @@ impl Explorer {
         // Append difficulty
         tx.append_entries(&self.database.difficulty_index, std::slice::from_ref(diff))?;
 
-        // sled stores transaction indices so we can reference them
-        let mut batch = sled::Batch::default();
-        for (i, tx) in block.txs.iter().enumerate() {
-            let tx_hash = tx.hash();
+        // Commit Tapes first
+        tx.commit(Persistence::SyncData)?;
+
+        // Prepare data for atomic sled transaction
+        let header_hash = serialize_async(&block.header.hash()).await;
+        // Store height as u64 (8 bytes) to match lookup format
+        let height_bytes = (block.header.height as u64).to_le_bytes();
+
+        // Collect tx hashes and their indices
+        let mut tx_entries: Vec<([u8; 32], [u8; 8])> = Vec::with_capacity(block.txs.len());
+        for (i, transaction) in block.txs.iter().enumerate() {
+            let tx_hash = *transaction.hash().inner();
             let tx_idx_pos = tx_start_idx + i as u64;
-            batch.insert(tx_hash.inner(), &tx_idx_pos.to_le_bytes());
+            tx_entries.push((tx_hash, tx_idx_pos.to_le_bytes()));
         }
 
-        // Commit Tapes and Sled
-        tx.commit(Persistence::SyncData)?;
-        self.tx_indices.apply_batch(batch)?;
-
-        // TODO: This should also be atomic with the above batch
-        // Also store a map of header_hash -> height
-        // On reorg/delete we can get_header(height) from tapes and then find
-        // which header to remove from header_indices.
-        self.header_indices.insert(
-            serialize_async(&block.header.hash()).await,
-            &block.header.height.to_le_bytes(),
-        )?;
+        // Atomic sled transaction for both tx_indices and header_indices
+        (&self.tx_indices, &self.header_indices)
+            .transaction(|(tx_tree, header_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())?;
+                Ok(())
+            })
+            .map_err(|e: TransactionError<sled::Error>| {
+                io::Error::other(format!("sled transaction error: {e}"))
+            })?;
 
         info!(
             "Appended block {} ({} bytes header, {} txs)",
@@ -162,7 +173,7 @@ impl Explorer {
             return Err(io::Error::new(
                 io::ErrorKind::InvalidInput,
                 "Cannot revert more blocks than exist",
-            ));
+            ))
         }
 
         let new_block_count = current_len - count;
@@ -235,18 +246,20 @@ impl Explorer {
 
         truncate_tx.commit(Persistence::SyncData)?;
 
-        // Remove from sled
-        let mut tx_batch = sled::Batch::default();
-        for tx_hash in &tx_hashes_to_remove {
-            tx_batch.remove(tx_hash.as_slice());
-        }
-        self.tx_indices.apply_batch(tx_batch)?;
-
-        let mut header_batch = sled::Batch::default();
-        for header_hash in &header_hashes_to_remove {
-            header_batch.remove(header_hash.as_slice());
-        }
-        self.header_indices.apply_batch(header_batch)?;
+        // Atomic sled transaction for removing both tx and header indices
+        (&self.tx_indices, &self.header_indices)
+            .transaction(|(tx_tree, header_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())?;
+                }
+                Ok(())
+            })
+            .map_err(|e: TransactionError<sled::Error>| {
+                io::Error::other(format!("sled transaction error: {e}"))
+            })?;
 
         info!(
             "Reverted {} blocks (new height: {})",
@@ -304,18 +317,37 @@ impl Explorer {
         };
 
         if block_idx.tx_count == 0 {
-            return Ok(Some(vec![]));
+            return Ok(Some(vec![]))
         }
 
-        let mut txs = Vec::with_capacity(block_idx.tx_count as usize);
+        // Read all TxIndex entries for this block
+        let mut tx_indices = Vec::with_capacity(block_idx.tx_count as usize);
         for i in 0..block_idx.tx_count {
             let tx_idx = reader
                 .read_entry(&self.database.tx_index, block_idx.tx_start_idx + i)?
                 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "tx index not found"))?;
+            tx_indices.push(tx_idx);
+        }
+
+        if tx_indices.is_empty() {
+            return Ok(Some(vec![]))
+        }
 
-            let mut data = vec![0u8; tx_idx.length as usize];
-            reader.read_bytes(&self.database.transactions, tx_idx.offset, &mut data)?;
-            txs.push(deserialize_async(&data).await?);
+        // Since transactions are stored contiguously, read all data at once
+        let first_tx = &tx_indices[0];
+        let last_tx = &tx_indices[tx_indices.len() - 1];
+        let total_len = (last_tx.offset + last_tx.length - first_tx.offset) as usize;
+
+        // Read all transaction data in one operation
+        let mut all_tx_data = vec![0u8; total_len];
+        reader.read_bytes(&self.database.transactions, first_tx.offset, &mut all_tx_data)?;
+
+        // Deserialize each transaction from the combined buffer
+        let mut txs = Vec::with_capacity(tx_indices.len());
+        for tx_idx in &tx_indices {
+            let start = (tx_idx.offset - first_tx.offset) as usize;
+            let end = start + tx_idx.length as usize;
+            txs.push(deserialize_async(&all_tx_data[start..end]).await?);
         }
 
         Ok(Some(txs))
@@ -334,6 +366,40 @@ impl Explorer {
         Ok(Some(BlockInfo { header, txs, signature: Signature::dummy() }))
     }
 
+    /// Get basic block info without loading all transactions.
+    /// Returns (header, tx_count, total_size) for efficient latest_blocks display.
+    pub async fn get_block_summary(&self, height: u64) -> io::Result<Option<(Header, u64, u64)>> {
+        let reader = self.tapes_db.reader();
+
+        let block_idx = match reader.read_entry(&self.database.block_index, height)? {
+            Some(idx) => idx,
+            None => return Ok(None),
+        };
+
+        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 total size: header + all transactions
+        let total_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 total_size = block_idx.length + total_tx_size;
+        Ok(Some((header, block_idx.tx_count, total_size)))
+    }
+
     /// Get a transaction by its hash.
     /// Returns the transaction and the block height it belongs to.
     pub async fn get_tx_by_hash(

+ 23 - 6
bin/explorer/src/main.rs

@@ -87,6 +87,8 @@ impl RequestHandler<RpcHandler> for Explorer {
             "latest_blocks" => self.rpc_latest_blocks(req.id, req.params).await,
             "get_block" => self.rpc_get_block(req.id, req.params).await,
             "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,
             _ => JsonError::new(ErrorCode::MethodNotFound, None, req.id).into(),
         }
     }
@@ -142,13 +144,28 @@ impl Explorer {
 
             match block_notification {
                 JsonResult::Notification(notification) => {
-                    // TODO: Check if height is lower than our known height.
-                    // This means we need to reorg.
-
                     // Deserialize base64 block
                     let block_bytes =
                         base64::decode(notification.params[0].get::<String>().unwrap()).unwrap();
                     let block: BlockInfo = deserialize_async(&block_bytes).await.unwrap();
+                    let incoming_height = block.header.height as u64;
+
+                    // Check if we need to reorg
+                    let current_height = self.get_height().ok().flatten().unwrap_or(0);
+
+                    if incoming_height <= current_height {
+                        // Reorg needed: incoming block is at or before our current height
+                        let blocks_to_revert = current_height - incoming_height + 1;
+                        info!(
+                            "Reorg detected! Incoming height {} <= current height {}. Reverting {} blocks.",
+                            incoming_height, current_height, blocks_to_revert
+                        );
+
+                        if let Err(e) = self.revert_blocks(blocks_to_revert).await {
+                            tracing::error!("Failed to revert blocks during reorg: {}", e);
+                            continue;
+                        }
+                    }
 
                     // Get difficulty
                     let rpc_client =
@@ -225,9 +242,9 @@ impl Explorer {
 
 async fn realmain(ex: Arc<Executor<'static>>) -> Result<()> {
     let explorer = Arc::new(Explorer::new(
-        Path::new("sled_db"),
-        Path::new("tapes_metadata"),
-        Path::new("tapes"),
+        Path::new("db/sled_db"),
+        Path::new("db/tapes_metadata"),
+        Path::new("db/tapes"),
     )?);
 
     // First we should subscribe to new blocks and queue them to apply

+ 132 - 46
bin/explorer/src/rpc.rs

@@ -1,3 +1,21 @@
+/* This file is part of DarkFi (https://dark.fi)
+ *
+ * Copyright (C) 2020-2026 Dyne.org foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
 use std::collections::HashMap;
 
 use darkfi::{
@@ -14,44 +32,6 @@ use tinyjson::JsonValue;
 
 use crate::{DifficultyIndex, Explorer};
 
-struct LatestBlockInfo {
-    height: u64,
-    size: u64,
-    n_txs: u64,
-    timestamp: u64,
-    powtype: String,
-    hash: String,
-}
-
-impl LatestBlockInfo {
-    async fn new(block: &BlockInfo) -> Self {
-        let powtype = match block.header.pow_data {
-            PowData::DarkFi => "DarkFi".to_string(),
-            PowData::Monero(_) => "Monero".to_string(),
-        };
-
-        Self {
-            height: block.header.height as u64,
-            size: serialize_async(block).await.len() as u64,
-            n_txs: block.txs.len() as u64,
-            timestamp: block.header.timestamp.inner(),
-            powtype,
-            hash: block.header.hash().to_string(),
-        }
-    }
-
-    fn to_json(&self) -> JsonValue {
-        JsonValue::Object(HashMap::from([
-            ("height".to_string(), JsonValue::Number(self.height as f64)),
-            ("size".to_string(), JsonValue::Number(self.size as f64)),
-            ("n_txs".to_string(), JsonValue::Number(self.n_txs as f64)),
-            ("timestamp".to_string(), JsonValue::Number(self.timestamp as f64)),
-            ("powtype".to_string(), JsonValue::String(self.powtype.clone())),
-            ("hash".to_string(), JsonValue::String(self.hash.clone())),
-        ]))
-    }
-}
-
 struct ContractCallInfo {
     contract_id: String,
     contract_tag: Option<String>,
@@ -328,18 +308,28 @@ impl Explorer {
             return JsonError::new(InternalError, None, id).into()
         };
 
-        let (mut blocks, n_blocks) = if n_blocks > height {
-            (Vec::with_capacity(height as usize), height)
-        } else {
-            (Vec::with_capacity(n_blocks as usize), n_blocks)
-        };
+        // Calculate how many blocks we can actually return
+        let start_height = height.saturating_sub(n_blocks.saturating_sub(1));
+        let mut blocks = Vec::with_capacity((height - start_height + 1) as usize);
 
-        for i in (0..=n_blocks).rev() {
-            let Ok(Some(block)) = self.get_block(i).await else {
+        for h in (start_height..=height).rev() {
+            let Ok(Some((header, tx_count, size))) = self.get_block_summary(h).await else {
                 return JsonError::new(InternalError, None, id).into()
             };
 
-            blocks.push(LatestBlockInfo::new(&block).await.to_json());
+            let powtype = match header.pow_data {
+                PowData::DarkFi => "DarkFi".to_string(),
+                PowData::Monero(_) => "Monero".to_string(),
+            };
+
+            blocks.push(JsonValue::Object(HashMap::from([
+                ("height".to_string(), JsonValue::Number(header.height as f64)),
+                ("size".to_string(), JsonValue::Number(size as f64)),
+                ("n_txs".to_string(), JsonValue::Number(tx_count as f64)),
+                ("timestamp".to_string(), JsonValue::Number(header.timestamp.inner() as f64)),
+                ("powtype".to_string(), JsonValue::String(powtype)),
+                ("hash".to_string(), JsonValue::String(header.hash().to_string())),
+            ])));
         }
 
         JsonResponse::new(JsonValue::Array(blocks), id).into()
@@ -405,4 +395,100 @@ impl Explorer {
         let info = ExplTxInfo::new(&tx, block_height, current_height).await;
         JsonResponse::new(info.to_json(), id).into()
     }
+
+    /// Search for a block or transaction by hash.
+    /// Returns `{"type": "block", "height": N}` or `{"type": "tx"}` depending on what was found.
+    pub async fn rpc_search(&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 query = params[0].get::<String>().unwrap();
+
+        // Try to decode as hex
+        let Ok(hash_bytes) = hex::decode(query) else {
+            return JsonError::new(InvalidParams, None, id).into()
+        };
+
+        // Try block hash first (serialized blake3 hash)
+        if let Ok(Some(height_bytes)) = self.header_indices.get(&hash_bytes) {
+            let height = u64::from_le_bytes(height_bytes.as_ref().try_into().unwrap_or([0u8; 8]));
+            return JsonResponse::new(
+                JsonValue::Object(HashMap::from([
+                    ("type".to_string(), JsonValue::String("block".to_string())),
+                    ("height".to_string(), JsonValue::Number(height as f64)),
+                ])),
+                id,
+            )
+            .into()
+        }
+
+        // Try transaction hash (32 bytes)
+        if hash_bytes.len() == 32 {
+            let mut tx_hash = [0u8; 32];
+            tx_hash.copy_from_slice(&hash_bytes);
+            if self.tx_indices.get(tx_hash).ok().flatten().is_some() {
+                return JsonResponse::new(
+                    JsonValue::Object(HashMap::from([(
+                        "type".to_string(),
+                        JsonValue::String("tx".to_string()),
+                    )])),
+                    id,
+                )
+                .into()
+            }
+        }
+
+        // Not found
+        JsonError::new(InternalError, Some("Not found".to_string()), id).into()
+    }
+
+    /// Calculate the current network hashrate.
+    /// Hashrate = difficulty / average_block_time
+    /// We use the last N blocks to smooth out variance.
+    pub async fn rpc_get_hashrate(&self, id: u16, _params: JsonValue) -> JsonResult {
+        const BLOCKS_TO_AVERAGE: u64 = 30;
+
+        let Ok(Some(height)) = self.get_height() else {
+            return JsonError::new(InternalError, None, id).into()
+        };
+
+        if height < 2 {
+            return JsonResponse::new(JsonValue::Number(0.0), id).into()
+        }
+
+        let start_height = height.saturating_sub(BLOCKS_TO_AVERAGE);
+
+        // Get timestamps from start and end blocks
+        let Ok(Some(start_header)) = self.get_header(start_height).await else {
+            return JsonError::new(InternalError, None, id).into()
+        };
+        let Ok(Some(end_header)) = self.get_header(height).await else {
+            return JsonError::new(InternalError, None, id).into()
+        };
+
+        let time_diff = end_header.timestamp.inner() as f64 - start_header.timestamp.inner() as f64;
+        let blocks_mined = (height - start_height) as f64;
+
+        if time_diff <= 0.0 || blocks_mined <= 0.0 {
+            return JsonResponse::new(JsonValue::Number(0.0), id).into()
+        }
+
+        // Get current difficulty
+        let Ok(Some(diff)) = self.get_difficulty(height) else {
+            return JsonError::new(InternalError, None, id).into()
+        };
+
+        // Average block time in seconds
+        let avg_block_time = time_diff / blocks_mined;
+
+        // Hashrate = difficulty / block_time
+        // This approximates hashes per second needed to find a block at current difficulty
+        let hashrate = (diff.difficulty as f64) / avg_block_time;
+
+        JsonResponse::new(JsonValue::Number(hashrate), id).into()
+    }
 }