From d02aadeb4d317261b0adbf366aa81b9beba0983b Mon Sep 17 00:00:00 2001 From: "gr0wth.eth.xmr" <38635290+ethgr0wth@users.noreply.github.com> Date: Thu, 16 Oct 2025 21:47:33 -0400 Subject: [PATCH] Add P2P health checks and surface metrics in rewards portal --- README.md | 5 + .../node-operator-rewards-portal/README.md | 8 + .../backend/README.md | 56 +++ .../backend/app/__init__.py | 0 .../backend/app/config.py | 61 +++ .../backend/app/dependencies.py | 35 ++ .../backend/app/main.py | 340 ++++++++++++++ .../backend/app/models.py | 152 +++++++ .../backend/app/security.py | 54 +++ .../backend/app/services.py | 208 +++++++++ .../backend/app/storage.py | 293 ++++++++++++ .../backend/requirements.txt | 9 + .../backend/tests/test_rewards.py | 121 +++++ .../frontend/README.md | 21 + .../frontend/assets/css/style.css | 405 +++++++++++++++++ .../frontend/assets/js/app.js | 421 ++++++++++++++++++ .../frontend/index.html | 148 ++++++ 17 files changed, 2337 insertions(+) create mode 100644 ambassadors/node-operator-rewards-portal/README.md create mode 100644 ambassadors/node-operator-rewards-portal/backend/README.md create mode 100644 ambassadors/node-operator-rewards-portal/backend/app/__init__.py create mode 100644 ambassadors/node-operator-rewards-portal/backend/app/config.py create mode 100644 ambassadors/node-operator-rewards-portal/backend/app/dependencies.py create mode 100644 ambassadors/node-operator-rewards-portal/backend/app/main.py create mode 100644 ambassadors/node-operator-rewards-portal/backend/app/models.py create mode 100644 ambassadors/node-operator-rewards-portal/backend/app/security.py create mode 100644 ambassadors/node-operator-rewards-portal/backend/app/services.py create mode 100644 ambassadors/node-operator-rewards-portal/backend/app/storage.py create mode 100644 ambassadors/node-operator-rewards-portal/backend/requirements.txt create mode 100644 ambassadors/node-operator-rewards-portal/backend/tests/test_rewards.py create mode 100644 ambassadors/node-operator-rewards-portal/frontend/README.md create mode 100644 ambassadors/node-operator-rewards-portal/frontend/assets/css/style.css create mode 100644 ambassadors/node-operator-rewards-portal/frontend/assets/js/app.js create mode 100644 ambassadors/node-operator-rewards-portal/frontend/index.html diff --git a/README.md b/README.md index d6340fa986..f293918019 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,11 @@ that repository unless it is for development reasons. The contribution workflow is described in [CONTRIBUTING.md](CONTRIBUTING.md) and useful hints for developers can be found in [doc/developer-notes.md](doc/developer-notes.md). +Node Operator Rewards Portal +---------------------------- + +The `ambassadors/node-operator-rewards-portal/` directory contains a full-stack rewards dashboard for node operators with a FastAPI backend and a neon glassmorphic frontend. Consult `ambassadors/node-operator-rewards-portal/backend/README.md` and `ambassadors/node-operator-rewards-portal/frontend/README.md` for setup instructions. + Testing ------- diff --git a/ambassadors/node-operator-rewards-portal/README.md b/ambassadors/node-operator-rewards-portal/README.md new file mode 100644 index 0000000000..86b911284a --- /dev/null +++ b/ambassadors/node-operator-rewards-portal/README.md @@ -0,0 +1,8 @@ +# Interchained Node Operator Rewards Portal + +The Node Operator Rewards Portal is a full-stack experience that tracks uptime and performance for Interchained nodes and allocates daily rewards from a shared pool. It is composed of: + +- `backend/` — FastAPI service backed by Redis for authentication, node management, health monitoring, and reward distribution. +- `frontend/` — Dark neon glass UI built with vanilla HTML/CSS/JS that consumes the backend API. + +Refer to the individual READMEs for detailed setup instructions. diff --git a/ambassadors/node-operator-rewards-portal/backend/README.md b/ambassadors/node-operator-rewards-portal/backend/README.md new file mode 100644 index 0000000000..b12277983f --- /dev/null +++ b/ambassadors/node-operator-rewards-portal/backend/README.md @@ -0,0 +1,56 @@ +# Node Operator Rewards Portal Backend + +This FastAPI application powers the Interchained Node Operator Rewards Portal. It keeps track of registered nodes, monitors their health and responsiveness, and distributes daily rewards proportionally to performance. + +## Features + +- JWT-secured authentication endpoints for node operators. +- Node registration and management API. +- Background health monitoring that pings node RPC endpoints every 60 seconds and performs + bitcoind-compatible P2P socket checks to ensure network reachability. +- Redis-backed storage for users, nodes, uptime metrics, and reward history. +- Reward engine that performs daily weighted distributions and flags high-performing nodes. +- Administrative APIs for managing the reward pool balance, adjusting daily payouts, moderating nodes, and exporting payout + snapshots. + +## Requirements + +- Python 3.11+ +- Redis server (DB 6 is used by default) + +Install dependencies: + +```bash +pip install -r requirements.txt +``` + +Create a `.env` file with the following minimum configuration: + +```env +PORTAL_SECRET_KEY="<32+ character random string>" +PORTAL_REDIS_URL="redis://localhost:6379/6" +PORTAL_REWARD_POOL_DAILY=1000 +PORTAL_INITIAL_POOL_BALANCE=100000 +PORTAL_P2P_TIMEOUT_SECONDS=5 +PORTAL_ADMIN_EMAILS="admin@example.com,finance@example.com" +``` + +Run the development server: + +```bash +uvicorn app.main:app --reload +``` + +The API documentation is available at `http://localhost:8000/docs` once the server is running. + +### Admin access + +Any account whose email matches one of the `PORTAL_ADMIN_EMAILS` entries is granted administrative access after registration. +Admin tokens expose the following additional routes: + +- `GET /admin/pool` – Inspect the live pool balance, daily distribution amount, and recent payout metadata. +- `POST /admin/pool/adjust` – Deposit or withdraw funds from the pool balance. +- `PUT /admin/pool/daily` – Update the daily payout amount used by the reward engine. +- `GET /admin/nodes` – Review all registered nodes including uptime metrics and owner contact emails. +- `POST /admin/nodes/{id}/reject` / `POST /admin/nodes/{id}/reinstate` – Toggle node eligibility for payouts. +- `GET /admin/exports/payouts` – Generate a CSV-friendly snapshot of the current payout cycle (node, wallet, share). diff --git a/ambassadors/node-operator-rewards-portal/backend/app/__init__.py b/ambassadors/node-operator-rewards-portal/backend/app/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ambassadors/node-operator-rewards-portal/backend/app/config.py b/ambassadors/node-operator-rewards-portal/backend/app/config.py new file mode 100644 index 0000000000..ed3637f76f --- /dev/null +++ b/ambassadors/node-operator-rewards-portal/backend/app/config.py @@ -0,0 +1,61 @@ +"""Configuration helpers for the Node Operator Rewards Portal backend.""" + +from __future__ import annotations + +import os +from functools import lru_cache +from typing import List, Optional + +from pydantic import BaseSettings, Field, RedisDsn, validator + + +class Settings(BaseSettings): + """Application configuration loaded from environment variables.""" + + app_name: str = "Interchained Node Operator Rewards Portal" + secret_key: str = Field(..., env="PORTAL_SECRET_KEY") + jwt_algorithm: str = "HS256" + access_token_ttl_seconds: int = 60 * 60 * 4 # 4 hours + redis_url: RedisDsn = Field("redis://localhost:6379/6", env="PORTAL_REDIS_URL") + reward_pool_daily: float = Field(1000.0, env="PORTAL_REWARD_POOL_DAILY") + initial_pool_balance: float = Field(100000.0, env="PORTAL_INITIAL_POOL_BALANCE") + reward_distribution_interval_seconds: int = 60 * 60 * 24 + health_check_interval_seconds: int = 60 + rpc_timeout_seconds: float = Field(5.0, ge=0.1) + p2p_timeout_seconds: float = Field(5.0, ge=0.1, env="PORTAL_P2P_TIMEOUT_SECONDS") + admin_emails: List[str] = Field(default_factory=list, env="PORTAL_ADMIN_EMAILS") + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + + @validator("secret_key") + def _validate_secret_key(cls, value: Optional[str]) -> str: + if not value: + raise ValueError( + "PORTAL_SECRET_KEY must be provided via environment variables or a .env file." + ) + if len(value) < 32: + raise ValueError("PORTAL_SECRET_KEY must be at least 32 characters long.") + return value + + @validator("admin_emails", pre=True) + def _split_admin_emails(cls, value: str | List[str]) -> List[str]: + if isinstance(value, list): + return [email.strip().lower() for email in value if email] + if not value: + return [] + return [email.strip().lower() for email in value.split(",") if email.strip()] + + +@lru_cache +def get_settings() -> Settings: + """Return cached settings instance.""" + + return Settings() + + +def get_redis_url() -> str: + """Convenience helper for retrieving the Redis connection string.""" + + return str(get_settings().redis_url) diff --git a/ambassadors/node-operator-rewards-portal/backend/app/dependencies.py b/ambassadors/node-operator-rewards-portal/backend/app/dependencies.py new file mode 100644 index 0000000000..598722a153 --- /dev/null +++ b/ambassadors/node-operator-rewards-portal/backend/app/dependencies.py @@ -0,0 +1,35 @@ +"""Reusable FastAPI dependencies.""" + +from __future__ import annotations + +from fastapi import Depends, HTTPException, Security, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from .models import AuthenticatedUser +from .security import decode_access_token +from .storage import RedisRepository + +security = HTTPBearer(auto_error=True) +repo = RedisRepository() + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Security(security), +) -> AuthenticatedUser: + payload = decode_access_token(credentials.credentials) + user_id = payload.get("sub") + email = payload.get("email") + if not user_id or not email: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload") + user_data = await repo.get_user_by_id(str(user_id)) + if not user_data: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") + role = user_data.get("role", "operator") + is_admin = payload.get("is_admin", role == "admin") + return AuthenticatedUser(id=str(user_id), email=email, is_admin=bool(is_admin)) + + +async def require_admin_user(user: AuthenticatedUser = Depends(get_current_user)) -> AuthenticatedUser: + if not user.is_admin: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required") + return user diff --git a/ambassadors/node-operator-rewards-portal/backend/app/main.py b/ambassadors/node-operator-rewards-portal/backend/app/main.py new file mode 100644 index 0000000000..6f4bc0eb37 --- /dev/null +++ b/ambassadors/node-operator-rewards-portal/backend/app/main.py @@ -0,0 +1,340 @@ +"""FastAPI application for the Node Operator Rewards Portal.""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone +from typing import List + +from fastapi import Depends, FastAPI, HTTPException, Response, status +from fastapi.middleware.cors import CORSMiddleware + +from .config import get_settings +from .dependencies import get_current_user, require_admin_user +from .models import ( + AdminNodeDetail, + AuthenticatedUser, + DashboardSummary, + NodeCreate, + NodeRead, + NodeStatus, + NodeUpdate, + NodeWithMetrics, + PoolAdjustment, + PoolDailyUpdate, + PoolState, + RewardPreview, + PayoutExportRow, + TokenResponse, + UptimeScore, + UserCreate, + UserRead, +) +from .security import create_access_token, hash_password, verify_password +from .services import HealthMonitor, RewardEngine +from .storage import RedisRepository + +app = FastAPI(title="Interchained Rewards Portal", version="1.0.0") +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +repo = RedisRepository() +health_monitor = HealthMonitor(repo) +reward_engine = RewardEngine(repo) + + +def _empty_uptime(node_id: str) -> UptimeScore: + return UptimeScore( + node_id=node_id, + uptime_ratio=0.0, + total_checks=0, + successful_checks=0, + average_latency_ms=None, + p2p_uptime_ratio=0.0, + p2p_total_checks=0, + p2p_successful_checks=0, + p2p_average_latency_ms=None, + ) + + +@app.on_event("startup") +async def startup_event() -> None: + settings = get_settings() + await repo.ensure_pool_defaults(settings.reward_pool_daily, settings.initial_pool_balance) + await health_monitor.start() + await reward_engine.start() + + +@app.on_event("shutdown") +async def shutdown_event() -> None: + await health_monitor.stop() + await reward_engine.stop() + await repo.close() + + +@app.post("/auth/register", response_model=UserRead, status_code=status.HTTP_201_CREATED) +async def register_user(payload: UserCreate) -> UserRead: + existing = await repo.get_user_by_email(payload.email) + if existing: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered") + settings = get_settings() + is_admin = payload.email.lower() in settings.admin_emails + user_id = await repo.create_user(payload.email, hash_password(payload.password), is_admin=is_admin) + return UserRead(id=user_id, email=payload.email.lower()) + + +@app.post("/auth/login", response_model=TokenResponse) +async def login_user(payload: UserCreate) -> TokenResponse: + stored = await repo.get_user_by_email(payload.email) + if not stored or not verify_password(payload.password, stored.get("password", "")): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") + settings = get_settings() + user_id = stored.get("id", stored.get("email")) + email = stored.get("email", payload.email).lower() + is_admin = stored.get("role") == "admin" or email in settings.admin_emails + if is_admin and stored.get("role") != "admin": + await repo.set_user_role(str(user_id), "admin") + token = create_access_token( + user_id, + {"email": email, "is_admin": is_admin}, + ) + return TokenResponse(access_token=token) + + +@app.get("/nodes", response_model=List[NodeWithMetrics]) +async def list_nodes(current_user: AuthenticatedUser = Depends(get_current_user)) -> List[NodeWithMetrics]: + nodes = await repo.get_nodes_for_owner(current_user.id) + results: List[NodeWithMetrics] = [] + for node in nodes: + uptime = await repo.get_uptime(node.id) + last_health = await repo.get_latest_health(node.id) + results.append( + NodeWithMetrics( + **node.dict(), + uptime=uptime or _empty_uptime(node.id), + last_health=last_health, + ) + ) + return results + + +@app.post("/nodes", response_model=NodeRead, status_code=status.HTTP_201_CREATED) +async def create_node(payload: NodeCreate, current_user: AuthenticatedUser = Depends(get_current_user)) -> NodeRead: + now = datetime.now(timezone.utc) + node_data = { + "name": payload.name, + "p2p_host": str(payload.p2p_host), + "p2p_port": str(payload.p2p_port), + "rpc_host": str(payload.rpc_host), + "rpc_port": str(payload.rpc_port), + "wallet_address": payload.wallet_address, + "created_at": now.isoformat(), + "flagged": "false", + "status": NodeStatus.APPROVED.value, + } + node_id = await repo.create_node(current_user.id, node_data) + return NodeRead( + id=node_id, + owner_id=current_user.id, + name=payload.name, + p2p_host=str(payload.p2p_host), + p2p_port=payload.p2p_port, + rpc_host=str(payload.rpc_host), + rpc_port=payload.rpc_port, + wallet_address=payload.wallet_address, + created_at=now, + flagged=False, + status=NodeStatus.APPROVED, + ) + + +@app.put("/nodes/{node_id}", response_model=NodeRead) +async def update_node(node_id: str, payload: NodeUpdate, current_user: AuthenticatedUser = Depends(get_current_user)) -> NodeRead: + node = await repo.get_node(node_id) + if not node or node.owner_id != current_user.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node not found") + updates = {k: str(v) if v is not None else None for k, v in payload.dict(exclude_unset=True).items()} + updates = {k: v for k, v in updates.items() if v is not None} + if updates: + await repo.update_node(node_id, updates) + refreshed = await repo.get_node(node_id) + if not refreshed: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node missing after update") + return refreshed + + +@app.delete( + "/nodes/{node_id}", + status_code=status.HTTP_204_NO_CONTENT, + response_class=Response, +) +async def delete_node( + node_id: str, current_user: AuthenticatedUser = Depends(get_current_user) +) -> Response: + node = await repo.get_node(node_id) + if not node or node.owner_id != current_user.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node not found") + await repo.delete_node(node_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@app.get("/dashboard", response_model=DashboardSummary) +async def get_dashboard(current_user: AuthenticatedUser = Depends(get_current_user)) -> DashboardSummary: + nodes = await repo.get_nodes_for_owner(current_user.id) + active = 0 + flagged = 0 + for node in nodes: + uptime = await repo.get_uptime(node.id) + if uptime and uptime.total_checks and uptime.p2p_total_checks: + if uptime.uptime_ratio > 0.9 and uptime.p2p_uptime_ratio > 0.9: + active += 1 + if node.flagged: + flagged += 1 + settings = get_settings() + last_distribution = await repo.get_last_distribution() + daily_distribution = await repo.get_pool_daily_amount(settings.reward_pool_daily) + return DashboardSummary( + total_nodes=len(nodes), + active_nodes=active, + reward_pool_daily=daily_distribution, + last_distribution=last_distribution, + flagged_nodes=flagged, + ) + + +@app.get("/rewards", response_model=List[RewardPreview]) +async def get_reward_preview(current_user: AuthenticatedUser = Depends(get_current_user)) -> List[RewardPreview]: + nodes = await repo.get_nodes_for_owner(current_user.id) + settings = get_settings() + daily_pool = await repo.get_pool_daily_amount(settings.reward_pool_daily) + previews: List[RewardPreview] = [] + for node in nodes: + uptime = await repo.get_uptime(node.id) + if not uptime: + uptime = _empty_uptime(node.id) + score = reward_engine.score_from_uptime(uptime) + projected = score * daily_pool if score > 0 else 0.0 + if node.status == NodeStatus.REJECTED: + projected = 0.0 + previews.append(RewardPreview(node=node, uptime=uptime, projected_reward=round(projected, 8))) + return previews + + +@app.get("/admin/pool", response_model=PoolState) +async def get_pool_state(_: AuthenticatedUser = Depends(require_admin_user)) -> PoolState: + return await _build_pool_state() + + +@app.post("/admin/pool/adjust", response_model=PoolState) +async def adjust_pool_balance( + payload: PoolAdjustment, + _: AuthenticatedUser = Depends(require_admin_user), +) -> PoolState: + await repo.adjust_pool_balance(payload.amount) + return await _build_pool_state() + + +@app.put("/admin/pool/daily", response_model=PoolState) +async def update_pool_daily( + payload: PoolDailyUpdate, + _: AuthenticatedUser = Depends(require_admin_user), +) -> PoolState: + await repo.set_pool_daily_amount(payload.daily_distribution) + return await _build_pool_state() + + +@app.get("/admin/nodes", response_model=List[AdminNodeDetail]) +async def get_admin_nodes(_: AuthenticatedUser = Depends(require_admin_user)) -> List[AdminNodeDetail]: + nodes = await repo.get_all_nodes() + results: List[AdminNodeDetail] = [] + for node in nodes: + uptime = await repo.get_uptime(node.id) + if not uptime or uptime.total_checks == 0: + uptime = _empty_uptime(node.id) + last_health = await repo.get_latest_health(node.id) + owner = await repo.get_user_by_id(node.owner_id) + owner_email = owner.get("email", "unknown") if owner else "unknown" + results.append( + AdminNodeDetail( + node=NodeWithMetrics(**node.dict(), uptime=uptime, last_health=last_health), + owner_email=owner_email, + ) + ) + return results + + +@app.post("/admin/nodes/{node_id}/reject", response_model=AdminNodeDetail) +async def reject_node(node_id: str, _: AuthenticatedUser = Depends(require_admin_user)) -> AdminNodeDetail: + node = await repo.get_node(node_id) + if not node: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node not found") + await repo.update_node(node_id, {"status": NodeStatus.REJECTED.value}) + return await _admin_node_detail(node_id) + + +@app.post("/admin/nodes/{node_id}/reinstate", response_model=AdminNodeDetail) +async def reinstate_node(node_id: str, _: AuthenticatedUser = Depends(require_admin_user)) -> AdminNodeDetail: + node = await repo.get_node(node_id) + if not node: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node not found") + await repo.update_node(node_id, {"status": NodeStatus.APPROVED.value}) + return await _admin_node_detail(node_id) + + +@app.get("/admin/exports/payouts", response_model=List[PayoutExportRow]) +async def export_payouts(_: AuthenticatedUser = Depends(require_admin_user)) -> List[PayoutExportRow]: + projections = await reward_engine.calculate_reward_shares() + rows: List[PayoutExportRow] = [] + for record in projections: + owner = await repo.get_user_by_id(record.owner_id) + owner_email = owner.get("email", "unknown") if owner else "unknown" + node = await repo.get_node(record.node_id) + if not node: + continue + rows.append( + PayoutExportRow( + node_id=record.node_id, + node_name=node.name, + owner_email=owner_email, + wallet_address=node.wallet_address, + projected_reward=record.amount, + ) + ) + return rows + + +async def _build_pool_state() -> PoolState: + settings = get_settings() + state = await repo.get_pool_state(settings.reward_pool_daily) + nodes = await repo.get_all_nodes() + active = sum(1 for node in nodes if node.status == NodeStatus.APPROVED) + rejected = sum(1 for node in nodes if node.status == NodeStatus.REJECTED) + return PoolState( + current_balance=state["current_balance"], + daily_distribution=state["daily_distribution"], + last_distribution=state["last_distribution"], + total_nodes=len(nodes), + active_nodes=active, + rejected_nodes=rejected, + ) + + +async def _admin_node_detail(node_id: str) -> AdminNodeDetail: + refreshed = await repo.get_node(node_id) + if not refreshed: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node not found") + uptime = await repo.get_uptime(node_id) + if not uptime or uptime.total_checks == 0: + uptime = _empty_uptime(node_id) + last_health = await repo.get_latest_health(node_id) + owner = await repo.get_user_by_id(refreshed.owner_id) + owner_email = owner.get("email", "unknown") if owner else "unknown" + return AdminNodeDetail( + node=NodeWithMetrics(**refreshed.dict(), uptime=uptime, last_health=last_health), + owner_email=owner_email, + ) diff --git a/ambassadors/node-operator-rewards-portal/backend/app/models.py b/ambassadors/node-operator-rewards-portal/backend/app/models.py new file mode 100644 index 0000000000..d647ef8c94 --- /dev/null +++ b/ambassadors/node-operator-rewards-portal/backend/app/models.py @@ -0,0 +1,152 @@ +"""Data models used by the Rewards Portal backend.""" + +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from typing import Dict, Optional + +from pydantic import BaseModel, Field, IPvAnyAddress, validator + + +class UserCreate(BaseModel): + email: str + password: str + + @validator("password") + def _validate_password(cls, value: str) -> str: + if len(value) < 8: + raise ValueError("Password must be at least 8 characters long") + return value + + +class UserRead(BaseModel): + id: str + email: str + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + + +class NodeCreate(BaseModel): + name: str = Field(..., max_length=64) + p2p_host: IPvAnyAddress + p2p_port: int = Field(..., ge=1, le=65535) + rpc_host: IPvAnyAddress + rpc_port: int = Field(..., ge=1, le=65535) + wallet_address: str = Field(..., min_length=32, max_length=128) + + +class NodeUpdate(BaseModel): + name: Optional[str] + p2p_host: Optional[IPvAnyAddress] + p2p_port: Optional[int] + rpc_host: Optional[IPvAnyAddress] + rpc_port: Optional[int] + wallet_address: Optional[str] + + +class NodeStatus(str, Enum): + APPROVED = "approved" + REJECTED = "rejected" + + +class AuthenticatedUser(BaseModel): + id: str + email: str + is_admin: bool = False + + +class NodeRead(BaseModel): + id: str + owner_id: str + name: str + p2p_host: str + p2p_port: int + rpc_host: str + rpc_port: int + wallet_address: str + created_at: datetime + flagged: bool + status: NodeStatus = NodeStatus.APPROVED + + +class HealthCheckResult(BaseModel): + node_id: str + timestamp: datetime + online: bool + rpc_latency_ms: Optional[float] + sync_height: Optional[int] + p2p_online: bool = False + p2p_latency_ms: Optional[float] = None + + +class RewardRecord(BaseModel): + node_id: str + owner_id: str + amount: float + total_score: float + timestamp: datetime + + +class DashboardSummary(BaseModel): + total_nodes: int + active_nodes: int + reward_pool_daily: float + last_distribution: Optional[datetime] + flagged_nodes: int + + +class UptimeScore(BaseModel): + node_id: str + uptime_ratio: float + total_checks: int + successful_checks: int + average_latency_ms: Optional[float] + p2p_uptime_ratio: float = 0.0 + p2p_total_checks: int = 0 + p2p_successful_checks: int = 0 + p2p_average_latency_ms: Optional[float] = None + + +class RewardPreview(BaseModel): + node: NodeRead + uptime: UptimeScore + projected_reward: float + + +class NodeWithMetrics(NodeRead): + uptime: UptimeScore + last_health: Optional[HealthCheckResult] + + +class PoolState(BaseModel): + current_balance: float + daily_distribution: float + last_distribution: Optional[datetime] + total_nodes: int + active_nodes: int + rejected_nodes: int + + +class PoolAdjustment(BaseModel): + amount: float + + +class PoolDailyUpdate(BaseModel): + daily_distribution: float = Field(..., gt=0) + + +class AdminNodeDetail(BaseModel): + node: NodeWithMetrics + owner_email: str + + +class PayoutExportRow(BaseModel): + node_id: str + node_name: str + owner_email: str + wallet_address: str + projected_reward: float diff --git a/ambassadors/node-operator-rewards-portal/backend/app/security.py b/ambassadors/node-operator-rewards-portal/backend/app/security.py new file mode 100644 index 0000000000..e012f3afa5 --- /dev/null +++ b/ambassadors/node-operator-rewards-portal/backend/app/security.py @@ -0,0 +1,54 @@ +"""Security utilities for password hashing and JWT token management.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any, Dict + +import jwt +from fastapi import HTTPException, status +from passlib.context import CryptContext + +from .config import get_settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def hash_password(password: str) -> str: + """Hash a password using bcrypt.""" + + return pwd_context.hash(password) + + +def verify_password(password: str, hashed: str) -> bool: + """Check whether the password matches the stored hash.""" + + return pwd_context.verify(password, hashed) + + +def create_access_token(subject: str, claims: Dict[str, Any] | None = None) -> str: + """Generate a signed JWT containing the provided subject and optional claims.""" + + settings = get_settings() + expire_delta = timedelta(seconds=settings.access_token_ttl_seconds) + to_encode: Dict[str, Any] = { + "sub": subject, + "exp": datetime.now(timezone.utc) + expire_delta, + "iat": datetime.now(timezone.utc), + } + if claims: + to_encode.update(claims) + return jwt.encode(to_encode, settings.secret_key, algorithm=settings.jwt_algorithm) + + +def decode_access_token(token: str) -> Dict[str, Any]: + """Decode and validate a JWT access token.""" + + settings = get_settings() + try: + payload = jwt.decode(token, settings.secret_key, algorithms=[settings.jwt_algorithm]) + except jwt.ExpiredSignatureError as exc: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired") from exc + except jwt.PyJWTError as exc: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc + return payload diff --git a/ambassadors/node-operator-rewards-portal/backend/app/services.py b/ambassadors/node-operator-rewards-portal/backend/app/services.py new file mode 100644 index 0000000000..9c57ff0dac --- /dev/null +++ b/ambassadors/node-operator-rewards-portal/backend/app/services.py @@ -0,0 +1,208 @@ +"""Domain services for monitoring uptime and distributing rewards.""" + +from __future__ import annotations + +import asyncio +from contextlib import suppress +from datetime import datetime, timezone +from typing import Dict, List, Optional, Tuple + +import httpx + +from .config import get_settings +from .models import HealthCheckResult, NodeRead, NodeStatus, RewardRecord, UptimeScore +from .storage import RedisRepository + + +class HealthMonitor: + """Asynchronous service that periodically probes registered nodes.""" + + def __init__(self, repo: RedisRepository) -> None: + self.repo = repo + self.settings = get_settings() + self._task: Optional[asyncio.Task[None]] = None + self._stopped = asyncio.Event() + + async def start(self) -> None: + if self._task and not self._task.done(): + return + self._stopped.clear() + self._task = asyncio.create_task(self._run(), name="health-monitor") + + async def stop(self) -> None: + self._stopped.set() + if self._task: + await self._task + + async def _run(self) -> None: + interval = self.settings.health_check_interval_seconds + while not self._stopped.is_set(): + await self._probe_all_nodes() + try: + await asyncio.wait_for(self._stopped.wait(), timeout=interval) + except asyncio.TimeoutError: + continue + + async def _probe_all_nodes(self) -> None: + nodes = await self.repo.get_all_nodes() + if not nodes: + return + await asyncio.gather(*(self._probe_node(node) for node in nodes if node.status != NodeStatus.REJECTED)) + + async def _probe_node(self, node: NodeRead) -> None: + rpc_online, rpc_latency_ms, height = await self._check_rpc(node) + p2p_online, p2p_latency_ms = await self._check_p2p(node) + result = HealthCheckResult( + node_id=node.id, + timestamp=datetime.now(timezone.utc), + online=rpc_online, + rpc_latency_ms=rpc_latency_ms, + sync_height=height, + p2p_online=p2p_online, + p2p_latency_ms=p2p_latency_ms, + ) + await self.repo.store_health_result(result) + uptime = await self.repo.update_uptime(node.id, rpc_online, rpc_latency_ms, p2p_online, p2p_latency_ms) + await self._update_flag(node, uptime) + + async def _check_rpc(self, node: NodeRead) -> Tuple[bool, Optional[float], Optional[int]]: + rpc_url = f"http://{node.rpc_host}:{node.rpc_port}/json_rpc" + loop = asyncio.get_running_loop() + start = loop.time() + try: + async with httpx.AsyncClient(timeout=self.settings.rpc_timeout_seconds) as client: + response = await client.post( + rpc_url, + json={"jsonrpc": "2.0", "method": "get_info", "id": "0"}, + ) + if response.status_code != 200: + return False, None, None + latency_ms = (loop.time() - start) * 1000 + payload = response.json() + height = payload.get("result", {}).get("height") + return True, latency_ms, height + except httpx.HTTPError: + return False, None, None + + async def _check_p2p(self, node: NodeRead) -> Tuple[bool, Optional[float]]: + loop = asyncio.get_running_loop() + start = loop.time() + try: + connect = asyncio.open_connection(node.p2p_host, node.p2p_port) + _reader, writer = await asyncio.wait_for(connect, timeout=self.settings.p2p_timeout_seconds) + except (OSError, asyncio.TimeoutError): + return False, None + else: + latency_ms = (loop.time() - start) * 1000 + writer.close() + with suppress(Exception): + await writer.wait_closed() + return True, latency_ms + + async def _update_flag(self, node: NodeRead, uptime: UptimeScore) -> None: + flag_threshold = 0.98 + flag = ( + uptime.uptime_ratio >= flag_threshold + and uptime.p2p_uptime_ratio >= flag_threshold + and (uptime.average_latency_ms or 0) <= 1500 + and (uptime.p2p_average_latency_ms or 0) <= 1500 + ) + await self.repo.update_node(node.id, {"flagged": "true" if flag else "false"}) + + +class RewardEngine: + """Service that calculates and records node rewards.""" + + def __init__(self, repo: RedisRepository) -> None: + self.repo = repo + self.settings = get_settings() + self._task: Optional[asyncio.Task[None]] = None + self._stop = asyncio.Event() + + async def start(self) -> None: + if self._task and not self._task.done(): + return + self._stop.clear() + self._task = asyncio.create_task(self._run(), name="reward-engine") + + async def stop(self) -> None: + self._stop.set() + if self._task: + await self._task + + async def _run(self) -> None: + interval = self.settings.reward_distribution_interval_seconds + while not self._stop.is_set(): + await self.distribute_rewards() + try: + await asyncio.wait_for(self._stop.wait(), timeout=interval) + except asyncio.TimeoutError: + continue + + async def distribute_rewards(self) -> List[RewardRecord]: + records = await self.calculate_reward_shares() + if not records: + return [] + total_payout = sum(record.amount for record in records) + await asyncio.gather( + *(self.repo.record_reward(record.dict()) for record in records), + ) + if total_payout: + await self.repo.adjust_pool_balance(-total_payout) + await self.repo.set_last_distribution(datetime.now(timezone.utc)) + return records + + def score_from_uptime(self, uptime: UptimeScore) -> float: + if uptime.total_checks == 0 or uptime.p2p_total_checks == 0: + return 0.0 + reliability = min(uptime.uptime_ratio, uptime.p2p_uptime_ratio) + if reliability <= 0: + return 0.0 + rpc_latency_factor = ( + 1.0 if uptime.average_latency_ms is None else max(0.1, 1.5 - (uptime.average_latency_ms / 1500)) + ) + p2p_latency_factor = ( + 1.0 if uptime.p2p_average_latency_ms is None else max(0.1, 1.5 - (uptime.p2p_average_latency_ms / 1500)) + ) + return max(0.0, reliability * rpc_latency_factor * p2p_latency_factor) + + async def calculate_reward_shares(self) -> List[RewardRecord]: + nodes = await self.repo.get_all_nodes() + if not nodes: + return [] + daily_pool = await self.repo.get_pool_daily_amount(self.settings.reward_pool_daily) + pool_balance = await self.repo.get_pool_balance() + available_pool = min(daily_pool, pool_balance) if pool_balance > 0 else 0.0 + scores: Dict[str, float] = {} + uptimes: Dict[str, UptimeScore] = {} + total_score = 0.0 + for node in nodes: + if node.status == NodeStatus.REJECTED: + continue + uptime = await self.repo.get_uptime(node.id) + if not uptime or uptime.total_checks == 0: + continue + score = self.score_from_uptime(uptime) + if score <= 0: + continue + uptimes[node.id] = uptime + scores[node.id] = score + total_score += score + if total_score == 0 or available_pool <= 0: + return [] + records: List[RewardRecord] = [] + for node in nodes: + score = scores.get(node.id) + if not score: + continue + uptime = uptimes[node.id] + share = (score / total_score) * available_pool + record = RewardRecord( + node_id=node.id, + owner_id=node.owner_id, + amount=round(share, 8), + total_score=score, + timestamp=datetime.now(timezone.utc), + ) + records.append(record) + return records diff --git a/ambassadors/node-operator-rewards-portal/backend/app/storage.py b/ambassadors/node-operator-rewards-portal/backend/app/storage.py new file mode 100644 index 0000000000..120082157f --- /dev/null +++ b/ambassadors/node-operator-rewards-portal/backend/app/storage.py @@ -0,0 +1,293 @@ +"""Redis-based persistence helpers.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Iterable, List, Optional + +import redis.asyncio as redis + +from .config import get_redis_url +from .models import ( + HealthCheckResult, + NodeRead, + NodeStatus, + UptimeScore, +) + + +@dataclass +class RedisKeys: + users: str = "portal:users" + nodes: str = "portal:nodes" + node_owner_index: str = "portal:node_owners" + node_health: str = "portal:node:health" + node_uptime: str = "portal:node:uptime" + rewards: str = "portal:rewards" + last_distribution: str = "portal:last_distribution" + pool_balance: str = "portal:pool:balance" + pool_daily: str = "portal:pool:daily" + + +class RedisRepository: + """Abstraction over Redis access patterns used by the backend.""" + + def __init__(self) -> None: + self.client = redis.from_url(get_redis_url(), decode_responses=True) + self.keys = RedisKeys() + + async def close(self) -> None: + await self.client.aclose() + + # User management ----------------------------------------------------- + async def create_user(self, email: str, password_hash: str, *, is_admin: bool = False) -> str: + user_id = await self.client.incr("portal:ids:users") + user_key = f"portal:user:{user_id}" + await self.client.hset( + user_key, + mapping={ + "id": str(user_id), + "email": email.lower(), + "password": password_hash, + "created_at": datetime.now(timezone.utc).isoformat(), + "role": "admin" if is_admin else "operator", + }, + ) + await self.client.hset(self.keys.users, email.lower(), user_key) + return str(user_id) + + async def get_user_by_email(self, email: str) -> Optional[dict]: + user_key = await self.client.hget(self.keys.users, email.lower()) + if not user_key: + return None + return await self.client.hgetall(user_key) + + async def get_user_by_id(self, user_id: str) -> Optional[dict]: + user_key = f"portal:user:{user_id}" + data = await self.client.hgetall(user_key) + return data or None + + async def set_user_role(self, user_id: str, role: str) -> None: + user_key = f"portal:user:{user_id}" + await self.client.hset(user_key, mapping={"role": role}) + + # Node management ----------------------------------------------------- + async def create_node(self, owner_id: str, node_data: dict) -> str: + node_id = await self.client.incr("portal:ids:nodes") + node_key = f"portal:node:{node_id}" + node_payload = { + **node_data, + "id": str(node_id), + "owner_id": owner_id, + "status": node_data.get("status", NodeStatus.APPROVED.value), + } + await self.client.hset(node_key, mapping=node_payload) + await self.client.sadd(f"portal:owner:{owner_id}:nodes", node_key) + await self.client.hset(self.keys.nodes, node_key, owner_id) + return str(node_id) + + async def update_node(self, node_id: str, updates: dict) -> None: + node_key = f"portal:node:{node_id}" + if updates: + await self.client.hset(node_key, mapping=updates) + + async def delete_node(self, node_id: str) -> None: + node_key = f"portal:node:{node_id}" + owner_id = await self.client.hget(self.keys.nodes, node_key) + pipeline = self.client.pipeline() + pipeline.delete(node_key) + pipeline.hdel(self.keys.nodes, node_key) + pipeline.delete(f"portal:health:{node_id}") + pipeline.delete(f"portal:uptime:{node_id}") + if owner_id: + pipeline.srem(f"portal:owner:{owner_id}:nodes", node_key) + await pipeline.execute() + + async def get_nodes_for_owner(self, owner_id: str) -> List[NodeRead]: + node_keys = await self.client.smembers(f"portal:owner:{owner_id}:nodes") + nodes: List[NodeRead] = [] + for key in node_keys: + data = await self.client.hgetall(key) + if data: + nodes.append(_deserialize_node(data)) + return nodes + + async def get_all_nodes(self) -> List[NodeRead]: + node_entries = await self.client.hgetall(self.keys.nodes) + nodes: List[NodeRead] = [] + for node_key in node_entries.keys(): + data = await self.client.hgetall(node_key) + if data: + nodes.append(_deserialize_node(data)) + return nodes + + async def get_node(self, node_id: str) -> Optional[NodeRead]: + node_key = f"portal:node:{node_id}" + data = await self.client.hgetall(node_key) + if not data: + return None + return _deserialize_node(data) + + # Health + uptime ----------------------------------------------------- + async def store_health_result(self, result: HealthCheckResult) -> None: + key = f"portal:health:{result.node_id}" + await self.client.lpush(key, result.json()) + await self.client.ltrim(key, 0, 499) + + async def get_latest_health(self, node_id: str) -> Optional[HealthCheckResult]: + key = f"portal:health:{node_id}" + raw = await self.client.lindex(key, 0) + if not raw: + return None + return HealthCheckResult.parse_raw(raw) + + async def update_uptime( + self, + node_id: str, + rpc_online: bool, + rpc_latency_ms: Optional[float], + p2p_online: bool, + p2p_latency_ms: Optional[float], + ) -> UptimeScore: + key = f"portal:uptime:{node_id}" + data = await self.client.hgetall(key) + total_checks = int(data.get("total_checks", 0)) + 1 + successful_checks = int(data.get("successful_checks", 0)) + (1 if rpc_online else 0) + p2p_successful = int(data.get("p2p_successful_checks", 0)) + (1 if p2p_online else 0) + cumulative_latency = float(data.get("cumulative_latency", 0.0)) + (rpc_latency_ms or 0.0) + p2p_cumulative_latency = float(data.get("p2p_cumulative_latency", 0.0)) + (p2p_latency_ms or 0.0) + uptime_ratio = successful_checks / total_checks + p2p_uptime_ratio = p2p_successful / total_checks + average_latency = cumulative_latency / successful_checks if successful_checks else None + p2p_average_latency = p2p_cumulative_latency / p2p_successful if p2p_successful else None + await self.client.hset( + key, + mapping={ + "total_checks": total_checks, + "successful_checks": successful_checks, + "cumulative_latency": cumulative_latency, + "uptime_ratio": uptime_ratio, + "average_latency": average_latency or 0.0, + "p2p_successful_checks": p2p_successful, + "p2p_cumulative_latency": p2p_cumulative_latency, + "p2p_uptime_ratio": p2p_uptime_ratio, + "p2p_average_latency": p2p_average_latency or 0.0, + }, + ) + return UptimeScore( + node_id=node_id, + uptime_ratio=uptime_ratio, + total_checks=total_checks, + successful_checks=successful_checks, + average_latency_ms=average_latency, + p2p_uptime_ratio=p2p_uptime_ratio, + p2p_total_checks=total_checks, + p2p_successful_checks=p2p_successful, + p2p_average_latency_ms=p2p_average_latency, + ) + + async def get_uptime(self, node_id: str) -> Optional[UptimeScore]: + key = f"portal:uptime:{node_id}" + data = await self.client.hgetall(key) + if not data: + return None + avg_latency = float(data.get("average_latency", 0.0)) if data.get("successful_checks") else None + p2p_avg_latency = ( + float(data.get("p2p_average_latency", 0.0)) if data.get("p2p_successful_checks") else None + ) + return UptimeScore( + node_id=node_id, + uptime_ratio=float(data.get("uptime_ratio", 0.0)), + total_checks=int(data.get("total_checks", 0)), + successful_checks=int(data.get("successful_checks", 0)), + average_latency_ms=avg_latency, + p2p_uptime_ratio=float(data.get("p2p_uptime_ratio", 0.0)), + p2p_total_checks=int(data.get("total_checks", 0)), + p2p_successful_checks=int(data.get("p2p_successful_checks", 0)), + p2p_average_latency_ms=p2p_avg_latency, + ) + + # Rewards ------------------------------------------------------------- + async def record_reward(self, record: dict) -> None: + await self.client.lpush(self.keys.rewards, json.dumps(record)) + + async def get_recent_rewards(self, limit: int = 50) -> List[dict]: + raw = await self.client.lrange(self.keys.rewards, 0, limit - 1) + return [json.loads(item) for item in raw] + + async def set_last_distribution(self, timestamp: datetime) -> None: + await self.client.set(self.keys.last_distribution, timestamp.isoformat()) + + async def get_last_distribution(self) -> Optional[datetime]: + raw = await self.client.get(self.keys.last_distribution) + if not raw: + return None + return datetime.fromisoformat(raw) + + # Pool management ---------------------------------------------------- + async def ensure_pool_defaults(self, daily_amount: float, initial_balance: float) -> None: + await self.client.setnx(self.keys.pool_daily, str(daily_amount)) + await self.client.setnx(self.keys.pool_balance, str(initial_balance)) + + async def get_pool_daily_amount(self, fallback: float) -> float: + raw = await self.client.get(self.keys.pool_daily) + return float(raw) if raw is not None else fallback + + async def set_pool_daily_amount(self, value: float) -> float: + await self.client.set(self.keys.pool_daily, str(value)) + return value + + async def get_pool_balance(self) -> float: + raw = await self.client.get(self.keys.pool_balance) + return float(raw) if raw is not None else 0.0 + + async def set_pool_balance(self, value: float) -> float: + capped = max(0.0, value) + await self.client.set(self.keys.pool_balance, str(capped)) + return capped + + async def adjust_pool_balance(self, delta: float) -> float: + async with self.client.pipeline(transaction=True) as pipe: + while True: + try: + await pipe.watch(self.keys.pool_balance) + raw = await pipe.get(self.keys.pool_balance) + current = float(raw) if raw is not None else 0.0 + new_value = max(0.0, current + delta) + pipe.multi() + pipe.set(self.keys.pool_balance, str(new_value)) + await pipe.execute() + return new_value + except redis.WatchError: + continue + + async def get_pool_state(self, fallback_daily: float) -> dict: + balance = await self.get_pool_balance() + daily = await self.get_pool_daily_amount(fallback_daily) + last_distribution = await self.get_last_distribution() + return { + "current_balance": balance, + "daily_distribution": daily, + "last_distribution": last_distribution, + } + + +def _deserialize_node(data: dict) -> NodeRead: + created_at = datetime.fromisoformat(data["created_at"]) + flagged = data.get("flagged", "false") == "true" + status = NodeStatus(data.get("status", NodeStatus.APPROVED.value)) + return NodeRead( + id=data["id"], + owner_id=data["owner_id"], + name=data["name"], + p2p_host=data["p2p_host"], + p2p_port=int(data["p2p_port"]), + rpc_host=data["rpc_host"], + rpc_port=int(data["rpc_port"]), + wallet_address=data["wallet_address"], + created_at=created_at, + flagged=flagged, + status=status, + ) diff --git a/ambassadors/node-operator-rewards-portal/backend/requirements.txt b/ambassadors/node-operator-rewards-portal/backend/requirements.txt new file mode 100644 index 0000000000..0fe0f1ec40 --- /dev/null +++ b/ambassadors/node-operator-rewards-portal/backend/requirements.txt @@ -0,0 +1,9 @@ +fastapi==0.110.0 +uvicorn[standard]==0.27.1 +redis==5.0.3 +bcrypt==4.1.2 +passlib[bcrypt]==1.7.4 +pyjwt==2.8.0 +pydantic==1.10.14 +python-dotenv==1.0.1 +httpx==0.27.0 diff --git a/ambassadors/node-operator-rewards-portal/backend/tests/test_rewards.py b/ambassadors/node-operator-rewards-portal/backend/tests/test_rewards.py new file mode 100644 index 0000000000..a4b49c55ed --- /dev/null +++ b/ambassadors/node-operator-rewards-portal/backend/tests/test_rewards.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone +from typing import Dict + +import pytest + +import sys +from pathlib import Path + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from app.services import RewardEngine +from app.storage import RedisRepository +from app.models import UptimeScore, NodeRead, NodeStatus + + +pytestmark = pytest.mark.anyio + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +async def test_reward_distribution(monkeypatch): + monkeypatch.setenv("PORTAL_SECRET_KEY", "x" * 32) + repo = RedisRepository() + + async def fake_get_all_nodes(): + return [ + NodeRead( + id="1", + owner_id="u1", + name="Node 1", + p2p_host="127.0.0.1", + p2p_port=18080, + rpc_host="127.0.0.1", + rpc_port=18081, + wallet_address="wallet1", + created_at=datetime.now(timezone.utc), + flagged=False, + status=NodeStatus.APPROVED, + ), + NodeRead( + id="2", + owner_id="u2", + name="Node 2", + p2p_host="127.0.0.1", + p2p_port=28080, + rpc_host="127.0.0.1", + rpc_port=28081, + wallet_address="wallet2", + created_at=datetime.now(timezone.utc), + flagged=True, + status=NodeStatus.APPROVED, + ), + ] + + uptimes: Dict[str, UptimeScore] = { + "1": UptimeScore( + node_id="1", + uptime_ratio=0.99, + total_checks=100, + successful_checks=99, + average_latency_ms=450.0, + p2p_uptime_ratio=0.97, + p2p_total_checks=100, + p2p_successful_checks=97, + p2p_average_latency_ms=500.0, + ), + "2": UptimeScore( + node_id="2", + uptime_ratio=0.75, + total_checks=100, + successful_checks=75, + average_latency_ms=900.0, + p2p_uptime_ratio=0.8, + p2p_total_checks=100, + p2p_successful_checks=80, + p2p_average_latency_ms=650.0, + ), + } + + async def fake_get_uptime(node_id: str): + return uptimes.get(node_id) + + recorded = [] + + async def fake_record_reward(record): + recorded.append(record) + + async def fake_set_last_distribution(timestamp): + pass + + monkeypatch.setattr(repo, "get_all_nodes", fake_get_all_nodes) + monkeypatch.setattr(repo, "get_uptime", fake_get_uptime) + async def fake_get_pool_daily_amount(default): + return default + + async def fake_get_pool_balance(): + return 1000.0 + + async def fake_adjust_pool_balance(delta): + return 1000.0 + delta + + monkeypatch.setattr(repo, "record_reward", fake_record_reward) + monkeypatch.setattr(repo, "set_last_distribution", fake_set_last_distribution) + monkeypatch.setattr(repo, "get_pool_daily_amount", fake_get_pool_daily_amount) + monkeypatch.setattr(repo, "get_pool_balance", fake_get_pool_balance) + monkeypatch.setattr(repo, "adjust_pool_balance", fake_adjust_pool_balance) + + engine = RewardEngine(repo) + results = await engine.distribute_rewards() + + assert len(results) == 2 + assert sum(r.amount for r in results) == pytest.approx(engine.settings.reward_pool_daily, rel=1e-6) + assert recorded, "Rewards should be recorded in Redis" + + await repo.close() diff --git a/ambassadors/node-operator-rewards-portal/frontend/README.md b/ambassadors/node-operator-rewards-portal/frontend/README.md new file mode 100644 index 0000000000..b78a48328e --- /dev/null +++ b/ambassadors/node-operator-rewards-portal/frontend/README.md @@ -0,0 +1,21 @@ +# Node Operator Rewards Portal Frontend + +This lightweight frontend provides the neon-glass UI for the Interchained Node Operator Rewards Portal. It is a static site built with vanilla JavaScript and CSS and communicates with the FastAPI backend. + +Accounts flagged as administrators (email present in the backend `PORTAL_ADMIN_EMAILS` list) see an additional control center after logging in. The admin view surfaces pool balances, allows balance/daily payout adjustments, exposes node moderation controls, and can export the current payout cycle as a CSV snapshot. + +Both the operator and admin dashboards now display live RPC **and** P2P health indicators so you can confirm that each node is answering bitcoind-style peer handshakes as well as JSON-RPC calls. + +## Usage + +1. Serve the files from any static web server (e.g. `python -m http.server` inside the `ambassadors/node-operator-rewards-portal/frontend` directory). +2. The app expects the backend to be available at `http://localhost:8000` by default. Override by setting `window.PORTAL_API_URL` before loading `app.js`. + +Example using Python's built-in server: + +```bash +cd ambassadors/node-operator-rewards-portal/frontend +python -m http.server 9000 +``` + +Then visit `http://localhost:9000` in your browser. diff --git a/ambassadors/node-operator-rewards-portal/frontend/assets/css/style.css b/ambassadors/node-operator-rewards-portal/frontend/assets/css/style.css new file mode 100644 index 0000000000..1c38d18102 --- /dev/null +++ b/ambassadors/node-operator-rewards-portal/frontend/assets/css/style.css @@ -0,0 +1,405 @@ +:root { + --bg: #05010c; + --panel: rgba(22, 14, 40, 0.75); + --accent: #7b5cff; + --accent-strong: #1fffd7; + --text: #f5f5ff; + --text-muted: #9aa0d7; + --danger: #ff5c7a; + font-family: "Space Grotesk", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: radial-gradient(circle at top, #120b2b 0%, #05010c 70%); + color: var(--text); + overflow-x: hidden; +} + +canvas#starfield { + position: fixed; + inset: 0; + width: 100vw; + height: 100vh; + z-index: -2; +} + +.portal-shell { + position: relative; + min-height: 100vh; + padding: 2rem clamp(1rem, 3vw, 3rem) 4rem; + display: flex; + flex-direction: column; + gap: 2rem; +} + +.glass { + background: var(--panel); + backdrop-filter: blur(24px); + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: 0 24px 48px rgba(8, 0, 40, 0.45); + border-radius: 24px; +} + +.portal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.5rem 2rem; +} + +.portal-header .logo { + display: flex; + align-items: center; + gap: 1rem; +} + +.portal-header .logo h1 { + font-size: clamp(1.75rem, 2.5vw, 2.5rem); + margin: 0; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.glow-dot { + width: 16px; + height: 16px; + border-radius: 50%; + background: radial-gradient(circle, var(--accent-strong), rgba(31, 255, 215, 0)); + box-shadow: 0 0 16px var(--accent-strong), 0 0 48px rgba(123, 92, 255, 0.6); + animation: pulse 2.8s infinite; +} + +@keyframes pulse { + 0%, + 100% { + transform: scale(1); + opacity: 0.85; + } + 50% { + transform: scale(1.35); + opacity: 1; + } +} + +nav { + display: flex; + gap: 1rem; +} + +.button { + border: none; + border-radius: 999px; + padding: 0.75rem 1.75rem; + font-size: 0.95rem; + letter-spacing: 0.08em; + text-transform: uppercase; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.button.primary { + background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%); + color: #040109; + box-shadow: 0 12px 24px rgba(31, 255, 215, 0.45); +} + +.button.ghost { + background: transparent; + border: 1px solid rgba(123, 92, 255, 0.5); + color: var(--text); +} + +.button:hover { + transform: translateY(-2px); + box-shadow: 0 18px 36px rgba(123, 92, 255, 0.35); +} + +.portal-content { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 2rem; +} + +.auth-panel { + padding: 2rem; + display: flex; + flex-direction: column; + gap: 1.5rem; + max-width: 420px; +} + +.auth-panel h2 { + margin: 0; + letter-spacing: 0.12em; +} + +.auth-panel form { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +label { + display: flex; + flex-direction: column; + gap: 0.5rem; + font-weight: 500; +} + +input { + padding: 0.75rem 1rem; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.04); + color: var(--text); + font-size: 1rem; +} + +input:focus { + outline: 2px solid rgba(31, 255, 215, 0.4); +} + +.hint { + color: var(--text-muted); + font-size: 0.9rem; + margin: 0; +} + +.dashboard { + display: flex; + flex-direction: column; + gap: 2rem; +} + +.admin-panel { + display: grid; + gap: 2rem; +} + +.admin-summary { + padding: 2rem; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.admin-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 1.5rem; +} + +.admin-stats h3 { + font-size: 0.85rem; + letter-spacing: 0.08em; + color: var(--text-muted); + text-transform: uppercase; +} + +.admin-stats p { + font-size: 1.85rem; + font-weight: 600; + margin: 0.4rem 0 0; +} + +.admin-actions { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: flex-end; +} + +.admin-form { + display: flex; + flex-direction: column; + gap: 0.5rem; + min-width: 220px; +} + +.admin-form input { + width: 100%; +} + +.admin-nodes { + padding: 2rem; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.admin-node-actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +.admin-node-actions .button { + padding: 0.5rem 1.25rem; + font-size: 0.8rem; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 1.5rem; +} + +.stat-card { + padding: 1.5rem; + text-align: center; +} + +.stat-card h3 { + margin-bottom: 0.5rem; + font-size: 0.95rem; + letter-spacing: 0.12em; + color: var(--text-muted); +} + +.stat-card p { + margin: 0; + font-size: 2.5rem; + font-weight: 700; +} + +.nodes-panel, +.rewards-panel { + padding: 2rem; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.panel-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.node-list, +.reward-list { + display: grid; + gap: 1rem; +} + +.node-card, +.reward-card { + padding: 1.5rem; + border-radius: 20px; + background: rgba(8, 4, 28, 0.8); + border: 1px solid rgba(123, 92, 255, 0.18); + display: grid; + gap: 0.75rem; + position: relative; + overflow: hidden; +} + +.node-card::after, +.reward-card::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(135deg, rgba(123, 92, 255, 0.12), rgba(31, 255, 215, 0.04)); + mix-blend-mode: screen; + opacity: 0; + transition: opacity 0.3s ease; +} + +.node-card:hover::after, +.reward-card:hover::after { + opacity: 1; +} + +.node-meta { + display: flex; + flex-wrap: wrap; + gap: 1.25rem; + font-size: 0.9rem; + color: var(--text-muted); +} + +.badge { + padding: 0.25rem 0.75rem; + border-radius: 999px; + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; + background: rgba(123, 92, 255, 0.25); + color: var(--accent-strong); +} + +.badge.danger { + background: rgba(255, 92, 122, 0.25); + color: var(--danger); +} + +.modal { + position: fixed; + inset: 0; + display: grid; + place-items: center; + background: rgba(5, 1, 12, 0.85); + z-index: 20; + animation: fadeIn 0.25s ease; +} + +.modal.hidden { + display: none; +} + +.modal-content { + position: relative; + width: min(560px, 90vw); + padding: 2rem; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.modal-close { + position: absolute; + top: 1rem; + right: 1rem; + border: none; + background: transparent; + color: var(--text-muted); + font-size: 1.5rem; + cursor: pointer; +} + +.form-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 1rem; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@media (max-width: 900px) { + .portal-header { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .portal-content { + grid-template-columns: 1fr; + } + + .auth-panel { + max-width: 100%; + } +} diff --git a/ambassadors/node-operator-rewards-portal/frontend/assets/js/app.js b/ambassadors/node-operator-rewards-portal/frontend/assets/js/app.js new file mode 100644 index 0000000000..c867d86805 --- /dev/null +++ b/ambassadors/node-operator-rewards-portal/frontend/assets/js/app.js @@ -0,0 +1,421 @@ +const API_BASE = (window.PORTAL_API_URL || "http://localhost:8000").replace(/\/$/, ""); + +const state = { + mode: "login", + token: null, + isAdmin: false, + email: null, +}; + +const authPanel = document.getElementById("authPanel"); +const dashboard = document.getElementById("dashboard"); +const authTitle = document.getElementById("authTitle"); +const authSubmit = document.getElementById("authSubmit"); +const authForm = document.getElementById("authForm"); +const loginToggle = document.getElementById("loginToggle"); +const registerToggle = document.getElementById("registerToggle"); +const nodeModal = document.getElementById("nodeModal"); +const nodeModalToggle = document.getElementById("nodeModalToggle"); +const closeModal = document.getElementById("closeModal"); +const nodeForm = document.getElementById("nodeForm"); +const adminPanel = document.getElementById("adminPanel"); +const poolBalanceEl = document.getElementById("poolBalance"); +const poolDailyEl = document.getElementById("poolDaily"); +const poolLastEl = document.getElementById("poolLast"); +const adminStatusEl = document.getElementById("adminStatus"); +const adminNodesEl = document.getElementById("adminNodes"); +const poolAdjustForm = document.getElementById("poolAdjustForm"); +const poolDailyForm = document.getElementById("poolDailyForm"); +const poolAdjustmentInput = document.getElementById("poolAdjustment"); +const dailyDistributionInput = document.getElementById("dailyDistribution"); +const exportPayoutsBtn = document.getElementById("exportPayouts"); + +const totalNodesEl = document.getElementById("totalNodes"); +const activeNodesEl = document.getElementById("activeNodes"); +const flaggedNodesEl = document.getElementById("flaggedNodes"); +const rewardPoolEl = document.getElementById("rewardPool"); +const nodesList = document.getElementById("nodesList"); +const rewardsList = document.getElementById("rewardsList"); + +function switchMode(mode) { + state.mode = mode; + if (mode === "login") { + authTitle.textContent = "Login"; + authSubmit.textContent = "Login"; + } else { + authTitle.textContent = "Register"; + authSubmit.textContent = "Create Account"; + } +} + +loginToggle.addEventListener("click", () => switchMode("login")); +registerToggle.addEventListener("click", () => switchMode("register")); + +authForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const email = document.getElementById("email").value.trim(); + const password = document.getElementById("password").value; + + try { + if (state.mode === "register") { + await apiRequest("/auth/register", "POST", { email, password }); + } + const { access_token } = await apiRequest("/auth/login", "POST", { email, password }); + state.token = access_token; + const claims = decodeToken(access_token); + state.isAdmin = Boolean(claims?.is_admin); + state.email = claims?.email || email; + authPanel.hidden = true; + dashboard.hidden = false; + if (state.isAdmin && adminPanel) { + adminPanel.hidden = false; + if (adminStatusEl) { + adminStatusEl.textContent = `Admin • ${state.email}`; + } + await fetchAdminOverview(); + await fetchAdminNodes(); + } else if (adminPanel) { + adminPanel.hidden = true; + } + fetchDashboard(); + fetchNodes(); + fetchRewards(); + } catch (error) { + alert(error.message || "Unable to authenticate"); + } +}); + +nodeModalToggle.addEventListener("click", () => toggleModal(true)); +closeModal.addEventListener("click", () => toggleModal(false)); +nodeModal.addEventListener("click", (event) => { + if (event.target === nodeModal) toggleModal(false); +}); + +nodeForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const payload = { + name: document.getElementById("nodeName").value.trim(), + wallet_address: document.getElementById("walletAddress").value.trim(), + p2p_host: document.getElementById("p2pHost").value.trim(), + p2p_port: Number(document.getElementById("p2pPort").value), + rpc_host: document.getElementById("rpcHost").value.trim(), + rpc_port: Number(document.getElementById("rpcPort").value), + }; + + try { + await apiRequest("/nodes", "POST", payload); + toggleModal(false); + nodeForm.reset(); + fetchNodes(); + } catch (error) { + alert(error.message || "Unable to register node"); + } +}); + +poolAdjustForm?.addEventListener("submit", async (event) => { + event.preventDefault(); + const amount = Number(poolAdjustmentInput.value); + if (!Number.isFinite(amount) || amount === 0) return; + try { + const stateData = await apiRequest("/admin/pool/adjust", "POST", { amount }); + updateAdminOverview(stateData); + poolAdjustmentInput.value = ""; + } catch (error) { + alert(error.message || "Unable to adjust pool balance"); + } +}); + +poolDailyForm?.addEventListener("submit", async (event) => { + event.preventDefault(); + const dailyDistribution = Number(dailyDistributionInput.value); + if (!Number.isFinite(dailyDistribution) || dailyDistribution <= 0) return; + try { + const stateData = await apiRequest("/admin/pool/daily", "PUT", { + daily_distribution: dailyDistribution, + }); + updateAdminOverview(stateData); + dailyDistributionInput.value = ""; + } catch (error) { + alert(error.message || "Unable to update daily distribution"); + } +}); + +adminNodesEl?.addEventListener("click", async (event) => { + const button = event.target.closest("button[data-node-id]"); + if (!button) return; + const nodeId = button.dataset.nodeId; + const action = button.dataset.action; + try { + await apiRequest(`/admin/nodes/${nodeId}/${action}`, "POST"); + await fetchAdminNodes(); + } catch (error) { + alert(error.message || "Unable to update node status"); + } +}); + +exportPayoutsBtn?.addEventListener("click", async () => { + try { + const rows = await apiRequest("/admin/exports/payouts", "GET"); + if (!rows.length) { + alert("No payouts available for export."); + return; + } + downloadCsv(rows); + } catch (error) { + alert(error.message || "Unable to export payouts"); + } +}); + +async function fetchDashboard() { + try { + const data = await apiRequest("/dashboard", "GET"); + totalNodesEl.textContent = data.total_nodes; + activeNodesEl.textContent = data.active_nodes; + flaggedNodesEl.textContent = data.flagged_nodes; + rewardPoolEl.textContent = data.reward_pool_daily.toLocaleString(undefined, { + maximumFractionDigits: 2, + }); + } catch (error) { + console.error(error); + } +} + +async function fetchNodes() { + try { + const nodes = await apiRequest("/nodes", "GET"); + nodesList.innerHTML = nodes + .map( + (node) => ` +
+
+

