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) => `
+
+
+
+ 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) => `
+
+
+
+ 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 `
+
+
+
+ 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.
+
+
+
+
+
+ Total Nodes
+ 0
+
+
+ Active Nodes
+ 0
+
+
+ Flagged for Bonus
+ 0
+
+
+ Daily Reward Pool
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Current Pool Balance
+
0
+
+
+
Daily Distribution
+
0
+
+
+
Last Distribution
+
–
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+