Files
firepool/server.py
Nathan Woodburn 5519132c27
All checks were successful
Build Docker / BuildImage (push) Successful in 58s
Check Code Quality / RuffCheck (push) Successful in 1m6s
feat: Add initial server
2026-04-04 19:22:20 +11:00

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")