${node.name}

+ ${node.flagged ? 'Flagged' : ""} + ${node.status === "rejected" ? 'Rejected' : ""} +
+
+ P2P: ${node.p2p_host}:${node.p2p_port} + RPC: ${node.rpc_host}:${node.rpc_port} + Wallet: ${node.wallet_address} +
+
+ RPC Status: ${(node.last_health && node.last_health.online) ? "Online" : "Offline"} + P2P Status: ${(node.last_health && node.last_health.p2p_online) ? "Online" : "Offline"} + Checks: ${node.uptime.successful_checks}/${node.uptime.total_checks} +
+ +
` + ) + .join(""); + } catch (error) { + console.error(error); + } +} + +async function fetchRewards() { + try { + const rewards = await apiRequest("/rewards", "GET"); + rewardsList.innerHTML = rewards + .map( + (reward) => ` +
+
+

${reward.node.name}

+ ${reward.node.flagged ? 'Bonus Eligible' : ""} +
+
+ Projected Reward: ${reward.projected_reward.toFixed(6)} + RPC Uptime: ${(reward.uptime.uptime_ratio * 100).toFixed(2)}% + P2P Uptime: ${(reward.uptime.p2p_uptime_ratio * 100).toFixed(2)}% +
+
` + ) + .join(""); + } catch (error) { + console.error(error); + } +} + +function toggleModal(show) { + nodeModal.classList.toggle("hidden", !show); +} + +async function apiRequest(path, method = "GET", body) { + if (!state.token && !path.startsWith("/auth")) { + throw new Error("Authentication required"); + } + const response = await fetch(`${API_BASE}${path}`, { + method, + headers: { + "Content-Type": "application/json", + ...(state.token ? { Authorization: `Bearer ${state.token}` } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!response.ok) { + const message = await response.text(); + throw new Error(message || "Request failed"); + } + return response.status === 204 ? {} : response.json(); +} + +function decodeToken(token) { + try { + const payload = token.split(".")[1]; + const normalized = payload.replace(/-/g, "+").replace(/_/g, "/"); + const decoded = atob(normalized); + return JSON.parse(decoded); + } catch (error) { + console.error("Unable to decode token", error); + return {}; + } +} + +async function fetchAdminOverview() { + try { + const data = await apiRequest("/admin/pool", "GET"); + updateAdminOverview(data); + } catch (error) { + console.error(error); + } +} + +async function fetchAdminNodes() { + try { + const entries = await apiRequest("/admin/nodes", "GET"); + adminNodesEl.innerHTML = entries + .map((entry) => { + const node = entry.node; + const uptime = + node.uptime || { + uptime_ratio: 0, + successful_checks: 0, + total_checks: 0, + average_latency_ms: null, + p2p_uptime_ratio: 0, + p2p_average_latency_ms: null, + }; + const statusBadge = + node.status === "rejected" + ? 'Rejected' + : 'Approved'; + const actionButton = + node.status === "rejected" + ? `` + : ``; + return ` +
+
+

