explorer.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. #!/usr/bin/env python3
  2. # This file is part of DarkFi (https://dark.fi)
  3. #
  4. # Copyright (C) 2020-2026 Dyne.org foundation
  5. #
  6. # This program is free software: you can redistribute it and/or modify
  7. # it under the terms of the GNU Affero General Public License as
  8. # published by the Free Software Foundation, either version 3 of the
  9. # License, or (at your option) any later version.
  10. #
  11. # This program is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. # GNU Affero General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU Affero General Public License
  17. # along with this program. If not, see <https://www.gnu.org/licenses/>.
  18. import asyncio
  19. from datetime import datetime, timezone
  20. from quart import Quart, render_template, abort, request, redirect, url_for, Response
  21. from rpc_client import JsonRpcPool, JsonRpcError, RpcUnavailableError
  22. app = Quart(__name__)
  23. app.config.update(
  24. RPC_HOST="127.0.0.1",
  25. RPC_PORT="22222",
  26. RPC_MIN_CONNECTIONS=5,
  27. RPC_MAX_CONNECTIONS=50,
  28. RPC_RECONNECT_INTERVAL=5.0,
  29. NETWORK="Testnet",
  30. )
  31. # Global pool
  32. rpc: JsonRpcPool = None
  33. @app.before_serving
  34. async def startup():
  35. global rpc
  36. rpc = JsonRpcPool(
  37. host=app.config["RPC_HOST"],
  38. port=app.config["RPC_PORT"],
  39. min_connections=app.config["RPC_MIN_CONNECTIONS"],
  40. max_connections=app.config["RPC_MAX_CONNECTIONS"],
  41. reconnect_interval=app.config["RPC_RECONNECT_INTERVAL"],
  42. )
  43. await rpc.start()
  44. app.logger.info(f"RPC pool initialized for {app.config['RPC_HOST']}:{app.config['RPC_PORT']}")
  45. @app.after_serving
  46. async def shutdown():
  47. await rpc.close()
  48. app.logger.info("RPC pool closed")
  49. @app.errorhandler(JsonRpcError)
  50. async def handle_rpc_error(error: JsonRpcError):
  51. app.logger.error(f"RPC Error: {error.code} - {error.message}")
  52. if error.code == -32600:
  53. return await render_template(
  54. "error.html",
  55. network=app.config["NETWORK"],
  56. error_code="404",
  57. error="The requested resource was not found"
  58. ), 404
  59. return await render_template(
  60. "error.html",
  61. network=app.config["NETWORK"],
  62. error_code="500",
  63. error=error.message
  64. ), 500
  65. @app.errorhandler(RpcUnavailableError)
  66. async def handle_rpc_unavailable(error: RpcUnavailableError):
  67. app.logger.error(f"RPC Unavailable: {error}")
  68. return await render_template(
  69. "error.html",
  70. network=app.config["NETWORK"],
  71. error_code="503",
  72. error="Blockchain node is currently unavailable. Please try again later."
  73. ), 503
  74. @app.errorhandler(ConnectionError)
  75. async def handle_connection_error(error):
  76. app.logger.error(f"Connection Error: {error}")
  77. return await render_template(
  78. "error.html",
  79. network=app.config["NETWORK"],
  80. error_code="503",
  81. error="Service temporarily unavailable"
  82. ), 503
  83. @app.errorhandler(404)
  84. async def handle_not_found(error):
  85. return await render_template(
  86. "error.html",
  87. network=app.config["NETWORK"],
  88. error_code="404",
  89. error="Page not found"
  90. ), 404
  91. def format_hashrate(hashrate: float) -> str:
  92. """Format hashrate with appropriate unit."""
  93. if hashrate >= 1e12:
  94. return f"{hashrate / 1e12:.2f} TH/s"
  95. elif hashrate >= 1e9:
  96. return f"{hashrate / 1e9:.2f} GH/s"
  97. elif hashrate >= 1e6:
  98. return f"{hashrate / 1e6:.2f} MH/s"
  99. elif hashrate >= 1e3:
  100. return f"{hashrate / 1e3:.2f} KH/s"
  101. else:
  102. return f"{hashrate:.2f} H/s"
  103. def format_bytes(size: int) -> str:
  104. """Format byte size with appropriate unit."""
  105. if size >= 1024 * 1024:
  106. return f"{size / (1024 * 1024):.2f} MB"
  107. elif size >= 1024:
  108. return f"{size / 1024:.2f} KB"
  109. else:
  110. return f"{size} bytes"
  111. @app.route("/")
  112. async def index():
  113. current_difficulty = await rpc.call("current_difficulty", params=[])
  114. current_height = await rpc.call("current_height", params=[])
  115. latest_blocks = await rpc.call("latest_blocks", params=[20])
  116. hashrate = await rpc.call("get_hashrate", params=[])
  117. for block in latest_blocks:
  118. dt = datetime.fromtimestamp(block["timestamp"], tz=timezone.utc)
  119. block["timestamp"] = dt.strftime("%H:%M UTC %d %b %Y")
  120. return await render_template(
  121. "index.html",
  122. network=app.config["NETWORK"],
  123. current_difficulty=current_difficulty[0],
  124. current_height=current_height,
  125. hashrate=format_hashrate(hashrate),
  126. latest_blocks=latest_blocks,
  127. )
  128. @app.route("/blocks")
  129. async def list_blocks():
  130. """List all blocks with pagination"""
  131. BLOCKS_PER_PAGE = 100
  132. # Get page number from query param (1-indexed for users)
  133. page = request.args.get("page", 1, type=int)
  134. if page < 1:
  135. page = 1
  136. current_difficulty = await rpc.call("current_difficulty", params=[])
  137. current_height = await rpc.call("current_height", params=[])
  138. hashrate = await rpc.call("get_hashrate", params=[])
  139. total = current_height + 1 # blocks 0 to current_height
  140. # Calculate range: newest first
  141. offset = (page - 1) * BLOCKS_PER_PAGE
  142. start_height = current_height - offset
  143. end_height = max(0, start_height - BLOCKS_PER_PAGE + 1)
  144. # Build list of heights to fetch
  145. heights = [h for h in range(start_height, end_height - 1, -1) if h >= 0]
  146. async def fetch_block(height):
  147. try:
  148. block = await rpc.call("get_block", params=[height])
  149. dt = datetime.fromtimestamp(block["timestamp"], tz=timezone.utc)
  150. return {
  151. "height": int(block["height"]),
  152. "size": int(block["size"]),
  153. "n_txs": len(block["txs"]),
  154. "timestamp": dt.strftime("%H:%M UTC %d %b %Y"),
  155. "powtype": block["powtype"],
  156. "hash": block["hash"],
  157. }
  158. except JsonRpcError:
  159. return None
  160. # Fetch all blocks in parallel
  161. results = await asyncio.gather(*[fetch_block(h) for h in heights])
  162. blocks = [b for b in results if b is not None]
  163. # Calculate pagination info
  164. total_pages = (total + BLOCKS_PER_PAGE - 1) // BLOCKS_PER_PAGE
  165. has_prev = page > 1
  166. has_next = page < total_pages
  167. return await render_template(
  168. "blocks.html",
  169. network=app.config["NETWORK"],
  170. current_difficulty=current_difficulty[0],
  171. current_height=current_height,
  172. hashrate=format_hashrate(hashrate),
  173. blocks=blocks,
  174. total=total,
  175. page=page,
  176. total_pages=total_pages,
  177. has_prev=has_prev,
  178. has_next=has_next,
  179. blocks_per_page=BLOCKS_PER_PAGE,
  180. )
  181. @app.route("/block/<int:block_height>")
  182. async def get_block_by_height(block_height: int):
  183. if block_height < 0:
  184. abort(404)
  185. current_difficulty = await rpc.call("current_difficulty", params=[])
  186. current_height = await rpc.call("current_height", params=[])
  187. hashrate = await rpc.call("get_hashrate", params=[])
  188. block = await rpc.call("get_block", params=[block_height])
  189. dt = datetime.fromtimestamp(block["timestamp"], tz=timezone.utc)
  190. block["timestamp"] = dt.strftime("%H:%M UTC %d %b %Y")
  191. block["n_txs"] = len(block["txs"])
  192. return await render_template(
  193. "block.html",
  194. network=app.config["NETWORK"],
  195. current_difficulty=current_difficulty[0],
  196. current_height=current_height,
  197. hashrate=format_hashrate(hashrate),
  198. block=block,
  199. monero_hash=block["monero_hash"],
  200. )
  201. @app.route("/tx/<tx_hash>")
  202. async def get_tx_by_hash(tx_hash: str):
  203. # Validate hex string
  204. if not all(c in '0123456789abcdefABCDEF' for c in tx_hash):
  205. abort(404)
  206. current_difficulty = await rpc.call("current_difficulty", params=[])
  207. current_height = await rpc.call("current_height", params=[])
  208. hashrate = await rpc.call("get_hashrate", params=[])
  209. tx = await rpc.call("get_tx", params=[tx_hash])
  210. return await render_template(
  211. "tx.html",
  212. network=app.config["NETWORK"],
  213. current_difficulty=current_difficulty[0],
  214. current_height=current_height,
  215. hashrate=format_hashrate(hashrate),
  216. tx=tx,
  217. )
  218. @app.route("/search")
  219. async def search():
  220. """Search for blocks by height/hash or transactions by hash."""
  221. query = request.args.get("q", "").strip()
  222. if not query:
  223. return redirect(url_for("index"))
  224. # Try to interpret as block height (integer)
  225. if query.isdigit():
  226. return redirect(url_for("get_block_by_height", block_height=int(query)))
  227. # Check if it looks like a hex hash
  228. if all(c in '0123456789abcdefABCDEF' for c in query):
  229. # Use the search RPC to determine if it's a block or tx hash
  230. try:
  231. result = await rpc.call("search", params=[query])
  232. if result["type"] == "block":
  233. return redirect(f"/block/{result['height']}")
  234. elif result["type"] == "tx":
  235. return redirect(url_for("get_tx_by_hash", tx_hash=query))
  236. except JsonRpcError:
  237. pass
  238. # Try as contract ID (base58)
  239. try:
  240. contract = await rpc.call("get_contract", params=[query])
  241. return redirect(url_for("get_contract", contract_id=query))
  242. except JsonRpcError:
  243. pass
  244. # Nothing found
  245. return await render_template(
  246. "error.html",
  247. network=app.config["NETWORK"],
  248. error_code="Not Found",
  249. error=f"No block, transaction, or contract found for: {query}"
  250. ), 404
  251. @app.route("/contract/<contract_id>")
  252. async def get_contract(contract_id: str):
  253. current_difficulty = await rpc.call("current_difficulty", params=[])
  254. current_height = await rpc.call("current_height", params=[])
  255. hashrate = await rpc.call("get_hashrate", params=[])
  256. contract = await rpc.call("get_contract", params=[contract_id])
  257. contract["wasm_size_formatted"] = format_bytes(int(contract["wasm_size"]))
  258. return await render_template(
  259. "contract.html",
  260. network=app.config["NETWORK"],
  261. current_difficulty=current_difficulty[0],
  262. current_height=current_height,
  263. hashrate=format_hashrate(hashrate),
  264. contract=contract,
  265. )
  266. @app.route("/contracts")
  267. async def list_contracts():
  268. current_difficulty = await rpc.call("current_difficulty", params=[])
  269. current_height = await rpc.call("current_height", params=[])
  270. hashrate = await rpc.call("get_hashrate", params=[])
  271. contracts = await rpc.call("list_contracts", params=[])
  272. contract_count = await rpc.call("contract_count", params=[])
  273. for contract in contracts:
  274. contract["wasm_size_formatted"] = format_bytes(int(contract["wasm_size"]))
  275. return await render_template(
  276. "contracts.html",
  277. network=app.config["NETWORK"],
  278. current_difficulty=current_difficulty[0],
  279. current_height=current_height,
  280. hashrate=format_hashrate(hashrate),
  281. contracts=contracts,
  282. contract_count=contract_count,
  283. )
  284. @app.route("/stats")
  285. async def stats():
  286. current_difficulty = await rpc.call("current_difficulty", params=[])
  287. current_height = await rpc.call("current_height", params=[])
  288. hashrate = await rpc.call("get_hashrate", params=[])
  289. stats_data = await rpc.call("get_stats", params=[])
  290. return await render_template(
  291. "stats.html",
  292. network=app.config["NETWORK"],
  293. current_difficulty=current_difficulty[0],
  294. current_height=current_height,
  295. hashrate=format_hashrate(hashrate),
  296. stats=stats_data,
  297. )
  298. @app.route("/stats/daily_tx_chart.png")
  299. async def daily_tx_chart():
  300. """Generate daily average transactions chart as PNG using matplotlib."""
  301. import io
  302. import matplotlib
  303. matplotlib.use('Agg') # Non-interactive backend
  304. import matplotlib.pyplot as plt
  305. import matplotlib.dates as mdates
  306. stats_data = await rpc.call("get_stats", params=[])
  307. daily_stats = stats_data.get("daily_stats", [])
  308. # Filter to last 90 days
  309. if daily_stats:
  310. max_day = max(d["day"] for d in daily_stats)
  311. daily_stats = [d for d in daily_stats if d["day"] >= max_day - 90]
  312. # Create figure with dark theme
  313. plt.style.use('dark_background')
  314. fig, ax = plt.subplots(figsize=(12, 4), dpi=100)
  315. fig.patch.set_facecolor('#0d1117')
  316. ax.set_facecolor('#0d1117')
  317. if daily_stats:
  318. # Convert day numbers to dates
  319. dates = [datetime.fromtimestamp(d["day"] * 86400, tz=timezone.utc) for d in daily_stats]
  320. values = [d["avg_tx"] for d in daily_stats]
  321. ax.fill_between(dates, values, alpha=0.3, color='#6366f1')
  322. ax.plot(dates, values, color='#6366f1', linewidth=2)
  323. # Format x-axis
  324. ax.xaxis.set_major_formatter(mdates.DateFormatter('%b %d'))
  325. ax.xaxis.set_major_locator(mdates.DayLocator(interval=7))
  326. plt.xticks(rotation=45, ha='right')
  327. ax.set_ylabel('Avg TX per Block', color='#9ca3af')
  328. ax.tick_params(colors='#9ca3af')
  329. ax.spines['bottom'].set_color('#30363d')
  330. ax.spines['left'].set_color('#30363d')
  331. ax.spines['top'].set_visible(False)
  332. ax.spines['right'].set_visible(False)
  333. ax.grid(True, alpha=0.2, color='#30363d')
  334. else:
  335. ax.text(0.5, 0.5, 'No data available', ha='center', va='center',
  336. transform=ax.transAxes, color='#9ca3af', fontsize=14)
  337. ax.set_xlim(0, 1)
  338. ax.set_ylim(0, 1)
  339. plt.tight_layout()
  340. # Save to bytes buffer
  341. buf = io.BytesIO()
  342. plt.savefig(buf, format='png', facecolor='#0d1117', edgecolor='none')
  343. plt.close(fig)
  344. buf.seek(0)
  345. return Response(buf.getvalue(), mimetype='image/png')
  346. if __name__ == "__main__":
  347. app.run(host="127.0.0.1", port=5000, debug=True)