from flask import ( Flask, make_response, request, jsonify, render_template, send_from_directory, send_file, ) import os import requests from datetime import datetime, UTC import dotenv from typing import Any dotenv.load_dotenv() app = Flask(__name__) POOL_API_BASE = os.getenv("POOL_API_BASE", "http://127.0.0.1:32872/api") POOL_REQUEST_TIMEOUT = float(os.getenv("POOL_REQUEST_TIMEOUT", "6")) NODE_RPC_URL = os.getenv("NODE_RPC_URL", "http://127.0.0.1:32869") NODE_RPC_USER = os.getenv("NODE_RPC_USER", "") NODE_RPC_PASS = os.getenv("NODE_RPC_PASS", "") NODE_RPC_TIMEOUT = float(os.getenv("NODE_RPC_TIMEOUT", "6")) def find(name, path): for root, dirs, files in os.walk(path): if name in files: return os.path.join(root, name) def pool_get(endpoint: str) -> Any: """Fetch data from the upstream pool API.""" base = POOL_API_BASE.rstrip("/") url = f"{base}/{endpoint.lstrip('/')}" resp = requests.get(url, timeout=POOL_REQUEST_TIMEOUT) resp.raise_for_status() return resp.json() def to_float(value: Any, default: float = 0.0) -> float: try: return float(value) except (TypeError, ValueError): return default def to_int(value: Any, default: int = 0) -> int: try: return int(value) except (TypeError, ValueError): return default def satoshi_to_fbc(value: Any) -> float: return to_float(value, 0.0) / 1_000_000.0 def normalize_address(raw: str) -> str: return (raw or "").strip() def worker_matches_address(worker_username: str, address_query: str) -> bool: """Support searching by base address while matching address.rig worker names.""" username = normalize_address(worker_username) address = normalize_address(address_query) if not username or not address: return False return username == address or username.startswith(f"{address}.") def sanitize_worker_data(worker: dict) -> dict: """Remove sensitive fields from worker data before sending to client.""" sanitized = dict(worker) sanitized.pop("address", None) # Remove IP address return sanitized # Blockchain constants BLOCK_TIME_SECONDS = 120 # Target block time in seconds INITIAL_BLOCK_REWARD = 500_000_000 # 500 FBC in satoshis HALVING_INTERVAL = 1_051_200 # blocks between halvings BLOCKS_PER_DAY = 86400 // BLOCK_TIME_SECONDS def get_block_reward(height: int) -> int: """Calculate block reward (in satoshis) at given height, accounting for halvings.""" halvings = height // HALVING_INTERVAL if halvings >= 32: # Prevent overflow; reward becomes 0 return 0 return INITIAL_BLOCK_REWARD >> halvings def estimate_network_hashrate(difficulty: float) -> float: """Estimate network hashrate from chain difficulty.""" if difficulty <= 0: return 0.0 # Handshake-like PoW approximation. return (difficulty * (2**32)) / BLOCK_TIME_SECONDS def get_blockchain_info() -> dict: """Fetch getblockchaininfo from node RPC.""" payload = {"method": "getblockchaininfo", "params": [], "id": "pool-dashboard"} auth = (NODE_RPC_USER, NODE_RPC_PASS) if NODE_RPC_USER and NODE_RPC_PASS else None resp = requests.post( NODE_RPC_URL, json=payload, auth=auth, timeout=NODE_RPC_TIMEOUT ) resp.raise_for_status() data = resp.json() if data.get("error"): raise ValueError(f"RPC error: {data['error']}") result = data.get("result") if not isinstance(result, dict): raise ValueError("Invalid RPC response: missing result") return result def resolve_network_metrics(stats: dict, blocks: list) -> dict: """Resolve network difficulty/hashrate, preferring node RPC.""" fallback_height = to_int(blocks[0].get("height"), 0) if blocks else 0 try: info = get_blockchain_info() difficulty = to_float(info.get("difficulty")) height = to_int(info.get("blocks"), fallback_height) return { "difficulty": difficulty, "hashrate": estimate_network_hashrate(difficulty), "height": height, "source": "node_rpc", } except (requests.RequestException, ValueError, TypeError): difficulty = to_float(stats.get("window_difficulty")) return { "difficulty": difficulty, "hashrate": estimate_network_hashrate(difficulty), "height": fallback_height, "source": "pool_window_difficulty", } def calculate_reward_per_hash(current_height: int, network_hashrate: float) -> float: """Calculate expected reward per H/s per day in FBC. Formula: (block_reward_fbc * blocks_per_day) / network_hashrate_hps """ if network_hashrate <= 0: return 0.0 block_reward_fbc = satoshi_to_fbc(get_block_reward(current_height)) daily_network_emission_fbc = block_reward_fbc * BLOCKS_PER_DAY return daily_network_emission_fbc / network_hashrate def calculate_worker_reward_estimate( worker_hashrate: float, pool_hashrate: float, reward_per_hash: float ) -> dict: """Calculate estimated rewards for a worker.""" if pool_hashrate <= 0 or reward_per_hash <= 0: return { "daily_fbc": 0.0, "hourly_fbc": 0.0, "weekly_fbc": 0.0, "pool_share_pct": 0.0, } pool_share = worker_hashrate / pool_hashrate if pool_hashrate > 0 else 0.0 daily_reward = worker_hashrate * reward_per_hash return { "daily_fbc": daily_reward, "hourly_fbc": daily_reward / 24, "weekly_fbc": daily_reward * 7, "pool_share_pct": pool_share * 100, } # Assets routes @app.route("/assets/") def send_assets(path): if path.endswith(".json"): return send_from_directory( "templates/assets", path, mimetype="application/json" ) if os.path.isfile("templates/assets/" + path): return send_from_directory("templates/assets", path) # Try looking in one of the directories filename: str = path.split("/")[-1] if ( filename.endswith(".png") or filename.endswith(".jpg") or filename.endswith(".jpeg") or filename.endswith(".svg") ): if os.path.isfile("templates/assets/img/" + filename): return send_from_directory("templates/assets/img", filename) if os.path.isfile("templates/assets/img/favicon/" + filename): return send_from_directory("templates/assets/img/favicon", filename) return render_template("404.html"), 404 # region Special routes @app.route("/favicon.png") def faviconPNG(): return send_from_directory("templates/assets/img", "favicon.png") @app.route("/.well-known/") def wellknown(path): # Try to proxy to https://nathan.woodburn.au/.well-known/ req = requests.get(f"https://nathan.woodburn.au/.well-known/{path}") return make_response( req.content, 200, {"Content-Type": req.headers["Content-Type"]} ) # endregion # region Main routes @app.route("/") def index(): return render_template("index.html") @app.route("/") def catch_all(path: str): if os.path.isfile("templates/" + path): return render_template(path) # Try with .html if os.path.isfile("templates/" + path + ".html"): return render_template(path + ".html") if os.path.isfile("templates/" + path.strip("/") + ".html"): return render_template(path.strip("/") + ".html") # Try to find a file matching if path.count("/") < 1: # Try to find a file matching filename = find(path, "templates") if filename: return send_file(filename) return render_template("404.html"), 404 # endregion # region API routes @app.route("/api/v1/status", methods=["GET"]) def status(): return jsonify( { "status": "ok", "version": "1.0", "updated_at": datetime.now(UTC).isoformat(), } ) @app.route("/api/v1/pool/overview", methods=["GET"]) def pool_overview(): try: stats = pool_get("stats") workers = pool_get("workers") blocks = pool_get("blocks") balances = pool_get("balances") payouts = pool_get("payouts") except requests.RequestException as exc: return jsonify({"error": f"Failed to reach pool API: {exc}"}), 502 except ValueError as exc: return jsonify({"error": f"Invalid pool API response: {exc}"}), 502 total_balance = sum(to_int(entry.get("balance")) for entry in balances) total_paid = sum(to_int(entry.get("total_paid")) for entry in balances) total_earned = sum(to_int(entry.get("total_earned")) for entry in balances) total_payout_amount = sum(to_int(entry.get("amount")) for entry in payouts) pool_hashrate = to_float(stats.get("hashrate")) network = resolve_network_metrics(stats, blocks) network_hashrate = to_float(network.get("hashrate")) current_height = to_int(network.get("height"), 0) reward_per_hash = calculate_reward_per_hash(current_height, network_hashrate) current_block_reward_fbc = satoshi_to_fbc(get_block_reward(current_height)) daily_network_emission_fbc = current_block_reward_fbc * BLOCKS_PER_DAY workers_with_estimates = [] for worker in workers: w = sanitize_worker_data(worker) w["estimated_reward"] = calculate_worker_reward_estimate( to_float(worker.get("hashrate")), pool_hashrate, reward_per_hash ) workers_with_estimates.append(w) summary = { "workers": to_int(stats.get("workers"), len(workers)), "hashrate": pool_hashrate, "blocks_found": to_int(stats.get("blocks_found")), "shares_in_window": to_int(stats.get("shares_in_window")), "window_difficulty": to_float(stats.get("window_difficulty")), "fee": to_float(stats.get("fee")), "uptime": to_int(stats.get("uptime")), "tracked_balances": len(balances), "tracked_payouts": len(payouts), "total_balance": total_balance, "total_balance_fbc": satoshi_to_fbc(total_balance), "total_paid": total_paid, "total_paid_fbc": satoshi_to_fbc(total_paid), "total_earned": total_earned, "total_earned_fbc": satoshi_to_fbc(total_earned), "total_payout_amount": total_payout_amount, "total_payout_amount_fbc": satoshi_to_fbc(total_payout_amount), "network_hashrate": network_hashrate, "network_difficulty": to_float(network.get("difficulty")), "network_metrics_source": network.get("source"), "block_time_seconds": BLOCK_TIME_SECONDS, "blocks_per_day": BLOCKS_PER_DAY, "current_block_reward_fbc": current_block_reward_fbc, "daily_network_emission_fbc": daily_network_emission_fbc, "estimated_reward_per_hash_daily_fbc": reward_per_hash, } return jsonify( { "source": POOL_API_BASE, "updated_at": datetime.now(UTC).isoformat(), "summary": summary, "workers": workers_with_estimates, "blocks": blocks, "balances": balances, "payouts": payouts, } ) @app.route("/api/v1/pool/miner", methods=["GET"]) def pool_miner(): address = normalize_address(request.args.get("address", "")) if not address: return jsonify({"error": "Missing required query parameter: address"}), 400 try: stats = pool_get("stats") workers = pool_get("workers") blocks = pool_get("blocks") balances = pool_get("balances") payouts = pool_get("payouts") except requests.RequestException as exc: return jsonify({"error": f"Failed to reach pool API: {exc}"}), 502 except ValueError as exc: return jsonify({"error": f"Invalid pool API response: {exc}"}), 502 # Estimate rewards first pool_hashrate = to_float(stats.get("hashrate")) network = resolve_network_metrics(stats, blocks) network_hashrate = to_float(network.get("hashrate")) current_height = to_int(network.get("height"), 0) reward_per_hash = calculate_reward_per_hash(current_height, network_hashrate) matching_workers = [ worker for worker in workers if worker_matches_address(worker.get("username", ""), address) ] matching_blocks = [ block for block in blocks if worker_matches_address(block.get("found_by", ""), address) ] matching_balances = [ balance for balance in balances if worker_matches_address(balance.get("address", ""), address) ] matching_payouts = [ payout for payout in payouts if worker_matches_address(payout.get("address", ""), address) ] total_hashrate = sum( to_float(worker.get("hashrate")) for worker in matching_workers ) accepted = sum(to_int(worker.get("accepted")) for worker in matching_workers) rejected = sum(to_int(worker.get("rejected")) for worker in matching_workers) stale = sum(to_int(worker.get("stale")) for worker in matching_workers) blocks_found = sum(to_int(worker.get("blocks")) for worker in matching_workers) total_balance = sum(to_int(entry.get("balance")) for entry in matching_balances) total_earned = sum(to_int(entry.get("total_earned")) for entry in matching_balances) total_paid = sum(to_int(entry.get("total_paid")) for entry in matching_balances) payout_amount = sum(to_int(entry.get("amount")) for entry in matching_payouts) worker_reward_estimate = calculate_worker_reward_estimate( total_hashrate, pool_hashrate, reward_per_hash ) # Add reward estimates for each matching worker workers_with_estimates = [] for worker in matching_workers: w = sanitize_worker_data(worker) w["estimated_reward"] = calculate_worker_reward_estimate( to_float(worker.get("hashrate")), pool_hashrate, reward_per_hash ) workers_with_estimates.append(w) return jsonify( { "address": address, "updated_at": datetime.utcnow().isoformat() + "Z", "summary": { "matching_workers": len(matching_workers), "hashrate": total_hashrate, "accepted": accepted, "rejected": rejected, "stale": stale, "blocks_found": blocks_found, "total_balance": total_balance, "total_balance_fbc": satoshi_to_fbc(total_balance), "total_earned": total_earned, "total_earned_fbc": satoshi_to_fbc(total_earned), "total_paid": total_paid, "total_paid_fbc": satoshi_to_fbc(total_paid), "payout_amount": payout_amount, "payout_amount_fbc": satoshi_to_fbc(payout_amount), "estimated_daily_reward_fbc": worker_reward_estimate["daily_fbc"], "estimated_weekly_reward_fbc": worker_reward_estimate["weekly_fbc"], "pool_share_pct": worker_reward_estimate["pool_share_pct"], }, "workers": workers_with_estimates, "blocks": matching_blocks, "balances": matching_balances, "payouts": matching_payouts, } ) # endregion # region Error Catching # 404 catch all @app.errorhandler(404) def not_found(e): return render_template("404.html"), 404 # endregion if __name__ == "__main__": app.run(debug=True, port=5000, host="0.0.0.0")