${node.name}

+ ${statusBadge} + ${node.flagged ? 'Flagged' : ""} +
+
+ Owner: ${entry.owner_email} + Wallet: ${node.wallet_address} + Registered: ${formatDate(node.created_at)} +
+
+ P2P: ${node.p2p_host}:${node.p2p_port} • ${(node.last_health && node.last_health.p2p_online) ? "Online" : "Offline"} + RPC: ${node.rpc_host}:${node.rpc_port} • ${(node.last_health && node.last_health.online) ? "Online" : "Offline"} + RPC Latency: ${uptime.average_latency_ms ? uptime.average_latency_ms.toFixed(1) : "–"} ms + P2P Latency: ${uptime.p2p_average_latency_ms ? uptime.p2p_average_latency_ms.toFixed(1) : "–"} ms +
+ +
`; + }) + .join(""); + } catch (error) { + console.error(error); + } +} + +function updateAdminOverview(data) { + poolBalanceEl.textContent = formatNumber(data.current_balance); + poolDailyEl.textContent = formatNumber(data.daily_distribution); + poolLastEl.textContent = data.last_distribution ? formatDate(data.last_distribution) : "–"; +} + +function downloadCsv(rows) { + const headers = ["node_id", "node_name", "owner_email", "wallet_address", "projected_reward"]; + const csv = [headers.join(",")].concat( + rows.map((row) => + headers + .map((key) => { + const value = row[key] ?? ""; + if (typeof value === "string" && value.includes(",")) { + return `"${value}"`; + } + return value; + }) + .join(",") + ) + ); + const blob = new Blob([csv.join("\n")], { type: "text/csv" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `payout-cycle-${new Date().toISOString()}.csv`; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); +} + +function formatNumber(value) { + return Number(value || 0).toLocaleString(undefined, { + maximumFractionDigits: 4, + }); +} + +function formatDate(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString(); +} + +function animateStarfield() { + const canvas = document.getElementById("starfield"); + const ctx = canvas.getContext("2d"); + let width; + let height; + let stars; + + function resize() { + width = canvas.width = window.innerWidth; + height = canvas.height = window.innerHeight; + stars = Array.from({ length: Math.floor(width / 4) }, () => ({ + x: Math.random() * width, + y: Math.random() * height, + z: Math.random() * 0.6 + 0.4, + speed: Math.random() * 0.35 + 0.05, + })); + } + + function step() { + ctx.clearRect(0, 0, width, height); + for (const star of stars) { + star.y += star.speed; + if (star.y > height) star.y = 0; + const size = star.z * 1.5; + ctx.fillStyle = `rgba(123, 92, 255, ${star.z})`; + ctx.fillRect(star.x, star.y, size, size); + } + requestAnimationFrame(step); + } + + resize(); + window.addEventListener("resize", resize); + requestAnimationFrame(step); +} + +animateStarfield(); diff --git a/ambassadors/node-operator-rewards-portal/frontend/index.html b/ambassadors/node-operator-rewards-portal/frontend/index.html new file mode 100644 index 0000000000..8fcede29d3 --- /dev/null +++ b/ambassadors/node-operator-rewards-portal/frontend/index.html @@ -0,0 +1,148 @@ + + + + + + Interchained Node Operator Rewards Portal + + + + + + + +
+
+ + +
+ +
+
+

Authenticate

+
+ + + +
+

Use the toggle buttons above to switch between login and registration.

+
+ + + + +
+
+ + + + + +