explorer.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  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. from datetime import datetime, timezone
  19. from quart import Quart, render_template, abort, request, redirect, url_for, Response
  20. from rpc_client import JsonRpcPool, JsonRpcError, RpcUnavailableError
  21. app = Quart(__name__)
  22. app.config.update(
  23. RPC_HOST="127.0.0.1",
  24. RPC_PORT="22222",
  25. RPC_MIN_CONNECTIONS=5,
  26. RPC_MAX_CONNECTIONS=50,
  27. RPC_RECONNECT_INTERVAL=5.0,
  28. NETWORK="Testnet",
  29. )
  30. # Global pool
  31. rpc: JsonRpcPool = None
  32. @app.before_serving
  33. async def startup():
  34. global rpc
  35. rpc = JsonRpcPool(
  36. host=app.config["RPC_HOST"],
  37. port=app.config["RPC_PORT"],
  38. min_connections=app.config["RPC_MIN_CONNECTIONS"],
  39. max_connections=app.config["RPC_MAX_CONNECTIONS"],
  40. reconnect_interval=app.config["RPC_RECONNECT_INTERVAL"],
  41. )
  42. await rpc.start()
  43. app.logger.info(f"RPC pool initialized for {app.config['RPC_HOST']}:{app.config['RPC_PORT']}")
  44. @app.after_serving
  45. async def shutdown():
  46. await rpc.close()
  47. app.logger.info("RPC pool closed")
  48. @app.errorhandler(JsonRpcError)
  49. async def handle_rpc_error(error: JsonRpcError):
  50. app.logger.error(f"RPC Error: {error.code} - {error.message}")
  51. if error.code == -32600:
  52. return await render_template(
  53. "error.html",
  54. network=app.config["NETWORK"],
  55. error_code="404",
  56. error="The requested resource was not found"
  57. ), 404
  58. return await render_template(
  59. "error.html",
  60. network=app.config["NETWORK"],
  61. error_code="500",
  62. error=error.message
  63. ), 500
  64. @app.errorhandler(RpcUnavailableError)
  65. async def handle_rpc_unavailable(error: RpcUnavailableError):
  66. app.logger.error(f"RPC Unavailable: {error}")
  67. return await render_template(
  68. "error.html",
  69. network=app.config["NETWORK"],
  70. error_code="503",
  71. error="Blockchain node is currently unavailable. Please try again later."
  72. ), 503
  73. @app.errorhandler(ConnectionError)
  74. async def handle_connection_error(error):
  75. app.logger.error(f"Connection Error: {error}")
  76. return await render_template(
  77. "error.html",
  78. network=app.config["NETWORK"],
  79. error_code="503",
  80. error="Service temporarily unavailable"
  81. ), 503
  82. @app.errorhandler(404)
  83. async def handle_not_found(error):
  84. return await render_template(
  85. "error.html",
  86. network=app.config["NETWORK"],
  87. error_code="404",
  88. error="Page not found"
  89. ), 404
  90. def format_hashrate(hashrate: float) -> str:
  91. """Format hashrate with appropriate unit."""
  92. if hashrate >= 1e12:
  93. return f"{hashrate / 1e12:.2f} TH/s"
  94. elif hashrate >= 1e9:
  95. return f"{hashrate / 1e9:.2f} GH/s"
  96. elif hashrate >= 1e6:
  97. return f"{hashrate / 1e6:.2f} MH/s"
  98. elif hashrate >= 1e3:
  99. return f"{hashrate / 1e3:.2f} KH/s"
  100. else:
  101. return f"{hashrate:.2f} H/s"
  102. def format_bytes(size: int) -> str:
  103. """Format byte size with appropriate unit."""
  104. if size >= 1024 * 1024:
  105. return f"{size / (1024 * 1024):.2f} MB"
  106. elif size >= 1024:
  107. return f"{size / 1024:.2f} KB"
  108. else:
  109. return f"{size} bytes"
  110. @app.route("/")
  111. async def index():
  112. current_difficulty = await rpc.call("current_difficulty", params=[])
  113. current_height = await rpc.call("current_height", params=[])
  114. latest_blocks = await rpc.call("latest_blocks", params=[20])
  115. hashrate = await rpc.call("get_hashrate", params=[])
  116. for block in latest_blocks:
  117. dt = datetime.fromtimestamp(block["timestamp"], tz=timezone.utc)
  118. block["timestamp"] = dt.strftime("%H:%M UTC %d %b %Y")
  119. return await render_template(
  120. "index.html",
  121. network=app.config["NETWORK"],
  122. current_difficulty=current_difficulty[0],
  123. current_height=current_height,
  124. hashrate=format_hashrate(hashrate),
  125. latest_blocks=latest_blocks,
  126. )
  127. @app.route("/block/<int:block_height>")
  128. async def get_block_by_height(block_height: int):
  129. if block_height < 0:
  130. abort(404)
  131. current_difficulty = await rpc.call("current_difficulty", params=[])
  132. current_height = await rpc.call("current_height", params=[])
  133. hashrate = await rpc.call("get_hashrate", params=[])
  134. block = await rpc.call("get_block", params=[block_height])
  135. dt = datetime.fromtimestamp(block["timestamp"], tz=timezone.utc)
  136. block["timestamp"] = dt.strftime("%H:%M UTC %d %b %Y")
  137. block["n_txs"] = len(block["txs"])
  138. return await render_template(
  139. "block.html",
  140. network=app.config["NETWORK"],
  141. current_difficulty=current_difficulty[0],
  142. current_height=current_height,
  143. hashrate=format_hashrate(hashrate),
  144. block=block,
  145. monero_hash=block["monero_hash"],
  146. )
  147. @app.route("/tx/<tx_hash>")
  148. async def get_tx_by_hash(tx_hash: str):
  149. # Validate hex string
  150. if not all(c in '0123456789abcdefABCDEF' for c in tx_hash):
  151. abort(404)
  152. current_difficulty = await rpc.call("current_difficulty", params=[])
  153. current_height = await rpc.call("current_height", params=[])
  154. hashrate = await rpc.call("get_hashrate", params=[])
  155. tx = await rpc.call("get_tx", params=[tx_hash])
  156. return await render_template(
  157. "tx.html",
  158. network=app.config["NETWORK"],
  159. current_difficulty=current_difficulty[0],
  160. current_height=current_height,
  161. hashrate=format_hashrate(hashrate),
  162. tx=tx,
  163. )
  164. @app.route("/search")
  165. async def search():
  166. """Search for blocks by height/hash or transactions by hash."""
  167. query = request.args.get("q", "").strip()
  168. if not query:
  169. return redirect(url_for("index"))
  170. # Try to interpret as block height (integer)
  171. if query.isdigit():
  172. return redirect(url_for("get_block_by_height", block_height=int(query)))
  173. # Check if it looks like a hex hash
  174. if all(c in '0123456789abcdefABCDEF' for c in query):
  175. # Use the search RPC to determine if it's a block or tx hash
  176. try:
  177. result = await rpc.call("search", params=[query])
  178. if result["type"] == "block":
  179. return redirect(f"/block/{result['height']}")
  180. elif result["type"] == "tx":
  181. return redirect(url_for("get_tx_by_hash", tx_hash=query))
  182. except JsonRpcError:
  183. pass
  184. # Try as contract ID (base58)
  185. try:
  186. contract = await rpc.call("get_contract", params=[query])
  187. return redirect(url_for("get_contract", contract_id=query))
  188. except JsonRpcError:
  189. pass
  190. # Nothing found
  191. return await render_template(
  192. "error.html",
  193. network=app.config["NETWORK"],
  194. error_code="Not Found",
  195. error=f"No block, transaction, or contract found for: {query}"
  196. ), 404
  197. @app.route("/contract/<contract_id>")
  198. async def get_contract(contract_id: str):
  199. current_difficulty = await rpc.call("current_difficulty", params=[])
  200. current_height = await rpc.call("current_height", params=[])
  201. hashrate = await rpc.call("get_hashrate", params=[])
  202. contract = await rpc.call("get_contract", params=[contract_id])
  203. contract["wasm_size_formatted"] = format_bytes(int(contract["wasm_size"]))
  204. return await render_template(
  205. "contract.html",
  206. network=app.config["NETWORK"],
  207. current_difficulty=current_difficulty[0],
  208. current_height=current_height,
  209. hashrate=format_hashrate(hashrate),
  210. contract=contract,
  211. )
  212. @app.route("/contracts")
  213. async def list_contracts():
  214. current_difficulty = await rpc.call("current_difficulty", params=[])
  215. current_height = await rpc.call("current_height", params=[])
  216. hashrate = await rpc.call("get_hashrate", params=[])
  217. contracts = await rpc.call("list_contracts", params=[])
  218. contract_count = await rpc.call("contract_count", params=[])
  219. for contract in contracts:
  220. contract["wasm_size_formatted"] = format_bytes(int(contract["wasm_size"]))
  221. return await render_template(
  222. "contracts.html",
  223. network=app.config["NETWORK"],
  224. current_difficulty=current_difficulty[0],
  225. current_height=current_height,
  226. hashrate=format_hashrate(hashrate),
  227. contracts=contracts,
  228. contract_count=contract_count,
  229. )
  230. @app.route("/stats")
  231. async def stats():
  232. current_difficulty = await rpc.call("current_difficulty", params=[])
  233. current_height = await rpc.call("current_height", params=[])
  234. hashrate = await rpc.call("get_hashrate", params=[])
  235. stats_data = await rpc.call("get_stats", params=[])
  236. return await render_template(
  237. "stats.html",
  238. network=app.config["NETWORK"],
  239. current_difficulty=current_difficulty[0],
  240. current_height=current_height,
  241. hashrate=format_hashrate(hashrate),
  242. stats=stats_data,
  243. )
  244. @app.route("/stats/daily_tx_chart.png")
  245. async def daily_tx_chart():
  246. """Generate daily average transactions chart as PNG using matplotlib."""
  247. import io
  248. import matplotlib
  249. matplotlib.use('Agg') # Non-interactive backend
  250. import matplotlib.pyplot as plt
  251. import matplotlib.dates as mdates
  252. stats_data = await rpc.call("get_stats", params=[])
  253. daily_stats = stats_data.get("daily_stats", [])
  254. # Filter to last 90 days
  255. if daily_stats:
  256. max_day = max(d["day"] for d in daily_stats)
  257. daily_stats = [d for d in daily_stats if d["day"] >= max_day - 90]
  258. # Create figure with dark theme
  259. plt.style.use('dark_background')
  260. fig, ax = plt.subplots(figsize=(12, 4), dpi=100)
  261. fig.patch.set_facecolor('#0d1117')
  262. ax.set_facecolor('#0d1117')
  263. if daily_stats:
  264. # Convert day numbers to dates
  265. dates = [datetime.fromtimestamp(d["day"] * 86400, tz=timezone.utc) for d in daily_stats]
  266. values = [d["avg_tx"] for d in daily_stats]
  267. ax.fill_between(dates, values, alpha=0.3, color='#6366f1')
  268. ax.plot(dates, values, color='#6366f1', linewidth=2)
  269. # Format x-axis
  270. ax.xaxis.set_major_formatter(mdates.DateFormatter('%b %d'))
  271. ax.xaxis.set_major_locator(mdates.DayLocator(interval=7))
  272. plt.xticks(rotation=45, ha='right')
  273. ax.set_ylabel('Avg TX per Block', color='#9ca3af')
  274. ax.tick_params(colors='#9ca3af')
  275. ax.spines['bottom'].set_color('#30363d')
  276. ax.spines['left'].set_color('#30363d')
  277. ax.spines['top'].set_visible(False)
  278. ax.spines['right'].set_visible(False)
  279. ax.grid(True, alpha=0.2, color='#30363d')
  280. else:
  281. ax.text(0.5, 0.5, 'No data available', ha='center', va='center',
  282. transform=ax.transAxes, color='#9ca3af', fontsize=14)
  283. ax.set_xlim(0, 1)
  284. ax.set_ylim(0, 1)
  285. plt.tight_layout()
  286. # Save to bytes buffer
  287. buf = io.BytesIO()
  288. plt.savefig(buf, format='png', facecolor='#0d1117', edgecolor='none')
  289. plt.close(fig)
  290. buf.seek(0)
  291. return Response(buf.getvalue(), mimetype='image/png')
  292. if __name__ == "__main__":
  293. app.run(host="127.0.0.1", port=5000, debug=True)