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

explorer: Add WIP rewrite with Tapes db

x пре 6 месеци
родитељ
комит
2b0481558f

+ 2 - 2
.gitignore

@@ -36,8 +36,8 @@ witness.json
 /bin/vanityaddr/vanityaddr
 /vanityaddr
 
-/bin/explorer/explorerd/explorerd
-/explorerd
+/bin/explorer/explorer
+/explorer
 
 /bin/fud/fud/fud
 /fud

+ 57 - 78
Cargo.lock

@@ -841,6 +841,29 @@ dependencies = [
  "cipher",
 ]
 
+[[package]]
+name = "borsh"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f"
+dependencies = [
+ "borsh-derive",
+ "cfg_aliases",
+]
+
+[[package]]
+name = "borsh-derive"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c"
+dependencies = [
+ "once_cell",
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
+
 [[package]]
 name = "bridgetree"
 version = "0.7.0"
@@ -945,6 +968,20 @@ name = "bytemuck"
 version = "1.24.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4"
+dependencies = [
+ "bytemuck_derive",
+]
+
+[[package]]
+name = "bytemuck_derive"
+version = "1.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
 
 [[package]]
 name = "byteorder"
@@ -2794,31 +2831,22 @@ dependencies = [
 ]
 
 [[package]]
-name = "explorerd"
+name = "explorer"
 version = "0.5.0"
 dependencies = [
+ "async-channel 2.5.0",
  "async-trait",
- "blake3",
+ "bytemuck",
  "darkfi",
  "darkfi-sdk",
  "darkfi-serial",
  "easy-parallel",
- "lazy_static",
- "serde",
- "signal-hook",
- "signal-hook-async-std",
- "sled-overlay",
+ "hex",
+ "sled",
  "smol",
- "structopt",
- "structopt-toml",
- "tar",
- "tempdir",
- "thiserror 2.0.17",
+ "tapes",
  "tinyjson",
- "toml 0.9.11+spec-1.1.0",
  "tracing",
- "tracing-appender",
- "tracing-subscriber",
  "url",
 ]
 
@@ -3111,12 +3139,6 @@ dependencies = [
  "url",
 ]
 
-[[package]]
-name = "fuchsia-cprng"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba"
-
 [[package]]
 name = "fud"
 version = "0.5.0"
@@ -5624,19 +5646,6 @@ dependencies = [
  "ptr_meta 0.3.1",
 ]
 
-[[package]]
-name = "rand"
-version = "0.4.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "552840b97013b1a26992c11eac34bdd778e464601a4c2054b5f0bff7c6761293"
-dependencies = [
- "fuchsia-cprng",
- "libc",
- "rand_core 0.3.1",
- "rdrand 0.4.0",
- "winapi",
-]
-
 [[package]]
 name = "rand"
 version = "0.8.5"
@@ -5678,21 +5687,6 @@ dependencies = [
  "rand_core 0.9.5",
 ]
 
-[[package]]
-name = "rand_core"
-version = "0.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b"
-dependencies = [
- "rand_core 0.4.2",
-]
-
-[[package]]
-name = "rand_core"
-version = "0.4.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc"
-
 [[package]]
 name = "rand_core"
 version = "0.6.4"
@@ -5775,15 +5769,6 @@ dependencies = [
  "yasna",
 ]
 
-[[package]]
-name = "rdrand"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2"
-dependencies = [
- "rand_core 0.3.1",
-]
-
 [[package]]
 name = "rdrand"
 version = "0.8.3"
@@ -5916,15 +5901,6 @@ dependencies = [
  "windows-sys 0.52.0",
 ]
 
-[[package]]
-name = "remove_dir_all"
-version = "0.5.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7"
-dependencies = [
- "winapi",
-]
-
 [[package]]
 name = "rend"
 version = "0.5.3"
@@ -6961,6 +6937,19 @@ version = "1.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
 
+[[package]]
+name = "tapes"
+version = "0.1.0"
+source = "git+https://github.com/Cuprate/Tapes#a30a5261564b12b21b126599149a88d5a0c5087c"
+dependencies = [
+ "blake2",
+ "borsh",
+ "bytemuck",
+ "libc",
+ "parking_lot 0.12.5",
+ "slab",
+]
+
 [[package]]
 name = "tar"
 version = "0.4.44"
@@ -7016,16 +7005,6 @@ dependencies = [
  "url",
 ]
 
-[[package]]
-name = "tempdir"
-version = "0.3.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "15f2b5fb00ccdf689e0149d1b1b3c03fead81c2b37735d812fa8bddbbf41b6d8"
-dependencies = [
- "rand 0.4.6",
- "remove_dir_all",
-]
-
 [[package]]
 name = "tempfile"
 version = "3.24.0"
@@ -7978,7 +7957,7 @@ dependencies = [
  "rand_core 0.6.4",
  "rand_core 0.9.5",
  "rand_jitter",
- "rdrand 0.8.3",
+ "rdrand",
  "rsa",
  "safelog",
  "serde",

+ 1 - 1
Cargo.toml

@@ -17,7 +17,7 @@ bench = false
 members = [
     "bin/zkas",
     "bin/darkfid",
-    "bin/explorer/explorerd",
+    "bin/explorer",
     "bin/drk",
     "bin/fud/fu",
     "bin/fud/fud",

+ 13 - 16
Makefile

@@ -31,7 +31,7 @@ BINS = \
 	lilith \
 	taud \
 	vanityaddr \
-	explorerd \
+	explorer \
 	fud \
 	fu
 
@@ -108,16 +108,13 @@ vanityaddr:
 		RUST_TARGET="$(RUST_TARGET)" \
 		RUSTFLAGS="$(RUSTFLAGS)"
 
-explorerd:
-	$(MAKE) -C bin/explorer/$@ \
+explorer:
+	$(MAKE) -C bin/$@ \
 		PREFIX="$(PREFIX)" \
 		CARGO="$(CARGO)" \
 		RUST_TARGET="$(RUST_TARGET)" \
 		RUSTFLAGS="$(RUSTFLAGS)"
 
-explorerd_bundle_contracts_src: contracts
-	$(MAKE) -C bin/explorer/explorerd bundle_contracts_src
-
 fud:
 	$(MAKE) -C bin/fud/$@ \
 		PREFIX="$(PREFIX)" \
@@ -138,39 +135,39 @@ fmt:
 	$(CARGO) +nightly fmt --all
 
 # cargo install cargo-hack
-check: explorerd_bundle_contracts_src $(PROOFS_BIN)
+check: $(PROOFS_BIN)
 	RUSTFLAGS="$(RUSTFLAGS)" $(CARGO) hack check --target=$(RUST_TARGET) \
 		--release --feature-powerset --workspace
 
-clippy: explorerd_bundle_contracts_src $(PROOFS_BIN)
+clippy: $(PROOFS_BIN)
 	RUSTFLAGS="$(RUSTFLAGS)" $(CARGO) clippy --target=$(RUST_TARGET) \
 		--release --all-features --workspace --tests
 
-fix: explorerd_bundle_contracts_src $(PROOFS_BIN)
+fix: $(PROOFS_BIN)
 	RUSTFLAGS="$(RUSTFLAGS)" $(CARGO) clippy --target=$(RUST_TARGET) \
 		--release --all-features --workspace --tests --fix --allow-dirty
 
-rustdoc: explorerd_bundle_contracts_src $(PROOFS_BIN)
+rustdoc: $(PROOFS_BIN)
 	RUSTFLAGS="$(RUSTFLAGS)" $(CARGO) doc --target=$(RUST_TARGET) \
 		--release --all-features --workspace --document-private-items --no-deps
 
-test: explorerd_bundle_contracts_src $(PROOFS_BIN)
+test: $(PROOFS_BIN)
 	RUSTFLAGS="$(RUSTFLAGS)" $(CARGO) test --target=$(RUST_TARGET) \
 		--release --all-features --workspace
 
-bench-zk-from-json: explorerd_bundle_contracts_src $(PROOFS_BIN)
+bench-zk-from-json: $(PROOFS_BIN)
 	rm -f src/contract/test-harness/*.bin
 	RUSTFLAGS="$(RUSTFLAGS)" $(CARGO) bench --target=$(RUST_TARGET) \
 		--bench zk_from_json --all-features --workspace \
 		-- --save-baseline master
 
-bench: explorerd_bundle_contracts_src $(PROOFS_BIN)
+bench: $(PROOFS_BIN)
 	rm -f src/contract/test-harness/*.bin
 	RUSTFLAGS="$(RUSTFLAGS)" $(CARGO) bench --target=$(RUST_TARGET) \
 		--all-features --workspace \
 		-- --save-baseline master
 
-coverage: explorerd_bundle_contracts_src $(PROOFS_BIN)
+coverage: $(PROOFS_BIN)
 	RUSTFLAGS="$(RUSTFLAGS)" $(CARGO) llvm-cov --target=$(RUST_TARGET) \
 		--release --all-features --workspace --html
 
@@ -187,12 +184,12 @@ clean:
 	$(MAKE) -C bin/lilith clean
 	$(MAKE) -C bin/tau/taud clean
 	$(MAKE) -C bin/vanityaddr clean
-	$(MAKE) -C bin/explorer/explorerd clean
+	$(MAKE) -C bin/explorer clean
 	RUSTFLAGS="$(RUSTFLAGS)" $(CARGO) clean --target=$(RUST_TARGET) --release
 	rm -f $(PROOFS_BIN)
 
 distclean: clean
 	rm -rf target
 
-.PHONY: all $(BINS) explorerd_bundle_contracts_src fmt check clippy fix rustdoc \
+.PHONY: all $(BINS) fmt check clippy fix rustdoc \
 	test bench-zk-from-json bench coverage clean distclean

+ 24 - 0
bin/explorer/Cargo.toml

@@ -0,0 +1,24 @@
+[package]
+name = "explorer"
+version = "0.5.0"
+edition = "2021"
+
+[dependencies]
+darkfi = {path = "../../", features = ["blockchain", "rpc"]}
+darkfi-serial = {path = "../../src/serial", features = ["derive"]}
+darkfi-sdk = {path = "../../src/sdk"}
+
+async-channel = "2.5.0"
+async-trait = "0.1.89"
+easy-parallel = "3.3.1"
+smol = "2.0.2"
+
+tracing = "0.1.44"
+
+url = "2.5.7"
+tinyjson = "2.5.1"
+hex = "0.4.3"
+
+sled = "0.34.7"
+tapes = {git = "https://github.com/Cuprate/Tapes"}
+bytemuck = {version = "1.24.0", features = ["derive"]}

+ 42 - 0
bin/explorer/Makefile

@@ -0,0 +1,42 @@
+.POSIX:
+
+# Install prefix
+PREFIX = $(HOME)/.cargo
+
+# Cargo binary
+CARGO = cargo
+
+# Compile target
+RUST_TARGET = $(shell rustc -Vv | grep '^host: ' | cut -d' ' -f2)
+# Uncomment when doing musl static builds
+#RUSTFLAGS = -C target-feature=+crt-static -C link-self-contained=yes
+
+SRC = \
+	Cargo.toml \
+	../../Cargo.toml \
+	$(shell find src -type f -name '*.rs') \
+	$(shell find ../../src -type f -name '*.rs') \
+	$(shell find ../../src/contract -type f -name '*.wasm')
+
+BIN = $(shell grep '^name = ' Cargo.toml | cut -d' ' -f3 | tr -d '"')
+
+all: $(BIN)
+
+$(BIN): $(SRC)
+	RUSTFLAGS="$(RUSTFLAGS)" $(CARGO) build --target=$(RUST_TARGET) --release --package $@
+	cp -f ../../target/$(RUST_TARGET)/release/$@ $@
+	cp -f ../../target/$(RUST_TARGET)/release/$@ ../../$@
+
+clean:
+	RUSTFLAGS="$(RUSTFLAGS)" $(CARGO) clean --target=$(RUST_TARGET) --release --package $(BIN)
+	rm -f $(BIN) ../../$(BIN)
+
+install: all
+	mkdir -p $(DESTDIR)$(PREFIX)/bin
+	cp -f $(BIN) $(DESTDIR)$(PREFIX)/bin
+	chmod 755 $(DESTDIR)$(PREFIX)/bin/$(BIN)
+
+uninstall:
+	rm -f $(DESTDIR)$(PREFIX)/bin/$(BIN)
+
+.PHONY: all clean install uninstall

+ 114 - 0
bin/explorer/python/explorer.py

@@ -0,0 +1,114 @@
+#!/usr/bin/env python3
+from datetime import datetime, timezone
+
+from quart import Quart, render_template, abort, g
+
+from rpc_client import JsonRpcPool, JsonRpcError
+
+app = Quart(__name__)
+app.config.update(
+    RPC_HOST="127.0.0.1",
+    RPC_PORT="22222",
+    RPC_MIN_CONNECTIONS=5,
+    RPC_MAX_CONNECTIONS=50,
+    NETWORK="testnet",
+)
+
+# Global pool
+rpc: JsonRpcPool = None
+
+
+@app.before_serving
+async def startup():
+    global rpc
+    rpc = JsonRpcPool(
+        host=app.config["RPC_HOST"],
+        port=app.config["RPC_PORT"],
+        min_connections=app.config["RPC_MIN_CONNECTIONS"],
+        max_connections=app.config["RPC_MAX_CONNECTIONS"],
+    )
+    await rpc.start()
+    app.logger.info(f"RPC pool started: {app.config['RPC_HOST']}:{app.config['RPC_PORT']}")
+
+
+@app.after_serving
+async def shutdown():
+    await rpc.close()
+    app.logger.info("RPC pool closed")
+
+
+@app.errorhandler(JsonRpcError)
+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
+
+
+@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
+
+
+@app.route("/")
+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])
+
+    # 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")
+
+    return await render_template(
+        "index.html",
+        network=app.config["NETWORK"],
+        current_difficulty=current_difficulty[0],
+        current_height=current_height,
+        #mempool_txs=mempool_txs,
+        #mempool_txs_len=len(mempool_txs),
+        latest_blocks=latest_blocks,
+    )
+
+
+@app.route("/block/<int:block_height>")
+async def get_block_by_height(block_height: int):
+    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])    
+
+    dt = datetime.fromtimestamp(block["timestamp"], tz=timezone.utc)
+    block["timestamp"] = dt.strftime("%B %d, %Y at %I:%M %p UTC")
+    block["n_txs"] = len(block["txs"])
+
+    return await render_template(
+        "block.html",
+        network=app.config["NETWORK"],
+        current_difficulty=current_difficulty[0],
+        current_height=current_height,
+        block=block,
+    )
+
+
+@app.route("/tx/<tx_hash>")
+async def get_tx_by_hash(tx_hash: str):
+    current_difficulty = await rpc.call("current_difficulty", params=[])
+    current_height = await rpc.call("current_height", params=[])
+    tx = await rpc.call("get_tx", params=[tx_hash])
+
+    return await render_template(
+        "tx.html",
+        network=app.config["NETWORK"],
+        current_difficulty=current_difficulty[0],
+        current_height=current_height,
+        tx=tx,
+    )
+
+
+if __name__ == "__main__":
+    app.run(host="127.0.0.1", port=5000, debug=True)

+ 13 - 0
bin/explorer/python/hypercorn.toml

@@ -0,0 +1,13 @@
+# hypercorn --config hypercorn.toml app:app
+bind = ["unix:/run/explorer/hypercorn.sock"]
+workers = 8
+worker_class = "asyncio"
+
+accesslog = "/var/log/explorer/access.log"
+errorlog = "/var/log/explorer/error.log"
+loglevel = "info"
+
+graceful_timeout = 30
+keep_alive_timeout = 65
+
+umask = 0o117

+ 34 - 0
bin/explorer/python/nginx.conf

@@ -0,0 +1,34 @@
+upstream explorer {
+	server unix:/run/explorer/hypercorn.sock fail_timeout=0;
+	keepalive 32;
+}
+
+server {
+	listen 80;
+	server_name testnet.explorer.dark.fi;
+
+	access_log /var/log/nginx/explorer.access.log;
+	error_log /var/log/nginx/explorer.error.log;
+
+	add_header X-Frame-Options "SAMEORIGIN" always;
+	add_header X-Content-Type-Options "nosniff" always;
+	add_header X-XSS-Protection "1; mode=block" always;
+
+	location / {
+		proxy_pass http://explorer
+		proxy_redirect off;
+
+		proxy_set_header Host $host;
+		proxy_set_header X-Real-IP $remote_addr;
+		proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+		proxy_set_header X-Forwarded-Proto $scheme;
+
+		proxy_connect_timeout 60s;
+		proxy_send_timeout 60s;
+		proxy_read_timeout 60s;
+
+		proxy_buffering on;
+		proxy_buffer_size 4k;
+		proxy_buffers 8 16k;
+	}
+}

+ 175 - 0
bin/explorer/python/rpc_client.py

@@ -0,0 +1,175 @@
+import asyncio
+import json
+from dataclasses import dataclass
+from typing import Any
+from contextlib import asynccontextmanager
+
+
+class JsonRpcError(Exception):
+    def __init__(self, code: int, message: str, data: Any = None):
+        self.code = code
+        self.message = message
+        self.data = data
+        super().__init__(f"RPC Error {code}: {message}")
+
+
+class JsonRpcConnection:
+    """Single JSON-RPC connection over TCP"""
+
+    def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
+        self.reader = reader
+        self.writer = writer
+        self.request_id = 0
+        self._lock = asyncio.Lock()
+        self._closed = False
+
+    @property
+    def is_closed(self) -> bool:
+        return self._closed or self.writer.is_closing()
+
+    async def call(self, method: str, params: Any = None, timeout: float = 30.0) -> Any:
+        if self.is_closed:
+            raise ConnectionError("Connection is closed")
+
+        async with self._lock:
+            self.request_id += 1
+            request = {
+                "jsonrpc": "2.0",
+                "method": method,
+                "id": self.request_id,
+                "params": params,
+            }
+
+            message = json.dumps(request) + "\n"
+
+            try:
+                self.writer.write(message.encode("utf-8"))
+                await self.writer.drain()
+
+                response_line = await asyncio.wait_for(
+                    self.reader.readline(),
+                    timeout=timeout
+                )
+            except asyncio.TimeoutError:
+                self._closed = True
+                raise TimeoutError(f"RPC call '{method}' timed out after {timeout}s")
+            except (ConnectionError, OSError) as e:
+                self._closed = True
+                raise ConnectionError(f"Connection lost: {e}")
+
+            if not response_line:
+                self._closed = True
+                raise ConnectionError("Connection closed by server")
+
+            response = json.loads(response_line.decode("utf-8"))
+
+            if "error" in response and response["error"]:
+                err = response["error"]
+                raise JsonRpcError(
+                    err.get("code", -1),
+                    err.get("message", "Unknown error"),
+                    err.get("data")
+                )
+
+            return response.get("result")
+
+    async def close(self):
+        if not self._closed:
+            self._closed = True
+            self.writer.close()
+            try:
+                await self.writer.wait_closed()
+            except Exception:
+                pass
+
+
+class JsonRpcPool:
+    """Connection pool with automatic reconnection"""
+
+    def __init__(
+        self,
+        host: str,
+        port: int,
+        min_connections: int = 5,
+        max_connections: int = 20,
+    ):
+        self.host = host
+        self.port = port
+        self.min_connections = min_connections
+        self.max_connections = max_connections
+
+        self._pool: asyncio.Queue[JsonRpcConnection] = None
+        self._semaphore: asyncio.Semaphore = None
+        self._connection_count = 0
+        self._lock = asyncio.Lock()
+        self._closed = False
+
+    async def start(self):
+        """Initialize the pool with minimum connections"""
+        self._pool = asyncio.Queue()
+        self._semaphore = asyncio.Semaphore(self.max_connections)
+        self._connection_count = 0
+
+        for _ in range(self.min_connections):
+            try:
+                conn = await self._create_connection()
+                await self._pool.put(conn)
+            except Exception as e:
+                print(f"Warning: Failed to create initial connection: {e}")
+
+    async def _create_connection(self) -> JsonRpcConnection:
+        reader, writer = await asyncio.open_connection(self.host, self.port)
+        async with self._lock:
+            self._connection_count += 1
+        return JsonRpcConnection(reader, writer)
+
+    async def _destroy_connection(self, conn: JsonRpcConnection):
+        await conn.close()
+        async with self._lock:
+            self._connection_count -= 1
+
+    @asynccontextmanager
+    async def connection(self):
+        """Acquire a connection from the pool"""
+        if self._closed:
+            raise RuntimeError("Pool's closed")
+
+        conn = None
+
+        async with self._semaphore:
+            # Try to get an existing connection
+            while not self._pool.empty():
+                conn = await self._pool.get()
+                if not conn.is_closed:
+                    break
+                await self._destroy_connection(conn)
+                conn = None
+
+            # Create new if needed
+            if conn is None:
+                conn = await self._create_connection()
+
+            try:
+                yield conn
+            except (ConnectionError, TimeoutError):
+                # Connection is bad, don't return to pool
+                await self._destroy_connection(conn)
+                raise
+            else:
+                # Return healthy connection to pool
+                if not conn.is_closed:
+                    await self._pool.put(conn)
+                else:
+                    await self._destroy_connection(conn)
+
+    async def call(self, method: str, params: Any = None, timeout: float = 30.0) -> Any:
+        """Make an RPC call using a pooled connection"""
+        async with self.connection() as conn:
+            return await conn.call(method, params, timeout)
+
+    async def close(self):
+        """Close all connections"""
+        self._closed = True
+        while not self._pool.empty():
+            conn = await self._pool.get()
+            await self._destroy_connection(conn)

+ 625 - 0
bin/explorer/python/static/layout.css

@@ -0,0 +1,625 @@
+:root {
+  font-size: 62.5%;
+  color-scheme: dark;
+
+  --color-bg: #0a0a0a;
+  --color-surface: #0f1a15;
+  --color-border: #2a2a2a;
+  --color-text: #e0e0e0;
+  --color-muted: #888888;
+  --color-hover: #1a1a1a;
+
+  --color-accent: #00ffff;
+  --color-accent-hover: #bd90f8;
+  --color-highlight: #ff69b4;
+
+  --h1-border-color: #00ffff;
+  --h2-border-color: #6a9eff;
+  --h3-border-color: #c4003b;
+  --h4-border-color: #ff69b4;
+  --h5-border-color: #00ff7f;
+
+  --table-border-color: #1e4242;
+  --table-header-bg: #070935;
+
+  --code-bg: #051313;
+  --code-border: #41121c;
+  --inline-code-color: #57a2ce;
+
+  --quote-bg: #051111;
+  --quote-border: #361016;
+
+  --radius: 4px;
+  --shadow: 0 1px 3px rgba(0, 255, 255, 0.1);
+  --shadow-hover: 0 4px 12px rgba(0, 255, 255, 0.2);
+  --transition: 0.2s ease;
+
+  --font-sans: "Inter", system-ui, -apple-system, sans-serif;
+  --font-display: "InterDisplay", var(--font-sans);
+  --font-mono: "Iosevka", "Iosevka Web", monospace;
+}
+
+*, *::before, *::after {
+  box-sizing: border-box;
+  margin: 0;
+  padding: 0;
+}
+
+html {
+  font-family: var(--font-sans);
+  color: var(--color-text);
+  background-color: var(--color-bg);
+  -webkit-text-size-adjust: none;
+  text-size-adjust: none;
+}
+
+body {
+  font-size: 1.6rem;
+  line-height: 1.5;
+  overflow-x: hidden;
+  min-height: 100vh;
+}
+
+h1 {
+  font-family: var(--font-display);
+  font-weight: 900;
+  font-size: 2.8rem;
+  border-left: 3px solid var(--h1-border-color);
+  padding-inline-start: 12px;
+  margin-bottom: 1.5rem;
+}
+
+h2, h3, h4, h5 {
+  font-family: var(--font-sans);
+  font-weight: 700;
+  border-left: 3px solid;
+  padding-inline-start: 12px;
+}
+
+h2 { font-size: 2.2rem; border-left-color: var(--h2-border-color); margin-block-start: 2.5em; }
+h3 { font-size: 1.8rem; border-left-color: var(--h3-border-color); margin-block-start: 2.5em; }
+h4 { font-size: 1.6rem; border-left-color: var(--h4-border-color); margin-block-start: 2em; }
+h5 { font-size: 1.4rem; border-left-color: var(--h5-border-color); margin-block-start: 2em; }
+
+p, ul, ol { line-height: 1.45em; }
+ul { list-style-type: square; }
+
+a {
+  color: var(--color-accent);
+  text-decoration: none;
+  transition: color var(--transition);
+}
+
+a:hover { color: var(--color-accent-hover); }
+
+code {
+  font-family: var(--font-mono);
+  font-size: 1.4rem;
+  color: var(--inline-code-color);
+  background-color: var(--code-bg);
+  border: 1px solid var(--code-border);
+  padding: 0.2em 0.4em;
+  border-radius: 3px;
+}
+
+.container {
+  width: 100%;
+  max-width: 1200px;
+  margin: 0 auto;
+  padding: 0 1.5rem;
+}
+
+.container-sm { max-width: 720px; }
+.container-lg { max-width: 1400px; }
+
+.grid { display: grid; gap: 1.5rem; }
+.grid-2 { grid-template-columns: repeat(2, 1fr); }
+.grid-3 { grid-template-columns: repeat(3, 1fr); }
+.grid-4 { grid-template-columns: repeat(4, 1fr); }
+.grid-auto { grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); }
+
+.flex { display: flex; }
+.flex-wrap { flex-wrap: wrap; }
+.flex-col { flex-direction: column; }
+.items-center { align-items: center; }
+.justify-center { justify-content: center; }
+.justify-between { justify-content: space-between; }
+.gap-sm { gap: 0.5rem; }
+.gap { gap: 1.5rem; }
+.gap-lg { gap: 2rem; }
+
+.box {
+  background: var(--color-surface);
+  border: 1px solid var(--table-border-color);
+  border-radius: var(--radius);
+  padding: 1.5rem;
+}
+
+.box-outline {
+  background: transparent;
+  border: 1px solid var(--table-border-color);
+  border-radius: var(--radius);
+  padding: 1.5rem;
+}
+
+.box-shadow {
+  background: var(--color-surface);
+  border-radius: var(--radius);
+  padding: 1.5rem;
+  box-shadow: var(--shadow);
+}
+
+.hover {
+  transition: all var(--transition);
+  cursor: pointer;
+}
+
+.hover:hover {
+  background: var(--color-hover);
+  border-color: var(--color-accent);
+}
+
+.hover-lift {
+  transition: all var(--transition);
+  cursor: pointer;
+}
+
+.hover-lift:hover {
+  transform: translateY(-2px);
+  box-shadow: var(--shadow-hover);
+}
+
+.hover-border { transition: border-color var(--transition); }
+.hover-border:hover { border-color: var(--color-highlight); }
+
+.p-0 { padding: 0; }
+.p-sm { padding: 0.5rem; }
+.p { padding: 1.5rem; }
+.p-lg { padding: 2rem; }
+
+.m-0 { margin: 0; }
+.m-sm { margin: 0.5rem; }
+.m { margin: 1.5rem; }
+.m-lg { margin: 2rem; }
+.mx-auto { margin-inline: auto; }
+
+.mt { margin-top: 1.5rem; }
+.mb { margin-bottom: 1.5rem; }
+.mt-lg { margin-top: 2rem; }
+.mb-lg { margin-bottom: 2rem; }
+
+.text-center { text-align: center; }
+.text-muted { color: var(--color-muted); }
+.text-sm { font-size: 1.4rem; }
+.text-lg { font-size: 2rem; }
+.text-bold { font-weight: 700; }
+.mono { font-family: var(--font-mono); }
+
+.section { padding: 3rem 0; }
+
+.row {
+  display: flex;
+  flex-wrap: wrap;
+  margin: -0.75rem;
+}
+
+.row > * { padding: 0.75rem; }
+
+.col { flex: 1; }
+.col-auto { flex: 0 0 auto; }
+.col-6 { flex: 0 0 50%; max-width: 50%; }
+.col-4 { flex: 0 0 33.333%; max-width: 33.333%; }
+.col-3 { flex: 0 0 25%; max-width: 25%; }
+
+.input {
+  flex: 1;
+  padding: 0.8rem 1.2rem;
+  font-size: 1.6rem;
+  font-family: inherit;
+  border: 1px solid var(--color-border);
+  border-radius: var(--radius);
+  background: var(--color-bg);
+  color: var(--color-text);
+  outline: none;
+  transition: border-color var(--transition), box-shadow var(--transition);
+}
+
+.input:focus {
+  border-color: var(--color-accent);
+  box-shadow: 0 0 0 3px rgba(0, 255, 255, 0.15);
+}
+
+.input::placeholder { color: var(--color-muted); }
+
+.btn {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: 0.5rem;
+  padding: 0.8rem 1.6rem;
+  font-size: 1.6rem;
+  font-family: inherit;
+  font-weight: 600;
+  border: 1px solid transparent;
+  border-radius: var(--radius);
+  cursor: pointer;
+  transition: all var(--transition);
+  background: var(--color-accent);
+  color: var(--color-bg);
+}
+
+.btn:hover { background: var(--color-highlight); }
+.btn:active { transform: scale(0.98); }
+
+.btn-outline {
+  background: transparent;
+  border-color: var(--table-border-color);
+  color: var(--color-text);
+}
+
+.btn-outline:hover {
+  background: var(--color-hover);
+  border-color: var(--color-accent);
+  color: var(--color-accent);
+}
+
+.input-group { display: flex; }
+.input-group .input {
+  border-top-right-radius: 0;
+  border-bottom-right-radius: 0;
+  border-right: none;
+}
+.input-group .btn {
+  border-top-left-radius: 0;
+  border-bottom-left-radius: 0;
+}
+
+.header {
+  background: var(--color-bg);
+  border-bottom: 1px solid var(--table-border-color);
+  padding: 1.5rem 0;
+}
+
+.logo {
+  font-family: var(--font-display);
+  font-size: 2rem;
+  font-weight: 900;
+  color: var(--color-text);
+}
+
+.logo span { color: var(--color-accent); }
+
+.nav { display: flex; gap: 2rem; }
+.nav a {
+  color: var(--color-muted);
+  font-weight: 500;
+  font-size: 1.5rem;
+}
+.nav a:hover { color: var(--color-highlight); }
+.nav a.active { color: var(--color-accent); }
+
+.stats-grid {
+  display: grid;
+  grid-template-columns: repeat(4, 1fr);
+  gap: 1.5rem;
+}
+
+.stat-card {
+  background: var(--color-surface);
+  border: 1px solid var(--table-border-color);
+  border-radius: var(--radius);
+  padding: 1.5rem;
+  text-align: center;
+}
+
+.stat-card .label {
+  font-size: 1.1rem;
+  color: var(--color-muted);
+  text-transform: uppercase;
+  letter-spacing: 0.1em;
+  margin-bottom: 0.4rem;
+}
+
+.stat-card .value {
+  font-size: 1.8rem;
+  font-weight: 600;
+  color: var(--color-accent);
+  font-family: var(--font-mono);
+  letter-spacing: 0.05em;
+}
+
+.table-section {
+  background: var(--color-bg);
+  border: 1px solid var(--table-border-color);
+  border-radius: var(--radius);
+  overflow: hidden;
+}
+
+.table-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 1.5rem;
+  border-bottom: 1px solid var(--table-border-color);
+  background: var(--table-header-bg);
+}
+
+.table-header h3 {
+  font-size: 1.6rem;
+  font-weight: 700;
+  color: var(--color-text);
+  border-left: none;
+  padding-inline-start: 0;
+  margin: 0;
+}
+
+.badge {
+  background: var(--color-accent);
+  color: var(--color-bg);
+  font-size: 1.2rem;
+  padding: 0.3rem 0.8rem;
+  border-radius: 3px;
+  font-weight: 600;
+}
+
+.badge-pink { background: var(--color-highlight); }
+
+.table-wrapper { overflow-x: auto; }
+
+.table-scroll {
+  overflow: auto;
+  max-height: 300px;
+}
+
+table {
+  width: 100%;
+  border-collapse: collapse;
+  font-size: 1.4rem;
+  font-family: var(--font-mono);
+  letter-spacing: 0.05em;
+}
+
+thead {
+  background: var(--table-header-bg);
+  position: sticky;
+  top: 0;
+  z-index: 1;
+}
+
+th {
+  text-align: left;
+  padding: 1rem 1.5rem;
+  color: var(--color-muted);
+  font-weight: 600;
+  font-size: 1.1rem;
+  text-transform: uppercase;
+  letter-spacing: 0.1em;
+  border-bottom: 1px solid var(--table-border-color);
+}
+
+td {
+  padding: 1rem 1.5rem;
+  border-bottom: 1px solid var(--table-border-color);
+  color: var(--color-text);
+}
+
+tr:last-child td { border-bottom: none; }
+tbody tr:hover td { background: var(--color-hover); }
+
+.hash {
+  font-family: var(--font-mono);
+  font-size: 1.3rem;
+  color: var(--color-accent);
+}
+
+.hash:hover { color: var(--color-highlight); }
+
+.height { font-weight: 700; }
+
+.info-table { width: 100%; }
+
+.info-table td {
+  padding: 1rem 1.5rem;
+  border-bottom: 1px solid var(--table-border-color);
+  vertical-align: top;
+}
+
+.info-table tr:last-child td { border-bottom: none; }
+
+.info-table .info-label {
+  width: 200px;
+  color: var(--color-muted);
+  font-weight: 400;
+  font-family: var(--font-sans);
+  font-size: 1.4rem;
+}
+
+.info-table td:last-child {
+  font-family: var(--font-mono);
+  font-size: 1.4rem;
+  word-break: break-all;
+}
+
+.block-title {
+  border-bottom: 1px solid var(--table-border-color);
+  padding-bottom: 1.5rem;
+}
+
+.block-title h1 {
+  font-size: 2.4rem;
+  margin-bottom: 0.8rem;
+  border-left: none;
+  padding-inline-start: 0;
+}
+
+.block-nav {
+  color: var(--color-muted);
+  padding: 0 0.5rem;
+}
+
+.block-nav:hover { color: var(--color-accent); }
+
+.block-hash {
+  font-family: var(--font-mono);
+  font-size: 1.3rem;
+  color: var(--color-muted);
+  word-break: break-all;
+  line-height: 1.6;
+}
+
+.pow-monero { border-color: #ff6600; }
+
+.pow-monero .table-header {
+  background: linear-gradient(90deg, rgba(255, 102, 0, 0.1) 0%, transparent 100%);
+  border-bottom-color: rgba(255, 102, 0, 0.3);
+}
+
+.badge-monero { background: #ff6600; color: #fff; }
+.hash-monero { color: #ff6600 !important; }
+.hash-monero:hover { color: #ff9933 !important; }
+
+.pow-native .table-header { border-bottom: none; }
+.pow-native-info { padding: 1.5rem; font-size: 1.4rem; }
+
+.tx-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 1.5rem;
+  align-items: start;
+}
+
+.contract-calls { padding: 0; }
+
+.call-item {
+  display: flex;
+  gap: 1.5rem;
+  padding: 1.2rem 1.5rem;
+  border-bottom: 1px solid var(--table-border-color);
+}
+
+.call-item:last-child { border-bottom: none; }
+.call-item:hover { background: var(--color-hover); }
+
+.call-index {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  min-width: 3rem;
+  height: 3rem;
+  background: var(--color-accent);
+  color: var(--color-bg);
+  font-family: var(--font-mono);
+  font-size: 1.3rem;
+  font-weight: 700;
+  border-radius: var(--radius);
+  flex-shrink: 0;
+}
+
+.call-details { flex: 1; min-width: 0; }
+
+.call-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: baseline;
+  gap: 1rem;
+  padding: 0.3rem 0;
+}
+
+.call-label { font-size: 1.3rem; color: var(--color-muted); }
+
+.call-value {
+  font-family: var(--font-mono);
+  font-size: 1.3rem;
+  color: var(--color-text);
+  text-align: right;
+  word-break: break-all;
+}
+
+.contract-tag {
+  font-family: var(--font-sans);
+  font-size: 1.1rem;
+  color: var(--color-muted);
+  font-style: italic;
+  margin-left: 0.5rem;
+}
+
+.call-byte { color: var(--color-accent); margin-right: 0.5rem; }
+
+.code-block {
+  padding: 1.5rem;
+  background: var(--code-bg);
+  border-top: 1px solid var(--code-border);
+  overflow-x: auto;
+}
+
+.code-block code {
+  font-size: 1.3rem;
+  color: var(--inline-code-color);
+  background: none;
+  border: none;
+  padding: 0;
+  word-break: break-all;
+  white-space: pre-wrap;
+}
+
+.footer {
+  border-top: 1px solid var(--table-border-color);
+  padding: 3rem 0;
+  text-align: center;
+  color: var(--color-muted);
+  font-size: 1.4rem;
+}
+
+blockquote {
+  margin: 2rem 0;
+  padding: 0.5rem 2rem;
+  color: var(--color-text);
+  background-color: var(--quote-bg);
+  border-left: 3px solid var(--quote-border);
+}
+
+::-webkit-scrollbar { width: 8px; height: 8px; }
+::-webkit-scrollbar-track { background: var(--color-bg); }
+::-webkit-scrollbar-thumb { background: var(--table-border-color); border-radius: 4px; }
+::-webkit-scrollbar-thumb:hover { background: var(--color-accent); }
+
+@media (max-width: 768px) {
+  .grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; }
+  .stats-grid { grid-template-columns: repeat(2, 1fr); }
+  .tx-grid { grid-template-columns: 1fr; }
+
+  .row { flex-direction: column; }
+  .col-6, .col-4, .col-3 { flex: 0 0 100%; max-width: 100%; }
+
+  .input-group { flex-direction: row; }
+  .nav { gap: 1.5rem; font-size: 1.4rem; }
+
+  .hash {
+    max-width: 120px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    display: inline-block;
+  }
+
+  table { font-size: 1.3rem; }
+  th, td { padding: 0.75rem 1rem; }
+
+  .info-table .info-label { width: 140px; font-size: 1.3rem; }
+  .info-table td:last-child { font-size: 1.3rem; }
+
+  .block-hash { font-size: 1.1rem; }
+
+  .call-row { flex-direction: column; align-items: flex-start; gap: 0.2rem; }
+  .call-value { text-align: left; }
+
+  .hide-mobile { display: none; }
+}
+
+@media (min-width: 769px) {
+  .hide-desktop { display: none; }
+}
+
+@media (min-width: 769px) and (max-width: 1024px) {
+  .grid-4, .stats-grid { grid-template-columns: repeat(2, 1fr); }
+}

+ 208 - 0
bin/explorer/python/templates/block.html

@@ -0,0 +1,208 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>DarkFi Blocks - Block Explorer</title>
+  <link rel="stylesheet" href="/static/layout.css">
+</head>
+<body>
+  <header class="header">
+    <div class="container flex justify-between items-center">
+      <div class="logo"><span>Dark</span>Fi {{ network }} Blocks</div>
+      <nav class="nav">
+        <a href="/" class="active">Explorer</a>
+        <a href="#">Stats</a>
+        <a href="https://dark.fi/book/">Docs</a>
+        <a href="https://codeberg.org/darkrenaissance/darkfi/">Code</a>
+      </nav>
+    </div>
+  </header>
+
+  <main class="container section">
+
+    <div class="input-group mb-lg" style="max-width: 600px;">
+      <input type="search" class="input" placeholder="Search by block height, hash, or transaction...">
+      <button type="submit" class="btn">Search</button>
+    </div>
+
+    <div class="stats-grid mb-lg">
+      <div class="stat-card">
+        <div class="label">Difficulty</div>
+        <div class="value">{{ current_difficulty }}</div>
+      </div>
+      <div class="stat-card">
+        <div class="label">Height</div>
+        <div class="value">{{ current_height }}</div>
+      </div>
+      <div class="stat-card">
+        <div class="label">Hashrate</div>
+        <div class="value">n/a</div>
+      </div>
+      <div class="stat-card">
+        <div class="label">Emission</div>
+        <div class="value">n/a</div>
+      </div>
+    </div>
+
+    <div class="block-title mb-lg">
+      <h1>
+        Block
+        {% if block.height != 0 %}
+        <a href="/block/{{ block.height - 1 }}" class="block-nav">&lt</a>
+        {% else %}
+        <a href="/block/0" class="block-nav">&lt</a>
+        {% endif %}
+        {{ block.height }}
+        <a href="/block/{{ block.height + 1 }}" class="block-nav">&gt;</a>
+      </h1>
+      <span class="block-hash">{{ block.hash }}</span>
+    </div>
+
+    <div class="table-section mb-lg">
+      <div class="table-header">
+        <h3>Block Info</h3>
+      </div>
+      <div class="table-wrapper">
+        <table class="info-table">
+          <tbody>
+            <tr>
+              <td class="info-label">Version</td>
+              <td>{{ block.version }}</td>
+            </tr>
+            <tr>
+              <td class="info-label">Previous Hash</td>
+              <td><a href="/block/{{ block.height -1 }}" class="hash">{{ block.previous_hash }}</a></td>
+            </tr>
+            <tr>
+              <td class="info-label">Height</td>
+              <td>{{ block.height }}</td>
+            </tr>
+            <tr>
+              <td class="info-label">Nonce</td>
+              <td>{{ block.nonce }}</td>
+            </tr>
+            <tr>
+              <td class="info-label">Timestamp</td>
+              <td>{{ block.timestamp }}</td>
+            </tr>
+            <tr>
+              <td class="info-label">Transactions Root</td>
+              <td class="hash">{{ block.transactions_root }}</td>
+            </tr>
+            <tr>
+              <td class="info-label">State Root</td>
+              <td class="hash">{{ block.state_root }}</td>
+            </tr>
+            <tr>
+              <td class="info-label">Block Size (bytes)</td>
+              <td>{{ block.size }}</td>
+            </tr>
+            <tr>
+              <td class="info-label">Block Difficulty</td>
+              <td>{{ block.difficulty }}</td>
+            </tr>
+            <tr>
+              <td class="info-label">Cumulative Difficulty</td>
+              <td>{{ block.cumulative }}</td>
+            </tr>
+            <tr>
+              <td class="info-label">Transactions</td>
+              <td>{{ block.n_txs }}</td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+
+    {% if block.powtype == "Monero" %}
+    <div class="table-section mb-lg pow-monero">
+      <div class="table-header">
+        <h3>PoW Data</h3>
+        <span class="badge badge-monero">Merge Mined · Monero</span>
+      </div>
+      <div class="table-wrapper">
+        <table class="info-table">
+          <tbody>
+            <tr>
+              <td class="info-label">Monero Block Hash</td>
+              <td><a href="https://localmonero.co/blocks/block/{{ monero_hash }}" target="_blank" class="hash hash-monero">{{ monero_hash }}</a></td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+    {% else %}
+    <div class="table-section mb-lg pow-native">
+      <div class="table-header">
+        <h3>PoW Data</h3>
+        <span class="badge">Native · DarkFi</span>
+      </div>
+      <div class="pow-native-info">
+        <span class="text-muted">Native DarkFi proof-of-work</span>
+      </div>
+    </div>
+    {% endif %}
+
+    <div class="table-section mb-lg">
+      <div class="table-header">
+        <h3>Coinbase Transaction</h3>
+      </div>
+      <div class="table-wrapper">
+        <table>
+          <thead>
+            <tr>
+              <th>Hash</th>
+              <th>Amount</th>
+              <th>Bytes</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr>
+              <td><a href="/tx/{{ block.coinbase.hash }}" class="hash">{{ block.coinbase.hash }}</a></td>
+              <td>{{ block.coinbase.reward }}</td>
+              <td>{{ block.coinbase.size }}</td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+
+    <div class="table-section">
+      <div class="table-header">
+        <h3>Transactions ({{ block.n_txs }})</h3>
+      </div>
+      <div class="table-scroll">
+        <table>
+          <thead>
+            <tr>
+              <th>Hash</th>
+              <th>Calls</th>
+              <th>Fee</th>
+              <th>Bytes</th>
+            </tr>
+          </thead>
+          <tbody>
+          {% for tx in block.txs %}
+            <tr>
+              <td><a href="/tx/{{ tx.hash }}" class="hash">{{ tx.hash }}</a></td>
+              <td>{{ tx.n_calls }}</td>
+              <td>{{ tx.fee }}</td>
+              <td>{{ tx.size }}</td>
+            </tr>
+          {% endfor %}
+          </tbody>
+        </table>
+      </div>
+    </div>
+
+  </main>
+
+  <footer class="footer">
+    <div class="container">
+      <a href="https://dark.fi">Let there be Dark</a> · Code licensed under AGPL-3
+    </div>
+  </footer>
+
+</body>
+</html>

+ 120 - 0
bin/explorer/python/templates/index.html

@@ -0,0 +1,120 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>DarkFi Blocks - Block Explorer</title>
+  <link rel="stylesheet" href="/static/layout.css">
+</head>
+<body>
+  <header class="header">
+    <div class="container flex justify-between items-center">
+      <div class="logo"><span>Dark</span>Fi {{ network }} Blocks</div>
+      <nav class="nav">
+        <a href="/" class="active">Explorer</a>
+        <a href="#">Stats</a>
+        <a href="https://dark.fi/book/">Docs</a>
+        <a href="https://codeberg.org/darkrenaissance/darkfi/">Code</a>
+      </nav>
+    </div>
+  </header>
+
+  <main class="container section">
+
+    <div class="input-group mb-lg" style="max-width: 600px;">
+      <input type="search" class="input" placeholder="Search by block height, hash, or transaction...">
+      <button type="submit" class="btn">Search</button>
+    </div>
+
+    <div class="stats-grid mb-lg">
+      <div class="stat-card">
+        <div class="label">Difficulty</div>
+        <div class="value">{{ current_difficulty }}</div>
+      </div>
+      <div class="stat-card">
+        <div class="label">Height</div>
+        <div class="value">{{ current_height }}</div>
+      </div>
+      <div class="stat-card">
+        <div class="label">Hashrate</div>
+        <div class="value">n/a</div>
+      </div>
+      <div class="stat-card">
+        <div class="label">Emission</div>
+        <div class="value">n/a</div>
+      </div>
+    </div>
+
+	<!-- 
+    <div class="table-section mb-lg">
+      <div class="table-header">
+        <h3>Transaction Pool</h3>
+        <span class="badge">{{ mempool_txs_len }} Transactions</span>
+      </div>
+      <div class="table-scroll">
+        <table>
+          <thead>
+            <tr>
+              <th>Hash</th>
+              <th>Timestamp</th>
+              <th>Fee</th>
+              <th>Size</th>
+            </tr>
+          </thead>
+          <tbody>
+          	{% for tx in mempool_txs %}
+            <tr>
+              <td><a href="/tx/{{ tx.hash }}">{{ tx.hash }}</a></td>
+              <td>{{ tx.timestamp }}</td>
+              <td>{{ tx.fee }}</td>
+              <td>{{ tx.size }} B</td>
+          	{% endfor %}
+          </tbody>
+        </table>
+      </div>
+    </div>
+    -->
+
+    <div class="table-section">
+      <div class="table-header">
+        <h3>Latest Blocks</h3>
+        <a href="#">View all →</a>
+      </div>
+      <div class="table-wrapper">
+        <table>
+          <thead>
+            <tr>
+              <th>Height</th>
+              <th>Size</th>
+              <th>Txs</th>
+              <th>Timestamp</th>
+              <th>PoW</th>
+              <th>Block Hash</th>
+            </tr>
+          </thead>
+          <tbody>
+            {% for block in latest_blocks %}
+            <tr>
+              <td class="height"><a href="/block/{{ block.height }}">{{ block.height }}</a></td>
+              <td>{{ block.size }}</td>
+              <td>{{ block.n_txs }}</td>
+              <td>{{ block.timestamp }}</td>
+              <th>{{ block.powtype }}</td>
+              <td><a href="/block/{{ block.height }}">{{ block.hash }}</a></td>
+            </tr>
+            {% endfor %}
+          </tbody>
+        </table>
+      </div>
+    </div>
+
+  </main>
+
+  <footer class="footer">
+    <div class="container">
+      <a href="https://dark.fi">Let there be Dark</a>
+    </div>
+  </footer>
+
+</body>
+</html>

+ 124 - 0
bin/explorer/python/templates/tx.html

@@ -0,0 +1,124 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>DarkFi Blocks - Block Explorer</title>
+  <link rel="stylesheet" href="/static/layout.css">
+</head>
+<body>
+  <header class="header">
+    <div class="container flex justify-between items-center">
+      <div class="logo"><span>Dark</span>Fi {{ network }} Blocks</div>
+      <nav class="nav">
+        <a href="/" class="active">Explorer</a>
+        <a href="#">Stats</a>
+        <a href="https://dark.fi/book/">Docs</a>
+        <a href="https://codeberg.org/darkrenaissance/darkfi/">Code</a>
+      </nav>
+    </div>
+  </header>
+
+  <main class="container section">
+
+    <div class="input-group mb-lg" style="max-width: 600px;">
+      <input type="search" class="input" placeholder="Search by block height, hash, or transaction...">
+      <button type="submit" class="btn">Search</button>
+    </div>
+
+    <div class="stats-grid mb-lg">
+      <div class="stat-card">
+        <div class="label">Difficulty</div>
+        <div class="value">{{ current_difficulty }}</div>
+      </div>
+      <div class="stat-card">
+        <div class="label">Height</div>
+        <div class="value">{{ current_height }}</div>
+      </div>
+      <div class="stat-card">
+        <div class="label">Hashrate</div>
+        <div class="value">n/a</div>
+      </div>
+      <div class="stat-card">
+        <div class="label">Emission</div>
+        <div class="value">n/a</div>
+      </div>
+    </div>
+
+    <div class="block-title mb-lg">
+      <h1>Transaction</h1>
+      <span class="block-hash">{{ tx.hash }}</span>
+    </div>
+
+    <div class="tx-grid mb-lg">
+
+      <div class="table-section">
+        <div class="table-headear">
+          <h3>Transaction Info</h3>
+        </div>
+        <div class="table-wrapper">
+          <table class="info-table">
+            <tbody>
+              <tr>
+                <td class="info-label">Confirmations</td>
+                <td>{{ tx.confirmations }}</td>
+              </tr>
+              <tr>
+                <td class="info-label">From Block</td>
+                <td><a href="/block/{{ tx.from_block }}">{{ tx.from_block }}</a></td>
+              </tr>
+              <tr>
+                <td class="info-label">Fee</a>
+                <td>{{ tx.fee }}</td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+      </div>
+
+      <div class="table-section">
+        <div class="table-header">
+          <h3>Contract Calls ({{ tx.n_calls }})</h3>
+        </div>
+        <div class="contract-calls">
+        {% for call in tx.calls %}
+          <div class="call-item">
+            <div class="call-index">{{ loop.index0 }}</div>
+            <div class="call-details">
+              <div class="call-row">
+                <span class="call-label">Contract ID</span>
+                <span class="call-value"><a href="#" class="hash">{{ call.contract_id }}</a>
+                {% if call.contract_tag %}
+                  <span class="contract-tag">{{ call.contract_tag }}</span>
+                {% endif %}
+                </span
+                <div class="call-row">
+                  <span class="call-label">Data</span>
+                  <span class="call-value"><span class="call-byte">{{ call.func }}</span> {{ call.size }}</span>
+                </div>
+              </div>
+            </div>
+          {% endfor %}
+        </div>
+      </div>
+    </div>
+
+    <div class="table-section">
+      <div class="table-header">
+        <h3>Raw Transaction</h3>
+      </div>
+      <div class="code-block">
+        <code>{{ tx.raw }}</code>
+      </div>
+    </div>
+
+  </main>
+
+  <footer class="footer">
+    <div class="container">
+      <a href="https://dark.fi">Let there be Dark</a> · Code licensed under AGPL-3
+    </div>
+  </footer>
+
+</body>
+</html>

+ 385 - 0
bin/explorer/src/db.rs

@@ -0,0 +1,385 @@
+/* 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::io;
+
+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 tapes::{BlobTape, FixedSizedTape, Persistence, TapeOpenOptions, Tapes};
+use tracing::info;
+
+use super::Explorer;
+
+/// Index entry for a block pointing to block data in the blob tape
+#[derive(Debug, Copy, Clone, Pod, Zeroable)]
+#[repr(C)]
+pub struct BlockIndex {
+    pub offset: u64,
+    pub length: u64,
+    pub tx_count: u64,
+    pub tx_start_idx: u64,
+}
+
+/// Index entry for a transaction pointing to tx data in the blob tape
+#[derive(Debug, Copy, Clone, Pod, Zeroable)]
+#[repr(C)]
+pub struct TxIndex {
+    pub offset: u64,
+    pub length: u64,
+    pub block_height: u64,
+}
+
+/// Difficulty data for a block
+#[derive(Debug, Copy, Clone, Pod, Zeroable)]
+#[repr(C)]
+pub struct DifficultyIndex {
+    pub difficulty: u64,
+    pub cumulative: u64,
+}
+
+impl Explorer {
+    pub fn open_tapes(
+        db: &Tapes,
+        options: &TapeOpenOptions,
+    ) -> io::Result<(
+        FixedSizedTape<BlockIndex>,
+        FixedSizedTape<TxIndex>,
+        FixedSizedTape<DifficultyIndex>,
+        BlobTape,
+        BlobTape,
+    )> {
+        let mut tx = db.append();
+        let block_index = tx.open_fixed_sized_tape("block_index", options)?;
+        let tx_index = tx.open_fixed_sized_tape("tx_index", options)?;
+        let difficulty_index = tx.open_fixed_sized_tape("diff_index", options)?;
+        let blocks = tx.open_blob_tape("blocks", options)?;
+        let transactions = tx.open_blob_tape("transactions", options)?;
+        tx.commit(Persistence::Buffer)?;
+        Ok((block_index, tx_index, difficulty_index, blocks, transactions))
+    }
+
+    /// Append a new block
+    pub async fn append_block(&self, block: &BlockInfo, diff: &DifficultyIndex) -> io::Result<()> {
+        let mut tx = self.tapes_db.append();
+
+        let block_offset = tx.blob_tape_len(&self.blocks).unwrap_or(0);
+        let tx_blob_offset = tx.blob_tape_len(&self.transactions).unwrap_or(0);
+        let tx_start_idx = tx.fixed_sized_tape_len(&self.tx_index).unwrap_or(0);
+
+        // Append block header
+        let header_data = serialize_async(&block.header).await;
+        tx.append_bytes(&self.blocks, &header_data)?;
+
+        // Append all block transactions
+        let mut current_tx_offset = tx_blob_offset;
+        for transaction in &block.txs {
+            let tx_data = serialize_async(transaction).await;
+            tx.append_bytes(&self.transactions, &tx_data)?;
+
+            let tx_idx = TxIndex {
+                offset: current_tx_offset,
+                length: tx_data.len() as u64,
+                block_height: block.header.height as u64,
+            };
+            tx.append_entries(&self.tx_index, std::slice::from_ref(&tx_idx))?;
+
+            current_tx_offset += tx_data.len() as u64;
+        }
+
+        // Append block index
+        let block_idx = BlockIndex {
+            offset: block_offset,
+            length: header_data.len() as u64,
+            tx_count: block.txs.len() as u64,
+            tx_start_idx,
+        };
+        tx.append_entries(&self.block_index, std::slice::from_ref(&block_idx))?;
+
+        // Append difficulty
+        tx.append_entries(&self.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();
+            let tx_idx_pos = tx_start_idx + i as u64;
+            batch.insert(tx_hash.inner(), &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(),
+        )?;
+
+        info!(
+            "Appended block {} ({} bytes header, {} txs)",
+            block.header.height,
+            header_data.len(),
+            block.txs.len(),
+        );
+
+        Ok(())
+    }
+
+    /// Revert n blocks from the database
+    pub async fn revert_blocks(&self, count: u64) -> io::Result<()> {
+        if count == 0 {
+            return Ok(())
+        }
+
+        let reader = self.tapes_db.reader();
+        let current_len = reader.fixed_sized_tape_len(&self.block_index).unwrap_or(0);
+
+        if count > current_len {
+            return Err(io::Error::new(
+                io::ErrorKind::InvalidInput,
+                "Cannot revert more blocks than exist",
+            ));
+        }
+
+        let new_block_count = current_len - count;
+        let current_tx_idx_len = reader.fixed_sized_tape_len(&self.tx_index).unwrap_or(0);
+
+        // 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();
+
+        for height in new_block_count..current_len {
+            // Get the block index to find transactions
+            let block_idx = reader
+                .read_entry(&self.block_index, height)?
+                .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "block index not found"))?;
+
+            // Read header to get its hash
+            let mut header_data = vec![0u8; block_idx.length as usize];
+            reader.read_bytes(&self.blocks, block_idx.offset, &mut header_data)?;
+            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
+            for i in 0..block_idx.tx_count {
+                let tx_idx = reader
+                    .read_entry(&self.tx_index, block_idx.tx_start_idx + i)?
+                    .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "tx index not found"))?;
+
+                let mut tx_data = vec![0u8; tx_idx.length as usize];
+                reader.read_bytes(&self.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());
+            }
+        }
+
+        let (new_block_blob_len, new_tx_idx_len, new_tx_blob_len) = if new_block_count == 0 {
+            (0, 0, 0)
+        } else {
+            let last_block_idx = reader
+                .read_entry(&self.block_index, new_block_count - 1)?
+                .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "block index not found"))?;
+
+            let new_block_blob_len = last_block_idx.offset + last_block_idx.length;
+            let new_tx_idx_len = last_block_idx.tx_start_idx + last_block_idx.tx_count;
+
+            let new_tx_blob_len = if new_tx_idx_len == 0 {
+                0
+            } else {
+                let last_tx_idx = reader
+                    .read_entry(&self.tx_index, new_tx_idx_len - 1)?
+                    .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "tx index not found"))?;
+                last_tx_idx.offset + last_tx_idx.length
+            };
+
+            (new_block_blob_len, new_tx_idx_len, new_tx_blob_len)
+        };
+
+        // Drop the reader before truncating
+        drop(reader);
+
+        let mut truncate_tx = self.tapes_db.truncate();
+        truncate_tx.drop_from_fixed_sized_tape(&self.block_index, count);
+        truncate_tx.drop_from_fixed_sized_tape(&self.difficulty_index, count);
+
+        let tx_idx_to_remove = current_tx_idx_len - new_tx_idx_len;
+        truncate_tx.drop_from_fixed_sized_tape(&self.tx_index, tx_idx_to_remove);
+
+        truncate_tx.set_blob_tape_len(&self.blocks, new_block_blob_len);
+        truncate_tx.set_blob_tape_len(&self.transactions, new_tx_blob_len);
+
+        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)?;
+
+        info!(
+            "Reverted {} blocks (new height: {})",
+            count,
+            if new_block_count == 0 { 0 } else { new_block_count - 1 }
+        );
+
+        Ok(())
+    }
+
+    /// Revert to a specific height (keep blocks 0..=target_height)
+    pub async fn revert_to_height(&self, target_height: u64) -> io::Result<()> {
+        let current_height = self.get_height()?.unwrap_or(0);
+        if target_height >= current_height {
+            return Ok(())
+        }
+        self.revert_blocks(current_height - target_height).await
+    }
+
+    /// Get the current known blockchain height
+    pub fn get_height(&self) -> io::Result<Option<u64>> {
+        let reader = self.tapes_db.reader();
+        let len = reader.fixed_sized_tape_len(&self.block_index);
+        Ok(len.filter(|&l| l > 0).map(|l| l - 1))
+    }
+
+    /// Get the difficulty and cumulative difficulty for a block height
+    pub fn get_difficulty(&self, height: u64) -> io::Result<Option<DifficultyIndex>> {
+        let reader = self.tapes_db.reader();
+        reader.read_entry(&self.difficulty_index, height)
+    }
+
+    /// Get the block header for a height
+    pub async fn get_header(&self, height: u64) -> io::Result<Option<Header>> {
+        let reader = self.tapes_db.reader();
+
+        let idx = match reader.read_entry(&self.block_index, height)? {
+            Some(idx) => idx,
+            None => return Ok(None),
+        };
+
+        let mut data = vec![0u8; idx.length as usize];
+        reader.read_bytes(&self.blocks, idx.offset, &mut data)?;
+
+        Ok(Some(deserialize_async(&data).await?))
+    }
+
+    /// Get all the transactions in a given block height
+    pub async fn get_block_txs(&self, height: u64) -> io::Result<Option<Vec<Transaction>>> {
+        let reader = self.tapes_db.reader();
+
+        let block_idx = match reader.read_entry(&self.block_index, height)? {
+            Some(idx) => idx,
+            None => return Ok(None),
+        };
+
+        if block_idx.tx_count == 0 {
+            return Ok(Some(vec![]));
+        }
+
+        let mut txs = Vec::with_capacity(block_idx.tx_count as usize);
+        for i in 0..block_idx.tx_count {
+            let tx_idx = reader
+                .read_entry(&self.tx_index, block_idx.tx_start_idx + i)?
+                .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "tx index not found"))?;
+
+            let mut data = vec![0u8; tx_idx.length as usize];
+            reader.read_bytes(&self.transactions, tx_idx.offset, &mut data)?;
+            txs.push(deserialize_async(&data).await?);
+        }
+
+        Ok(Some(txs))
+    }
+
+    /// Get and construct the entire block for a given height.
+    pub async fn get_block(&self, height: u64) -> io::Result<Option<BlockInfo>> {
+        let header = match self.get_header(height).await? {
+            Some(h) => h,
+            None => return Ok(None),
+        };
+
+        let txs = self.get_block_txs(height).await?.unwrap_or_default();
+
+        // We don't care about displaying the block signature.
+        Ok(Some(BlockInfo { header, txs, signature: Signature::dummy() }))
+    }
+
+    /// Get a transaction by its hash.
+    /// Returns the transaction and the block height it belongs to.
+    pub async fn get_tx_by_hash(
+        &self,
+        tx_hash: &[u8; 32],
+    ) -> io::Result<Option<(Transaction, u64)>> {
+        // Look up the tx_index position from sled
+        let tx_idx_pos = match self.tx_indices.get(tx_hash)? {
+            Some(pos_bytes) => {
+                let bytes: [u8; 8] = pos_bytes.as_ref().try_into().map_err(|_| {
+                    io::Error::new(io::ErrorKind::InvalidData, "invalid tx index position")
+                })?;
+                u64::from_le_bytes(bytes)
+            }
+            None => return Ok(None),
+        };
+
+        // Read the TxIndex from tapes
+        let reader = self.tapes_db.reader();
+        let tx_idx = match reader.read_entry(&self.tx_index, tx_idx_pos)? {
+            Some(idx) => idx,
+            None => return Ok(None),
+        };
+
+        // Read the transaction data from the blob tape
+        let mut data = vec![0u8; tx_idx.length as usize];
+        reader.read_bytes(&self.transactions, tx_idx.offset, &mut data)?;
+
+        let transaction: Transaction = deserialize_async(&data).await?;
+        Ok(Some((transaction, tx_idx.block_height)))
+    }
+
+    /// Get a transaction by its hash string (hex encoded).
+    pub async fn get_tx_by_hash_str(
+        &self,
+        tx_hash_str: &str,
+    ) -> io::Result<Option<(Transaction, u64)>> {
+        let hash_bytes = hex::decode(tx_hash_str)
+            .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid hex string"))?;
+
+        if hash_bytes.len() != 32 {
+            return Err(io::Error::new(io::ErrorKind::InvalidInput, "hash must be 32 bytes"));
+        }
+
+        let mut tx_hash = [0u8; 32];
+        tx_hash.copy_from_slice(&hash_bytes);
+
+        self.get_tx_by_hash(&tx_hash).await
+    }
+}

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

@@ -0,0 +1,346 @@
+/* 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::HashSet,
+    path::Path,
+    sync::{
+        atomic::{AtomicBool, Ordering},
+        Arc,
+    },
+};
+
+use async_trait::async_trait;
+use darkfi::{
+    blockchain::BlockInfo,
+    rpc::{
+        client::RpcClient,
+        jsonrpc::{ErrorCode, JsonError, JsonRequest, JsonResult},
+        server::{listen_and_serve, RequestHandler},
+        settings::RpcSettings,
+    },
+    system::{CondVar, Publisher, PublisherPtr, StoppableTask, StoppableTaskPtr},
+    util::encoding::base64,
+    Error, Result,
+};
+use darkfi_serial::deserialize_async;
+use smol::{
+    future,
+    lock::{Mutex, MutexGuard},
+    Executor,
+};
+use tapes::{BlobTape, FixedSizedTape, TapeOpenOptions, Tapes};
+use tinyjson::JsonValue;
+use tracing::{debug, info};
+
+/// Database interfaces
+mod db;
+use db::{BlockIndex, DifficultyIndex, TxIndex};
+/// JSON-RPC server methods
+mod rpc;
+
+const RPC_ENDPOINT: &str = "tcp://127.0.0.1:18345";
+
+pub struct Explorer {
+    synced: AtomicBool,
+    synced_notifier: Arc<CondVar>,
+    _sled_db: sled::Db,
+    header_indices: sled::Tree,
+    tx_indices: sled::Tree,
+
+    tapes_db: Tapes,
+    _tapes_options: TapeOpenOptions,
+    block_index: FixedSizedTape<BlockIndex>,
+    tx_index: FixedSizedTape<TxIndex>,
+    difficulty_index: FixedSizedTape<DifficultyIndex>,
+    blocks: BlobTape,
+    transactions: BlobTape,
+
+    rpc_sub: StoppableTaskPtr,
+    rpc_sub_handler: StoppableTaskPtr,
+    blocks_publisher: PublisherPtr<JsonResult>,
+
+    rpc_connections: Mutex<HashSet<StoppableTaskPtr>>,
+}
+
+struct RpcHandler;
+
+#[async_trait]
+impl RequestHandler<RpcHandler> for Explorer {
+    async fn handle_request(&self, req: JsonRequest) -> JsonResult {
+        debug!(target: "explorer::rpc", "--> {}", req.stringify().unwrap());
+
+        match req.method.as_str() {
+            "current_difficulty" => self.rpc_current_difficulty(req.id, req.params).await,
+            "current_height" => self.rpc_current_height(req.id, req.params).await,
+            "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,
+            _ => JsonError::new(ErrorCode::MethodNotFound, None, req.id).into(),
+        }
+    }
+
+    async fn connections_mut(&self) -> MutexGuard<'life0, HashSet<StoppableTaskPtr>> {
+        self.rpc_connections.lock().await
+    }
+}
+
+impl Explorer {
+    fn new(sled_path: &Path, tapes_db_path: &Path, tapes_path: &Path) -> Result<Self> {
+        info!("Opening sled dbs");
+        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")?;
+
+        info!("Opening tapes dbs");
+        std::fs::create_dir_all(tapes_db_path)?;
+        std::fs::create_dir_all(tapes_path)?;
+        let tapes_db = Tapes::open(tapes_db_path)?;
+        let tapes_options =
+            TapeOpenOptions { top_cache_size: 64 * 1024, dir: tapes_path.to_path_buf() };
+
+        let (block_index, tx_index, difficulty_index, blocks, transactions) =
+            Self::open_tapes(&tapes_db, &tapes_options)?;
+
+        Ok(Self {
+            synced: AtomicBool::new(false),
+            synced_notifier: Arc::new(CondVar::new()),
+            _sled_db: sled_db,
+            header_indices,
+            tx_indices,
+            tapes_db,
+            _tapes_options: tapes_options,
+            block_index,
+            tx_index,
+            difficulty_index,
+            blocks,
+            transactions,
+            rpc_sub: StoppableTask::new(),
+            rpc_sub_handler: StoppableTask::new(),
+            blocks_publisher: Publisher::new(),
+            rpc_connections: Mutex::new(HashSet::new()),
+        })
+    }
+
+    async fn handle_block_sub(&self, ex: Arc<Executor<'_>>) -> Result<()> {
+        info!("Started handle_block_sub(), waiting until blockchain is synced");
+        let block_subscription = self.blocks_publisher.clone().subscribe().await;
+        self.synced_notifier.wait();
+        info!("Blockchain synced, processing new blocks...");
+
+        loop {
+            // Handle the new block. We get a JsonResult, so also handle
+            // any errors that might arise.
+            let block_notification = block_subscription.receive().await;
+            info!("Got new block notification! {:?}", block_notification);
+
+            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();
+
+                    // Get difficulty
+                    let rpc_client =
+                        RpcClient::new(RPC_ENDPOINT.parse().unwrap(), ex.clone()).await.unwrap();
+
+                    let req = JsonRequest::new(
+                        "blockchain.get_difficulty",
+                        JsonValue::Array(vec![(block.header.height as f64).into()]),
+                    );
+                    let rep = rpc_client.request(req).await?;
+                    rpc_client.stop().await;
+
+                    let params = rep.get::<Vec<JsonValue>>().unwrap();
+                    let difficulty = *params[0].get::<f64>().unwrap() as u64;
+                    let cumulative = *params[1].get::<f64>().unwrap() as u64;
+
+                    let diff = DifficultyIndex { difficulty, cumulative };
+
+                    self.append_block(&block, &diff).await.unwrap();
+                    info!("Appended block {}", block.header.height);
+                }
+                _ => panic!("fixme"),
+            }
+        }
+    }
+
+    async fn sync_blockchain(
+        &self,
+        from_height: u64,
+        to_height: u64,
+        ex: Arc<Executor<'_>>,
+    ) -> Result<()> {
+        if from_height == to_height {
+            info!("Blockchain already synced");
+            return Ok(())
+        }
+
+        info!("sync_blockchain started from_height={from_height} to_height={to_height}...");
+        let rpc_client = Arc::new(RpcClient::new(RPC_ENDPOINT.parse().unwrap(), ex.clone()).await?);
+
+        for height in from_height..=to_height {
+            info!("Requesting block at height {height}");
+
+            // Get block
+            let req = JsonRequest::new(
+                "blockchain.get_block",
+                JsonValue::Array(vec![(height as f64).into()]),
+            );
+            let rep = rpc_client.request(req).await?;
+
+            let param = rep.get::<String>().unwrap();
+            let bytes = base64::decode(param).unwrap();
+            let block: BlockInfo = deserialize_async(&bytes).await?;
+
+            // Get difficulty
+            let req = JsonRequest::new(
+                "blockchain.get_difficulty",
+                JsonValue::Array(vec![(height as f64).into()]),
+            );
+            let rep = rpc_client.request(req).await?;
+
+            let params = rep.get::<Vec<JsonValue>>().unwrap();
+            let difficulty = *params[0].get::<f64>().unwrap() as u64;
+            let cumulative = *params[1].get::<f64>().unwrap() as u64;
+
+            let diff = DifficultyIndex { difficulty, cumulative };
+            self.append_block(&block, &diff).await?;
+        }
+
+        rpc_client.stop().await;
+        Ok(())
+    }
+}
+
+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"),
+    )?);
+
+    // First we should subscribe to new blocks and queue them to apply
+    // after we sync. For this we create a new longterm background task
+    // that will handle incoming blocks. It will wait until the blockchain
+    // is synced and then proceed to process them.
+    let explorer_ = Arc::clone(&explorer);
+    let explorer__ = Arc::clone(&explorer);
+    let ex_ = ex.clone();
+    explorer.rpc_sub_handler.clone().start(
+        async move { explorer_.handle_block_sub(ex_).await },
+        |res| async move {
+            match res {
+                Ok(()) | Err(Error::DetachedTaskStopped) | Err(Error::RpcServerStopped) => {}
+                Err(_) => {
+                    explorer__
+                        .blocks_publisher
+                        .notify(JsonResult::Error(JsonError::new(
+                            ErrorCode::InternalError,
+                            None,
+                            0,
+                        )))
+                        .await;
+                }
+            }
+        },
+        Error::RpcServerStopped,
+        ex.clone(),
+    );
+
+    // Then we subscribe to darkfid's RPC to get new blocks. We should first
+    // fetch the current height, so we know how far to sync. Then any blocks
+    // that come after that should be queued in the `blocks_publisher`.
+    let rpc_client = Arc::new(RpcClient::new(RPC_ENDPOINT.parse().unwrap(), ex.clone()).await?);
+
+    let req = JsonRequest::new("blockchain.last_confirmed_block", JsonValue::Array(vec![]));
+    let rep = rpc_client.request(req).await?;
+    let params = rep.get::<Vec<JsonValue>>().unwrap();
+    let last_confirmed_height = *params[0].get::<f64>().unwrap() as u64;
+
+    // Now create the subscription task.
+    let rpc_client_ = Arc::clone(&rpc_client);
+    let explorer_ = Arc::clone(&explorer);
+    let explorer__ = Arc::clone(&explorer);
+    explorer.rpc_sub.clone().start(
+        async move {
+            let req = JsonRequest::new("blockchain.subscribe_blocks", JsonValue::Array(vec![]));
+            rpc_client_.subscribe(req, explorer_.blocks_publisher.clone()).await
+        },
+        |res| async move {
+            rpc_client.stop().await;
+            match res {
+                Ok(()) | Err(Error::DetachedTaskStopped) | Err(Error::RpcServerStopped) => {}
+                Err(_) => {
+                    explorer__
+                        .blocks_publisher
+                        .notify(JsonResult::Error(JsonError::new(
+                            ErrorCode::InternalError,
+                            None,
+                            0,
+                        )))
+                        .await;
+                }
+            }
+        },
+        Error::RpcServerStopped,
+        ex.clone(),
+    );
+
+    // Once the tasks are set up, we'll now perform a manual sync up to
+    // the last confirmed height. This will create a new RPC client that
+    // is going to request and parse all the necessary blocks, and then
+    // apply them to the databases.
+    let sync_from = explorer.get_height()?.unwrap_or(0);
+    explorer.sync_blockchain(sync_from, last_confirmed_height, ex.clone()).await?;
+    explorer.synced.store(true, Ordering::SeqCst);
+    explorer.synced_notifier.notify();
+
+    // Start up an RPC server that can be queried for data.
+    // This normally serves the data to the Python website frontend.
+    info!("Starting JSONRPC server");
+    let rpc_settings = RpcSettings::default();
+    listen_and_serve(rpc_settings, explorer, None, ex.clone()).await?;
+
+    Ok(())
+}
+
+fn main() -> Result<()> {
+    let ex = Arc::new(Executor::new());
+    let (signal, shutdown) = async_channel::unbounded::<()>();
+
+    darkfi::util::logger::setup_logging(1, None)?;
+
+    let (_, result) = easy_parallel::Parallel::new()
+        // Run four executor threads
+        .each(0..4, |_| future::block_on(ex.run(shutdown.recv())))
+        // Run the main future on the current thread
+        .finish(|| {
+            future::block_on(async {
+                realmain(ex.clone()).await?;
+                drop(signal);
+                Ok::<(), darkfi::Error>(())
+            })
+        });
+
+    result
+}

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

@@ -0,0 +1,408 @@
+use std::collections::HashMap;
+
+use darkfi::{
+    blockchain::{header_store::PowData, BlockInfo},
+    rpc::jsonrpc::{
+        ErrorCode::{InternalError, InvalidParams},
+        JsonError, JsonResponse, JsonResult,
+    },
+    tx::Transaction,
+    util::encoding::base64,
+};
+use darkfi_serial::serialize_async;
+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>,
+    func: String,
+    size: u64,
+}
+
+impl ContractCallInfo {
+    fn new(contract_id: String, func: String, size: u64) -> Self {
+        let contract_tag = match contract_id.as_str() {
+            "BZHKGQ26bzmBithTQYTJtjo2QdCqpkR9tjSBopT4yf4o" => Some("Money".to_string()),
+            "Fd8kfCuqU8BoFFp6GcXv5pC8XXRkBK7gUPQX5XDz7iXj" => Some("DAO".to_string()),
+            "EJs7oEjKkvCeEVCmpRsd6fEoTGCFJ7WKUBfmAjwaegN" => Some("Deployoor".to_string()),
+            _ => None,
+        };
+
+        Self { contract_id, contract_tag, func, size }
+    }
+
+    fn to_json(&self) -> JsonValue {
+        let tag = match &self.contract_tag {
+            Some(t) => JsonValue::String(t.clone()),
+            None => JsonValue::Null,
+        };
+
+        JsonValue::Object(HashMap::from([
+            ("contract_id".to_string(), JsonValue::String(self.contract_id.clone())),
+            ("contract_tag".to_string(), tag),
+            ("func".to_string(), JsonValue::String(self.func.clone())),
+            ("size".to_string(), JsonValue::Number(self.size as f64)),
+        ]))
+    }
+}
+
+struct TransactionInfo {
+    hash: String,
+    calls: Vec<ContractCallInfo>,
+    fee: u64,
+    size: u64,
+}
+
+impl TransactionInfo {
+    async fn new(tx: &Transaction) -> Self {
+        let mut calls = Vec::with_capacity(tx.calls.len());
+        for call in &tx.calls {
+            let call_data = serialize_async(&call.data).await;
+            let func = if call_data.is_empty() {
+                "0x00".to_string()
+            } else {
+                format!("0x{:02x}", call_data[0])
+            };
+            calls.push(ContractCallInfo::new(
+                call.data.contract_id.to_string(),
+                func,
+                call_data.len() as u64,
+            ));
+        }
+
+        Self {
+            hash: tx.hash().to_string(),
+            calls,
+            fee: 0,
+            size: serialize_async(tx).await.len() as u64,
+        }
+    }
+
+    fn to_json(&self) -> JsonValue {
+        let calls = self.calls.iter().map(|c| c.to_json()).collect();
+
+        JsonValue::Object(HashMap::from([
+            ("hash".to_string(), JsonValue::String(self.hash.clone())),
+            ("calls".to_string(), JsonValue::Array(calls)),
+            ("fee".to_string(), JsonValue::Number(self.fee as f64)),
+            ("size".to_string(), JsonValue::Number(self.size as f64)),
+        ]))
+    }
+}
+
+/// Full transaction info for the get_tx RPC endpoint
+struct ExplTxInfo {
+    hash: String,
+    from_block: u64,
+    confirmations: u64,
+    fee: u64,
+    size: u64,
+    n_calls: u64,
+    calls: Vec<ContractCallInfo>,
+    raw: String,
+}
+
+impl ExplTxInfo {
+    async fn new(tx: &Transaction, block_height: u64, current_height: u64) -> Self {
+        let mut calls = Vec::with_capacity(tx.calls.len());
+        for call in &tx.calls {
+            let call_data = serialize_async(&call.data).await;
+            let func = if call_data.is_empty() {
+                "0x00".to_string()
+            } else {
+                format!("0x{:02x}", call_data[0])
+            };
+            calls.push(ContractCallInfo::new(
+                call.data.contract_id.to_string(),
+                func,
+                call_data.len() as u64,
+            ));
+        }
+
+        let raw_bytes = serialize_async(tx).await;
+        let confirmations =
+            if current_height >= block_height { current_height - block_height + 1 } else { 0 };
+
+        Self {
+            hash: tx.hash().to_string(),
+            from_block: block_height,
+            confirmations,
+            fee: 0,
+            size: raw_bytes.len() as u64,
+            n_calls: tx.calls.len() as u64,
+            calls,
+            raw: base64::encode(&raw_bytes),
+        }
+    }
+
+    fn to_json(&self) -> JsonValue {
+        JsonValue::Object(HashMap::from([
+            ("hash".to_string(), JsonValue::String(self.hash.clone())),
+            ("from_block".to_string(), JsonValue::Number(self.from_block as f64)),
+            ("confirmations".to_string(), JsonValue::Number(self.confirmations as f64)),
+            ("fee".to_string(), JsonValue::Number(self.fee as f64)),
+            ("size".to_string(), JsonValue::Number(self.size as f64)),
+            ("n_calls".to_string(), JsonValue::Number(self.n_calls as f64)),
+            (
+                "calls".to_string(),
+                JsonValue::Array(self.calls.iter().map(|c| c.to_json()).collect()),
+            ),
+            ("raw".to_string(), JsonValue::String(self.raw.clone())),
+        ]))
+    }
+}
+
+struct ExplBlockInfo {
+    height: u64,
+    hash: String,
+    version: u8,
+    previous_hash: String,
+    nonce: u64,
+    timestamp: u64,
+    transactions_root: String,
+    state_root: String,
+    size: u64,
+    difficulty: u64,
+    cumulative: u64,
+    powtype: String,
+    txs: Vec<TransactionInfo>,
+    coinbase: CoinbaseInfo,
+}
+
+impl ExplBlockInfo {
+    async fn new(block: &BlockInfo, diff: &DifficultyIndex) -> Self {
+        let powtype = match block.header.pow_data {
+            PowData::DarkFi => "DarkFi".to_string(),
+            PowData::Monero(_) => "Monero".to_string(),
+        };
+
+        let mut txs = Vec::with_capacity(block.txs.len());
+        for tx in &block.txs {
+            txs.push(TransactionInfo::new(tx).await);
+        }
+
+        let coinbase = CoinbaseInfo::new(&block.txs[block.txs.len() - 1]).await;
+
+        Self {
+            height: block.header.height as u64,
+            hash: block.header.hash().to_string(),
+            version: block.header.version,
+            previous_hash: block.header.previous.to_string(),
+            nonce: block.header.nonce as u64,
+            timestamp: block.header.timestamp.inner(),
+            transactions_root: block.header.transactions_root.to_string(),
+            state_root: hex::encode(block.header.state_root),
+            size: serialize_async(block).await.len() as u64,
+            difficulty: diff.difficulty,
+            cumulative: diff.cumulative,
+            powtype,
+            txs,
+            coinbase,
+        }
+    }
+
+    fn to_json(&self) -> JsonValue {
+        JsonValue::Object(HashMap::from([
+            ("height".to_string(), JsonValue::Number(self.height as f64)),
+            ("hash".to_string(), JsonValue::String(self.hash.clone())),
+            ("version".to_string(), JsonValue::Number(self.version as f64)),
+            ("previous_hash".to_string(), JsonValue::String(self.previous_hash.clone())),
+            ("nonce".to_string(), JsonValue::Number(self.nonce as f64)),
+            ("timestamp".to_string(), JsonValue::Number(self.timestamp as f64)),
+            ("transactions_root".to_string(), JsonValue::String(self.transactions_root.clone())),
+            ("state_root".to_string(), JsonValue::String(self.state_root.clone())),
+            ("size".to_string(), JsonValue::Number(self.size as f64)),
+            ("difficulty".to_string(), JsonValue::Number(self.difficulty as f64)),
+            ("cumulative".to_string(), JsonValue::Number(self.cumulative as f64)),
+            ("powtype".to_string(), JsonValue::String(self.powtype.clone())),
+            ("txs".to_string(), JsonValue::Array(self.txs.iter().map(|t| t.to_json()).collect())),
+            ("coinbase".to_string(), self.coinbase.to_json()),
+        ]))
+    }
+}
+
+struct CoinbaseInfo {
+    hash: String,
+    reward: u64,
+    size: u64,
+}
+
+impl CoinbaseInfo {
+    async fn new(tx: &Transaction) -> Self {
+        Self {
+            hash: tx.hash().to_string(),
+            reward: 0,
+            size: serialize_async(tx).await.len() as u64,
+        }
+    }
+
+    fn to_json(&self) -> JsonValue {
+        JsonValue::Object(HashMap::from([
+            ("hash".to_string(), JsonValue::String(self.hash.clone())),
+            ("reward".to_string(), JsonValue::Number(self.reward as f64)),
+            ("size".to_string(), JsonValue::Number(self.size as f64)),
+        ]))
+    }
+}
+
+impl Explorer {
+    pub async fn rpc_current_difficulty(&self, id: u16, _params: JsonValue) -> JsonResult {
+        // Get latest height
+        let Ok(Some(height)) = self.get_height() else {
+            return JsonError::new(InternalError, None, id).into()
+        };
+
+        let Ok(Some(difficulty)) = self.get_difficulty(height) else {
+            return JsonError::new(InternalError, None, id).into()
+        };
+
+        JsonResponse::new(
+            JsonValue::Array(vec![
+                (difficulty.difficulty as f64).into(),
+                (difficulty.cumulative as f64).into(),
+            ]),
+            id,
+        )
+        .into()
+    }
+
+    pub async fn rpc_current_height(&self, id: u16, _params: JsonValue) -> JsonResult {
+        let Ok(Some(height)) = self.get_height() else {
+            return JsonError::new(InternalError, None, id).into()
+        };
+
+        JsonResponse::new(JsonValue::Number(height as f64), id).into()
+    }
+
+    pub async fn rpc_latest_blocks(&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_number() {
+            return JsonError::new(InvalidParams, None, id).into()
+        }
+
+        let n_blocks = *params[0].get::<f64>().unwrap() as u64;
+
+        let Ok(Some(height)) = self.get_height() else {
+            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)
+        };
+
+        for i in (0..=n_blocks).rev() {
+            let Ok(Some(block)) = self.get_block(i).await else {
+                return JsonError::new(InternalError, None, id).into()
+            };
+
+            blocks.push(LatestBlockInfo::new(&block).await.to_json());
+        }
+
+        JsonResponse::new(JsonValue::Array(blocks), id).into()
+    }
+
+    pub async fn rpc_get_block(&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 {
+            return JsonError::new(InvalidParams, None, id).into()
+        }
+
+        let height = if params[0].is_string() {
+            let Ok(hash) = hex::decode(params[0].get::<String>().unwrap()) else {
+                return JsonError::new(InvalidParams, None, id).into()
+            };
+
+            let Ok(Some(height_bytes)) = self.header_indices.get(hash) else {
+                return JsonError::new(InternalError, None, id).into()
+            };
+
+            let height_bytes = height_bytes.to_vec();
+            u64::from_le_bytes(height_bytes.try_into().unwrap())
+        } else if params[0].is_number() {
+            *params[0].get::<f64>().unwrap() as u64
+        } else {
+            return JsonError::new(InvalidParams, None, id).into()
+        };
+
+        let Ok(Some(block)) = self.get_block(height).await else {
+            return JsonError::new(InternalError, None, id).into()
+        };
+
+        let Ok(Some(diff)) = self.get_difficulty(height) else {
+            return JsonError::new(InternalError, None, id).into()
+        };
+
+        let info = ExplBlockInfo::new(&block, &diff).await;
+        JsonResponse::new(info.to_json(), id).into()
+    }
+
+    pub async fn rpc_get_tx(&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 tx_hash_str = params[0].get::<String>().unwrap();
+
+        // Get current height for confirmations calculation
+        let Ok(Some(current_height)) = self.get_height() else {
+            return JsonError::new(InternalError, None, id).into()
+        };
+
+        // Get transaction by hash
+        let Ok(Some((tx, block_height))) = self.get_tx_by_hash_str(tx_hash_str).await else {
+            return JsonError::new(InternalError, None, id).into()
+        };
+
+        let info = ExplTxInfo::new(&tx, block_height, current_height).await;
+        JsonResponse::new(info.to_json(), id).into()
+    }
+}