generated from nathanwoodburn/python-webserver-template
464 lines
15 KiB
Python
464 lines
15 KiB
Python
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/<path:path>")
|
|
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/<path:path>")
|
|
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("/<path:path>")
|
|
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")
|