generated from nathanwoodburn/python-webserver-template
feat: Add initial server
This commit is contained in:
366
server.py
366
server.py
@@ -9,13 +9,21 @@ from flask import (
|
||||
)
|
||||
import os
|
||||
import requests
|
||||
from datetime import datetime
|
||||
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):
|
||||
@@ -23,6 +31,155 @@ def find(name, path):
|
||||
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):
|
||||
@@ -71,13 +228,7 @@ def wellknown(path):
|
||||
# region Main routes
|
||||
@app.route("/")
|
||||
def index():
|
||||
# Print the IP address of the requester
|
||||
print(f"Request from IP: {request.remote_addr}")
|
||||
# And the headers
|
||||
print(f"Request headers: {request.headers}")
|
||||
# Get current time in the format "dd MMM YYYY hh:mm AM/PM"
|
||||
current_datetime = datetime.now().strftime("%d %b %Y %I:%M %p")
|
||||
return render_template("index.html", datetime=current_datetime)
|
||||
return render_template("index.html")
|
||||
|
||||
|
||||
@app.route("/<path:path>")
|
||||
@@ -107,25 +258,194 @@ def catch_all(path: str):
|
||||
|
||||
# region API routes
|
||||
|
||||
api_requests = 0
|
||||
|
||||
@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/data", methods=["GET"])
|
||||
def api_data():
|
||||
"""
|
||||
Example API endpoint that returns some data.
|
||||
You can modify this to return whatever data you need.
|
||||
"""
|
||||
@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
|
||||
|
||||
global api_requests
|
||||
api_requests += 1
|
||||
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)
|
||||
|
||||
data = {
|
||||
"header": "Sample API Response",
|
||||
"content": f"Hello, this is a sample API response! You have called this endpoint {api_requests} times.",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
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(data)
|
||||
|
||||
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
|
||||
@@ -140,4 +460,4 @@ def not_found(e):
|
||||
|
||||
# endregion
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=True, port=5000, host="127.0.0.1")
|
||||
app.run(debug=True, port=5000, host="0.0.0.0")
|
||||
|
||||
Reference in New Issue
Block a user