diff --git a/ambassadors/interchained-node-operator-portal/README.md b/ambassadors/interchained-node-operator-portal/README.md new file mode 100644 index 000000000..cf9a4714b --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/README.md @@ -0,0 +1,70 @@ +# Interchained Node Operator Rewards Portal + +This directory contains a full-stack prototype of the Interchained Node Operator +Rewards Portal described in the ambassadors brief. The project is split into a +FastAPI backend powered entirely by Redis (database 6) and a Next.js frontend +with a neon glass aesthetic. + +## Backend + +* Framework: FastAPI +* Data store: Redis DB 6 +* Location: `backend/` + +Key features: + +* Email + password authentication with bearer token sessions. +* Node registration and CRUD endpoints. +* Background monitor that pings registered nodes every 60 seconds to track + uptime, responsiveness, and block height. +* Daily reward distribution job that computes payouts based on uptime scores and + records a per-user history in Redis. + +Run locally with: + +```bash +cd backend +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +uvicorn main:app --reload +``` + +Set the `REDIS_URL` environment variable if Redis is not available at the +default `redis://localhost:6379/6`. + +## Frontend + +The frontend is scaffolded with Next.js and TailwindCSS. It provides a neon +inspired operator dashboard including login, node management, uptime metrics, +and reward visualisations. + +``` +frontend/ +├─ pages/ +│ ├─ index.js +│ ├─ login.js +│ ├─ dashboard.js +│ ├─ nodes.js +│ └─ rewards.js +├─ components/ +│ ├─ NeonCard.js +│ ├─ GlassContainer.js +│ ├─ NodeTable.js +│ ├─ RewardGraph.js +│ └─ StatusBadge.js +└─ styles/ + └─ globals.css +``` + +Install dependencies and run the dev server: + +```bash +cd frontend +npm install +npm run dev +``` + +The frontend expects the FastAPI backend to be available at +`http://localhost:8000` and uses the same Redis instance configured for the +backend to read metrics and reward data. diff --git a/ambassadors/interchained-node-operator-portal/backend/__init__.py b/ambassadors/interchained-node-operator-portal/backend/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ambassadors/interchained-node-operator-portal/backend/auth.py b/ambassadors/interchained-node-operator-portal/backend/auth.py new file mode 100644 index 000000000..e8f300e34 --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/backend/auth.py @@ -0,0 +1,78 @@ +"""Authentication utilities for the Node Operator portal.""" +from __future__ import annotations + +import secrets +from datetime import datetime, timedelta +from typing import Optional + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from passlib.context import CryptContext + +from .models import UserCreate, UserPublic +from .utils.redis_client import get_redis + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +TOKEN_TTL = int(timedelta(days=7).total_seconds()) +security = HTTPBearer(auto_error=False) + + +async def get_user(email: str) -> Optional[dict[str, str]]: + redis = await get_redis() + user_key = f"users:{email}" + user = await redis.hgetall(user_key) + return user or None + + +async def create_user(payload: UserCreate) -> UserPublic: + redis = await get_redis() + user_key = f"users:{payload.email}" + exists = await redis.exists(user_key) + if exists: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User already exists") + + hashed = pwd_context.hash(payload.password) + created_at = datetime.utcnow().isoformat() + await redis.hset( + user_key, + mapping={ + "email": payload.email, + "password": hashed, + "created_at": created_at, + }, + ) + return UserPublic(email=payload.email, created_at=datetime.fromisoformat(created_at)) + + +async def authenticate_user(email: str, password: str) -> Optional[UserPublic]: + user = await get_user(email) + if not user: + return None + if not pwd_context.verify(password, user.get("password", "")): + return None + return UserPublic(email=email, created_at=datetime.fromisoformat(user["created_at"])) + + +async def create_session_token(email: str) -> str: + redis = await get_redis() + token = secrets.token_urlsafe(32) + await redis.setex(f"sessions:{token}", TOKEN_TTL, email) + return token + + +async def resolve_token(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)) -> str: + if credentials is None or credentials.scheme.lower() != "bearer": + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing credentials") + redis = await get_redis() + email = await redis.get(f"sessions:{credentials.credentials}") + if not email: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + return email + + +async def get_current_user(email: str = Depends(resolve_token)) -> UserPublic: + user = await get_user(email) + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive user") + return UserPublic(email=email, created_at=datetime.fromisoformat(user["created_at"])) diff --git a/ambassadors/interchained-node-operator-portal/backend/main.py b/ambassadors/interchained-node-operator-portal/backend/main.py new file mode 100644 index 000000000..7814d6451 --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/backend/main.py @@ -0,0 +1,46 @@ +"""FastAPI entry point for the Node Operator Rewards Portal backend.""" +from __future__ import annotations + +import asyncio +from contextlib import asynccontextmanager +from typing import AsyncIterator + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from .node_monitor import NodeMonitor +from .rewards import RewardDistributor +from .routes import nodes, rewards, users +from .utils.redis_client import close_redis + + +@asynccontextmanager +def lifespan(_: FastAPI) -> AsyncIterator[None]: + monitor = NodeMonitor() + rewards_job = RewardDistributor() + await asyncio.gather(monitor.start(), rewards_job.start()) + try: + yield + finally: + await asyncio.gather(monitor.stop(), rewards_job.stop()) + await close_redis() + + +app = FastAPI(title="Interchained Node Operator Rewards Portal", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(users.router) +app.include_router(nodes.router) +app.include_router(rewards.router) + + +@app.get("/") +async def root() -> dict[str, str]: + return {"status": "ok"} diff --git a/ambassadors/interchained-node-operator-portal/backend/models.py b/ambassadors/interchained-node-operator-portal/backend/models.py new file mode 100644 index 000000000..04c4b44e2 --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/backend/models.py @@ -0,0 +1,64 @@ +"""Pydantic models used across the FastAPI application.""" +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, EmailStr, Field, HttpUrl + + +class UserCreate(BaseModel): + email: EmailStr + password: str = Field(min_length=8) + + +class UserLogin(BaseModel): + email: EmailStr + password: str + + +class UserPublic(BaseModel): + email: EmailStr + created_at: datetime + + +class NodeRegistration(BaseModel): + email: EmailStr + p2p_address: str = Field(description="IP or hostname with port, e.g. node.example:18080") + rpc_url: HttpUrl + wallet_address: str + + +class NodeUpdate(BaseModel): + p2p_address: Optional[str] = None + rpc_url: Optional[HttpUrl] = None + wallet_address: Optional[str] = None + + +class NodeStatus(BaseModel): + email: EmailStr + p2p_address: str + rpc_url: HttpUrl + wallet_address: str + last_seen: Optional[datetime] = None + uptime_score: float = 0.0 + total_checks: int = 0 + successful_checks: int = 0 + latency_ms: Optional[float] = None + block_height: Optional[int] = None + + +class RewardSummary(BaseModel): + date: datetime + rewards: dict[str, float] + pool_balance: float + + +class RewardHistoryItem(BaseModel): + date: datetime + amount: float + + +class RewardHistory(BaseModel): + email: EmailStr + history: list[RewardHistoryItem] diff --git a/ambassadors/interchained-node-operator-portal/backend/node_monitor.py b/ambassadors/interchained-node-operator-portal/backend/node_monitor.py new file mode 100644 index 000000000..f362c5ae3 --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/backend/node_monitor.py @@ -0,0 +1,67 @@ +"""Background task that periodically checks node health metrics.""" +from __future__ import annotations + +import asyncio +from datetime import datetime +from typing import Any + +from .utils.bitcoin_rpc import check_node_health +from .utils.redis_client import get_redis, iter_hash_keys + + +class NodeMonitor: + """Continuously check registered nodes and persist uptime metrics.""" + + def __init__(self, interval_seconds: int = 60) -> None: + self._interval = interval_seconds + self._task: asyncio.Task[Any] | None = None + self._stop_event = asyncio.Event() + + async def start(self) -> None: + if self._task is None: + self._stop_event.clear() + self._task = asyncio.create_task(self._run()) + + async def stop(self) -> None: + if self._task: + self._stop_event.set() + await self._task + self._task = None + + async def _run(self) -> None: + while not self._stop_event.is_set(): + await self._check_all_nodes() + try: + await asyncio.wait_for(self._stop_event.wait(), timeout=self._interval) + except asyncio.TimeoutError: + continue + + async def _check_all_nodes(self) -> None: + redis = await get_redis() + async for key in iter_hash_keys("node:*"): + node = await redis.hgetall(key) + if not node: + continue + email = node.get("email") + if not email: + continue + health = await check_node_health(node.get("p2p_address", ""), node.get("rpc_url", "")) + stats_key = f"uptime:{email}" + total_checks = await redis.hincrby(stats_key, "total_checks", 1) + if health.is_online: + successful_checks = await redis.hincrby(stats_key, "successful_checks", 1) + else: + successful_checks = int(await redis.hget(stats_key, "successful_checks") or 0) + uptime_score = successful_checks / total_checks if total_checks else 0.0 + await redis.hset( + stats_key, + mapping={ + "total_checks": total_checks, + "successful_checks": successful_checks, + "uptime_score": uptime_score, + "last_seen": datetime.utcnow().isoformat(), + "latency_ms": health.latency_ms, + "block_height": health.block_height or 0, + "rpc_responding": int(health.rpc_responding), + }, + ) diff --git a/ambassadors/interchained-node-operator-portal/backend/requirements.txt b/ambassadors/interchained-node-operator-portal/backend/requirements.txt new file mode 100644 index 000000000..a76219c32 --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/backend/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.110.0 +uvicorn[standard]==0.27.1 +redis==5.0.4 +httpx==0.27.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.9 diff --git a/ambassadors/interchained-node-operator-portal/backend/rewards.py b/ambassadors/interchained-node-operator-portal/backend/rewards.py new file mode 100644 index 000000000..df206e9f3 --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/backend/rewards.py @@ -0,0 +1,90 @@ +"""Reward distribution utilities.""" +from __future__ import annotations + +import asyncio +from datetime import datetime, timedelta +from typing import Any + +from .utils.redis_client import get_redis, iter_hash_keys + + +REWARD_PREFIX = "rewards:" +REWARD_HISTORY_PREFIX = "rewards:history:" + + +async def distribute_daily_rewards(date: datetime | None = None) -> dict[str, float]: + redis = await get_redis() + snapshot_date = (date or datetime.utcnow()).date() + date_key = f"{REWARD_PREFIX}{snapshot_date.isoformat()}" + + total_pool_raw = await redis.get("pool:balance") + total_pool = float(total_pool_raw or 0) + if total_pool <= 0: + return {} + + active_nodes: list[tuple[str, float]] = [] + async for key in iter_hash_keys("uptime:*"): + stats = await redis.hgetall(key) + email = key.split(":", 1)[1] + score = float(stats.get("uptime_score", 0)) + if score >= 0.9: + active_nodes.append((email, score)) + + if not active_nodes: + return {} + + total_weight = sum(score for _, score in active_nodes) + if total_weight == 0: + return {} + + rewards: dict[str, float] = {} + for email, score in active_nodes: + share = (score / total_weight) * total_pool + rewards[email] = round(share, 8) + + if rewards: + await redis.hset(date_key, mapping=rewards) + await redis.set("pool:balance", 0) + for email, amount in rewards.items(): + await redis.lpush( + f"{REWARD_HISTORY_PREFIX}{email}", + f"{snapshot_date.isoformat()}:{amount}", + ) + return rewards + + +class RewardDistributor: + """Background job that triggers a distribution once every 24 hours.""" + + def __init__(self, run_at_hour: int = 0, run_at_minute: int = 5) -> None: + self._run_at_hour = run_at_hour + self._run_at_minute = run_at_minute + self._task: asyncio.Task[Any] | None = None + self._stop_event = asyncio.Event() + + async def start(self) -> None: + if self._task is None: + self._stop_event.clear() + self._task = asyncio.create_task(self._loop()) + + async def stop(self) -> None: + if self._task: + self._stop_event.set() + await self._task + self._task = None + + async def _loop(self) -> None: + while not self._stop_event.is_set(): + seconds_until_run = self._seconds_until_next_run() + try: + await asyncio.wait_for(self._stop_event.wait(), timeout=seconds_until_run) + continue + except asyncio.TimeoutError: + await distribute_daily_rewards() + + def _seconds_until_next_run(self) -> float: + now = datetime.utcnow() + next_run = now.replace(hour=self._run_at_hour, minute=self._run_at_minute, second=0, microsecond=0) + if next_run <= now: + next_run = next_run + timedelta(days=1) + return (next_run - now).total_seconds() diff --git a/ambassadors/interchained-node-operator-portal/backend/routes/__init__.py b/ambassadors/interchained-node-operator-portal/backend/routes/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ambassadors/interchained-node-operator-portal/backend/routes/nodes.py b/ambassadors/interchained-node-operator-portal/backend/routes/nodes.py new file mode 100644 index 000000000..a3d7177e2 --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/backend/routes/nodes.py @@ -0,0 +1,76 @@ +"""Node registration and management endpoints.""" +from __future__ import annotations + +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException, status + +from .. import auth +from ..models import NodeRegistration, NodeStatus, NodeUpdate, UserPublic +from ..utils.redis_client import get_redis + +router = APIRouter(prefix="/nodes", tags=["nodes"]) + + +@router.post("/register", response_model=NodeStatus, status_code=status.HTTP_201_CREATED) +async def register_node(payload: NodeRegistration, current_user: UserPublic = Depends(auth.get_current_user)) -> NodeStatus: + if payload.email != current_user.email: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Email mismatch") + + redis = await get_redis() + node_key = f"node:{payload.email}" + await redis.hset( + node_key, + mapping={ + "email": payload.email, + "p2p_address": payload.p2p_address, + "rpc_url": str(payload.rpc_url), + "wallet_address": payload.wallet_address, + "created_at": datetime.utcnow().isoformat(), + }, + ) + return await _build_node_status(payload.email) + + +@router.patch("/me", response_model=NodeStatus) +async def update_node(payload: NodeUpdate, current_user: UserPublic = Depends(auth.get_current_user)) -> NodeStatus: + redis = await get_redis() + node_key = f"node:{current_user.email}" + exists = await redis.exists(node_key) + if not exists: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node not registered") + + updates = {k: v for k, v in payload.model_dump(exclude_none=True).items()} + if updates: + await redis.hset(node_key, mapping=updates) + return await _build_node_status(current_user.email) + + +@router.get("/me", response_model=NodeStatus) +async def get_node(current_user: UserPublic = Depends(auth.get_current_user)) -> NodeStatus: + return await _build_node_status(current_user.email) + + +async def _build_node_status(email: str) -> NodeStatus: + redis = await get_redis() + node = await redis.hgetall(f"node:{email}") + if not node: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node not registered") + + stats = await redis.hgetall(f"uptime:{email}") + latency_raw = float(stats.get("latency_ms", 0)) if stats.get("latency_ms") else None + if latency_raw is not None and latency_raw < 0: + latency_raw = None + + return NodeStatus( + email=email, + p2p_address=node.get("p2p_address", ""), + rpc_url=node.get("rpc_url", ""), + wallet_address=node.get("wallet_address", ""), + last_seen=datetime.fromisoformat(stats["last_seen"]) if stats.get("last_seen") else None, + uptime_score=float(stats.get("uptime_score", 0.0)), + total_checks=int(stats.get("total_checks", 0)), + successful_checks=int(stats.get("successful_checks", 0)), + latency_ms=latency_raw, + block_height=int(stats.get("block_height", 0)) if stats.get("block_height") else None, + ) diff --git a/ambassadors/interchained-node-operator-portal/backend/routes/rewards.py b/ambassadors/interchained-node-operator-portal/backend/routes/rewards.py new file mode 100644 index 000000000..363214330 --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/backend/routes/rewards.py @@ -0,0 +1,48 @@ +"""Reward data endpoints.""" +from __future__ import annotations + +from datetime import date, datetime + +from fastapi import APIRouter, Depends + +from .. import auth +from ..models import RewardHistory, RewardHistoryItem, RewardSummary, UserPublic +from ..rewards import distribute_daily_rewards +from ..utils.redis_client import get_redis + +router = APIRouter(prefix="/rewards", tags=["rewards"]) + + +@router.post("/run", response_model=RewardSummary) +async def trigger_rewards(_: UserPublic = Depends(auth.get_current_user)) -> RewardSummary: + rewards = await distribute_daily_rewards() + snapshot = datetime.utcnow() + redis = await get_redis() + pool_balance = float(await redis.get("pool:balance") or 0) + return RewardSummary(date=snapshot, rewards=rewards, pool_balance=pool_balance) + + +@router.get("/today", response_model=RewardSummary) +async def rewards_today(_: UserPublic = Depends(auth.get_current_user)) -> RewardSummary: + today = date.today().isoformat() + redis = await get_redis() + rewards = await redis.hgetall(f"rewards:{today}") + rewards_float = {email: float(amount) for email, amount in rewards.items()} + pool_balance = float(await redis.get("pool:balance") or 0) + return RewardSummary(date=datetime.utcnow(), rewards=rewards_float, pool_balance=pool_balance) + + +@router.get("/history", response_model=RewardHistory) +async def reward_history(current_user: UserPublic = Depends(auth.get_current_user)) -> RewardHistory: + redis = await get_redis() + entries = await redis.lrange(f"rewards:history:{current_user.email}", 0, 30) + history: list[RewardHistoryItem] = [] + for entry in entries: + try: + date_str, amount_str = entry.split(":", 1) + history.append( + RewardHistoryItem(date=datetime.fromisoformat(date_str), amount=float(amount_str)) + ) + except ValueError: + continue + return RewardHistory(email=current_user.email, history=list(reversed(history))) diff --git a/ambassadors/interchained-node-operator-portal/backend/routes/users.py b/ambassadors/interchained-node-operator-portal/backend/routes/users.py new file mode 100644 index 000000000..96f6d80cd --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/backend/routes/users.py @@ -0,0 +1,28 @@ +"""User authentication routes.""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, status + +from .. import auth +from ..models import UserCreate, UserLogin, UserPublic + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.post("/register", response_model=UserPublic, status_code=status.HTTP_201_CREATED) +async def register_user(payload: UserCreate) -> UserPublic: + return await auth.create_user(payload) + + +@router.post("/login") +async def login_user(payload: UserLogin) -> dict[str, str]: + user = await auth.authenticate_user(payload.email, payload.password) + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") + token = await auth.create_session_token(user.email) + return {"access_token": token, "token_type": "bearer"} + + +@router.get("/me", response_model=UserPublic) +async def get_me(current_user: UserPublic = Depends(auth.get_current_user)) -> UserPublic: + return current_user diff --git a/ambassadors/interchained-node-operator-portal/backend/utils/__init__.py b/ambassadors/interchained-node-operator-portal/backend/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ambassadors/interchained-node-operator-portal/backend/utils/bitcoin_rpc.py b/ambassadors/interchained-node-operator-portal/backend/utils/bitcoin_rpc.py new file mode 100644 index 000000000..8b6296838 --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/backend/utils/bitcoin_rpc.py @@ -0,0 +1,82 @@ +"""Helpers for performing lightweight node health checks.""" +from __future__ import annotations + +import asyncio +import json +import time +from dataclasses import dataclass +from typing import Optional +from urllib.parse import urlparse + +import httpx + + +@dataclass +class NodeHealth: + is_online: bool + latency_ms: float + block_height: Optional[int] + rpc_responding: bool + + +async def _check_tcp_connectivity(host: str, port: int, timeout: float = 2.0) -> float: + start = time.perf_counter() + try: + reader, writer = await asyncio.wait_for( + asyncio.open_connection(host, port), timeout=timeout + ) + except (OSError, asyncio.TimeoutError): + return -1.0 + else: + writer.close() + await writer.wait_closed() + return (time.perf_counter() - start) * 1000 + + +async def _fetch_rpc_height(rpc_url: str, timeout: float = 2.0) -> tuple[bool, Optional[int]]: + try: + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.post( + rpc_url, + json={"jsonrpc": "1.0", "id": "health", "method": "getblockcount", "params": []}, + ) + response.raise_for_status() + payload = response.json() + except (httpx.HTTPError, json.JSONDecodeError): + return False, None + + height = None + if isinstance(payload, dict): + height = payload.get("result") + if isinstance(height, str) and height.isdigit(): + height = int(height) + return True, height if isinstance(height, int) else None + + +async def check_node_health(p2p_address: str, rpc_url: str) -> NodeHealth: + """Check whether a node responds over P2P and RPC interfaces.""" + + latency_ms = -1.0 + rpc_ok = False + block_height: Optional[int] = None + + if ":" in p2p_address: + host, port_str = p2p_address.rsplit(":", 1) + try: + port = int(port_str) + except ValueError: + port = 0 + if host and port: + latency_ms = await _check_tcp_connectivity(host, port) + + parsed = urlparse(rpc_url) + if parsed.scheme and parsed.netloc: + rpc_ok, block_height = await _fetch_rpc_height(rpc_url) + + is_online = latency_ms >= 0 and rpc_ok + return NodeHealth( + is_online=is_online, + latency_ms=latency_ms if latency_ms >= 0 else -1, + block_height=block_height, + rpc_responding=rpc_ok, + ) diff --git a/ambassadors/interchained-node-operator-portal/backend/utils/redis_client.py b/ambassadors/interchained-node-operator-portal/backend/utils/redis_client.py new file mode 100644 index 000000000..958658b2c --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/backend/utils/redis_client.py @@ -0,0 +1,52 @@ +"""Utility helpers for working with Redis connections.""" +from __future__ import annotations + +import os +from functools import lru_cache +from typing import AsyncIterator + +from redis.asyncio import Redis + + +@lru_cache +def _build_redis_client() -> Redis: + """Instantiate a Redis client targeting DB 6 by default. + + The connection URL can be overridden with the ``REDIS_URL`` environment + variable so deployments can supply their own Redis host/port credentials. + """ + + url = os.getenv("REDIS_URL", "redis://localhost:6379/6") + return Redis.from_url(url, decode_responses=True) + + +async def get_redis() -> Redis: + """Return a cached Redis client instance. + + The redis-py asyncio client manages its own connection pool so we can share + the instance safely between requests. The application is responsible for + calling :func:`close_redis` on shutdown to close the pool cleanly. + """ + + return _build_redis_client() + + +async def close_redis() -> None: + """Close the cached Redis connection pool, if one has been instantiated.""" + + client = _build_redis_client() + await client.close() + + +async def iter_hash_keys(prefix: str) -> AsyncIterator[str]: + """Iterate over keys that match the provided hash prefix. + + Parameters + ---------- + prefix: + A glob-compatible prefix, e.g. ``"node:*"``. + """ + + client = await get_redis() + async for key in client.scan_iter(match=prefix): + yield key diff --git a/ambassadors/interchained-node-operator-portal/frontend/.eslintrc.json b/ambassadors/interchained-node-operator-portal/frontend/.eslintrc.json new file mode 100644 index 000000000..97a2bb84e --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/frontend/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next", "next/core-web-vitals"] +} diff --git a/ambassadors/interchained-node-operator-portal/frontend/components/GlassContainer.js b/ambassadors/interchained-node-operator-portal/frontend/components/GlassContainer.js new file mode 100644 index 000000000..7d898831a --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/frontend/components/GlassContainer.js @@ -0,0 +1,7 @@ +export default function GlassContainer({ children, className = '' }) { + return ( +
+ {children} +
+ ); +} diff --git a/ambassadors/interchained-node-operator-portal/frontend/components/NeonCard.js b/ambassadors/interchained-node-operator-portal/frontend/components/NeonCard.js new file mode 100644 index 000000000..074fb673f --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/frontend/components/NeonCard.js @@ -0,0 +1,9 @@ +export default function NeonCard({ title, value, footer }) { + return ( +
+

{title}

+

{value}

+ {footer &&

{footer}

} +
+ ); +} diff --git a/ambassadors/interchained-node-operator-portal/frontend/components/NodeTable.js b/ambassadors/interchained-node-operator-portal/frontend/components/NodeTable.js new file mode 100644 index 000000000..2bc6093b8 --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/frontend/components/NodeTable.js @@ -0,0 +1,43 @@ +import StatusBadge from './StatusBadge'; + +export default function NodeTable({ nodes }) { + if (!nodes?.length) { + return

No nodes registered yet.

; + } + + return ( +
+ + + + + + + + + + + + + {nodes.map((node) => ( + + + + + + + + + ))} + +
NodeRPC URLLatencyBlock HeightUptimeStatus
+
{node.p2p_address}
+
{node.email}
+
{node.rpc_url}{node.latency_ms ? `${node.latency_ms.toFixed(0)} ms` : '–'}{node.block_height || '–'}{(node.uptime_score * 100).toFixed(1)}% + 0.9 ? 'Online' : node.uptime_score > 0.5 ? 'Degraded' : 'Offline'} + /> +
+
+ ); +} diff --git a/ambassadors/interchained-node-operator-portal/frontend/components/RewardGraph.js b/ambassadors/interchained-node-operator-portal/frontend/components/RewardGraph.js new file mode 100644 index 000000000..6bad05756 --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/frontend/components/RewardGraph.js @@ -0,0 +1,30 @@ +import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; + +export default function RewardGraph({ data }) { + if (!data?.length) { + return

No rewards distributed yet.

; + } + + return ( +
+ + + + + + + + + + + + + + + +
+ ); +} diff --git a/ambassadors/interchained-node-operator-portal/frontend/components/StatusBadge.js b/ambassadors/interchained-node-operator-portal/frontend/components/StatusBadge.js new file mode 100644 index 000000000..d57bb1ece --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/frontend/components/StatusBadge.js @@ -0,0 +1,15 @@ +const colors = { + online: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/50', + offline: 'bg-rose-500/20 text-rose-300 border-rose-500/50', + degraded: 'bg-amber-500/20 text-amber-300 border-amber-500/50', +}; + +export default function StatusBadge({ status }) { + const key = status?.toLowerCase(); + const style = colors[key] || colors.degraded; + return ( + + {status} + + ); +} diff --git a/ambassadors/interchained-node-operator-portal/frontend/jsconfig.json b/ambassadors/interchained-node-operator-portal/frontend/jsconfig.json new file mode 100644 index 000000000..36aa1a4dc --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/frontend/jsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "baseUrl": "." + } +} diff --git a/ambassadors/interchained-node-operator-portal/frontend/next.config.js b/ambassadors/interchained-node-operator-portal/frontend/next.config.js new file mode 100644 index 000000000..83ebfd747 --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/frontend/next.config.js @@ -0,0 +1,10 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + swcMinify: true, + images: { + unoptimized: true, + }, +}; + +module.exports = nextConfig; diff --git a/ambassadors/interchained-node-operator-portal/frontend/package.json b/ambassadors/interchained-node-operator-portal/frontend/package.json new file mode 100644 index 000000000..1f6617445 --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/frontend/package.json @@ -0,0 +1,27 @@ +{ + "name": "interchained-node-operator-portal", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@headlessui/react": "^1.7.17", + "@heroicons/react": "^2.1.5", + "axios": "^1.6.8", + "next": "14.1.0", + "react": "18.2.0", + "react-dom": "18.2.0", + "recharts": "^2.7.2", + "tailwindcss": "^3.4.1" + }, + "devDependencies": { + "autoprefixer": "^10.4.16", + "eslint": "^8.56.0", + "eslint-config-next": "14.1.0", + "postcss": "^8.4.31" + } +} diff --git a/ambassadors/interchained-node-operator-portal/frontend/pages/_app.js b/ambassadors/interchained-node-operator-portal/frontend/pages/_app.js new file mode 100644 index 000000000..8dba54bdd --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/frontend/pages/_app.js @@ -0,0 +1,5 @@ +import '../styles/globals.css'; + +export default function App({ Component, pageProps }) { + return ; +} diff --git a/ambassadors/interchained-node-operator-portal/frontend/pages/_document.js b/ambassadors/interchained-node-operator-portal/frontend/pages/_document.js new file mode 100644 index 000000000..f59f20a2f --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/frontend/pages/_document.js @@ -0,0 +1,20 @@ +import { Html, Head, Main, NextScript } from 'next/document'; + +export default function Document() { + return ( + + + + + + + +
+ + + + ); +} diff --git a/ambassadors/interchained-node-operator-portal/frontend/pages/dashboard.js b/ambassadors/interchained-node-operator-portal/frontend/pages/dashboard.js new file mode 100644 index 000000000..31baa4e40 --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/frontend/pages/dashboard.js @@ -0,0 +1,88 @@ +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import axios from 'axios'; +import NeonCard from '../components/NeonCard'; +import GlassContainer from '../components/GlassContainer'; +import NodeTable from '../components/NodeTable'; + +const API_BASE = process.env.NEXT_PUBLIC_API_BASE || 'http://localhost:8000'; + +export default function Dashboard() { + const router = useRouter(); + const [profile, setProfile] = useState(null); + const [node, setNode] = useState(null); + const [rewards, setRewards] = useState({ rewards: {} }); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const token = localStorage.getItem('token'); + if (!token) { + router.push('/login'); + return; + } + + const client = axios.create({ + baseURL: API_BASE, + headers: { Authorization: `Bearer ${token}` }, + }); + + async function fetchData() { + try { + const [meRes, nodeRes, rewardsRes] = await Promise.all([ + client.get('/users/me'), + client.get('/nodes/me').catch(() => null), + client.get('/rewards/today'), + ]); + setProfile(meRes.data); + setNode(nodeRes?.data || null); + setRewards(rewardsRes.data); + } catch (err) { + localStorage.removeItem('token'); + router.push('/login'); + } finally { + setLoading(false); + } + } + + fetchData(); + }, [router]); + + if (loading) { + return

Loading dashboard...

; + } + + const uptime = node ? (node.uptime_score * 100).toFixed(2) : '0.00'; + const latency = node?.latency_ms ? `${node.latency_ms.toFixed(0)} ms` : '–'; + const rewardToday = Object.values(rewards.rewards || {}).reduce((sum, value) => sum + Number(value), 0); + + return ( +
+
+
+

Welcome back

+

{profile?.email}

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

Node Health

+ +
+
+ ); +} diff --git a/ambassadors/interchained-node-operator-portal/frontend/pages/index.js b/ambassadors/interchained-node-operator-portal/frontend/pages/index.js new file mode 100644 index 000000000..9efd9aead --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/frontend/pages/index.js @@ -0,0 +1,57 @@ +import Link from 'next/link'; +import GlassContainer from '../components/GlassContainer'; + +export default function Landing() { + return ( +
+
+

Interchained Node Operator Portal

+

+ Track node uptime, monitor performance, and earn daily rewards for + keeping the Interchained network resilient. Login to access your + personal dashboard. +

+
+ + Enter Portal + + + View Rewards + +
+
+ +
+ {[ + { + title: 'Real-time Monitoring', + description: + 'Background health checks run every 60 seconds to record availability, latency, and chain height.', + }, + { + title: 'Performance Weighted Rewards', + description: + 'Daily payouts are automatically shared among active operators based on uptime score.', + }, + { + title: 'Neon Control Center', + description: + 'A glassmorphic UI with animated charts and badges keeps node ops futuristic and fun.', + }, + ].map((card) => ( +
+

{card.title}

+

{card.description}

+
+ ))} +
+
+
+ ); +} diff --git a/ambassadors/interchained-node-operator-portal/frontend/pages/login.js b/ambassadors/interchained-node-operator-portal/frontend/pages/login.js new file mode 100644 index 000000000..7d17e0576 --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/frontend/pages/login.js @@ -0,0 +1,91 @@ +import { useState } from 'react'; +import { useRouter } from 'next/router'; +import axios from 'axios'; +import GlassContainer from '../components/GlassContainer'; + +const API_BASE = process.env.NEXT_PUBLIC_API_BASE || 'http://localhost:8000'; + +export default function LoginPage() { + const router = useRouter(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [mode, setMode] = useState('login'); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const handleSubmit = async (event) => { + event.preventDefault(); + setLoading(true); + setError(''); + + try { + if (mode === 'register') { + await axios.post(`${API_BASE}/users/register`, { email, password }); + } + const response = await axios.post(`${API_BASE}/users/login`, { email, password }); + localStorage.setItem('token', response.data.access_token); + router.push('/dashboard'); + } catch (err) { + setError(err.response?.data?.detail || 'Authentication failed'); + } finally { + setLoading(false); + } + }; + + return ( +
+ +
+
+

{mode === 'login' ? 'Login' : 'Register'}

+

+ {mode === 'login' + ? 'Authenticate with your operator account to access the portal.' + : 'Create an account to start earning node operator rewards.'} +

+
+
+ + +
+ {error &&

{error}

} + +

+ {mode === 'login' ? 'Need an account?' : 'Already registered?'}{' '} + +

+
+
+
+ ); +} diff --git a/ambassadors/interchained-node-operator-portal/frontend/pages/nodes.js b/ambassadors/interchained-node-operator-portal/frontend/pages/nodes.js new file mode 100644 index 000000000..ba6c62a41 --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/frontend/pages/nodes.js @@ -0,0 +1,131 @@ +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import axios from 'axios'; +import GlassContainer from '../components/GlassContainer'; + +const API_BASE = process.env.NEXT_PUBLIC_API_BASE || 'http://localhost:8000'; + +export default function NodesPage() { + const router = useRouter(); + const [node, setNode] = useState(null); + const [form, setForm] = useState({ p2p_address: '', rpc_url: '', wallet_address: '' }); + const [status, setStatus] = useState(''); + + useEffect(() => { + const token = localStorage.getItem('token'); + if (!token) { + router.push('/login'); + return; + } + + const client = axios.create({ + baseURL: API_BASE, + headers: { Authorization: `Bearer ${token}` }, + }); + + client + .get('/nodes/me') + .then((response) => { + setNode(response.data); + setForm({ + p2p_address: response.data.p2p_address, + rpc_url: response.data.rpc_url, + wallet_address: response.data.wallet_address, + }); + }) + .catch(() => setNode(null)); + + NodesPage.client = client; + }, [router]); + + const handleChange = (event) => { + const { name, value } = event.target; + setForm((prev) => ({ ...prev, [name]: value })); + }; + + const handleSubmit = async (event) => { + event.preventDefault(); + setStatus(''); + const token = localStorage.getItem('token'); + if (!token) { + router.push('/login'); + return; + } + + const client = axios.create({ + baseURL: API_BASE, + headers: { Authorization: `Bearer ${token}` }, + }); + + try { + if (node) { + const response = await client.patch('/nodes/me', form); + setNode(response.data); + setStatus('Node updated successfully.'); + } else { + const me = await client.get('/users/me'); + const response = await client.post('/nodes/register', { ...form, email: me.data.email }); + setNode(response.data); + setStatus('Node registered successfully.'); + } + } catch (err) { + setStatus(err.response?.data?.detail || 'Unable to save node.'); + } + }; + + return ( +
+ +

Node Configuration

+
+
+ +
+
+ +
+
+ +
+ +
+ {status &&

{status}

} +
+
+ ); +} diff --git a/ambassadors/interchained-node-operator-portal/frontend/pages/rewards.js b/ambassadors/interchained-node-operator-portal/frontend/pages/rewards.js new file mode 100644 index 000000000..2f74c47f9 --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/frontend/pages/rewards.js @@ -0,0 +1,87 @@ +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import axios from 'axios'; +import GlassContainer from '../components/GlassContainer'; +import RewardGraph from '../components/RewardGraph'; + +const API_BASE = process.env.NEXT_PUBLIC_API_BASE || 'http://localhost:8000'; + +export default function RewardsPage() { + const router = useRouter(); + const [history, setHistory] = useState([]); + const [today, setToday] = useState({ rewards: {} }); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const token = localStorage.getItem('token'); + + if (!token) { + router.push('/login'); + return; + } + + const client = axios.create({ + baseURL: API_BASE, + headers: { Authorization: `Bearer ${token}` }, + }); + + async function fetchRewards() { + try { + const [historyRes, todayRes] = await Promise.all([ + client.get('/rewards/history'), + client.get('/rewards/today'), + ]); + setHistory(historyRes.data.history); + setToday(todayRes.data); + } catch (err) { + localStorage.removeItem('token'); + router.push('/login'); + } finally { + setLoading(false); + } + } + + fetchRewards(); + }, [router]); + + if (loading) { + return

Loading rewards...

; + } + + const graphData = history.map((entry) => ({ + date: new Date(entry.date).toLocaleDateString(), + amount: entry.amount, + })); + + return ( +
+ +

Reward History

+ +
+ + +

Today's Pool

+
+
+ Total Pool Distributed + + {Object.values(today.rewards || {}) + .reduce((sum, amount) => sum + Number(amount), 0) + .toFixed(4)}{' '} + ITC + +
+
    + {Object.entries(today.rewards || {}).map(([email, amount]) => ( +
  • + {email} + {Number(amount).toFixed(4)} ITC +
  • + ))} +
+
+
+
+ ); +} diff --git a/ambassadors/interchained-node-operator-portal/frontend/postcss.config.js b/ambassadors/interchained-node-operator-portal/frontend/postcss.config.js new file mode 100644 index 000000000..12a703d90 --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/frontend/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/ambassadors/interchained-node-operator-portal/frontend/styles/globals.css b/ambassadors/interchained-node-operator-portal/frontend/styles/globals.css new file mode 100644 index 000000000..ceba1c474 --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/frontend/styles/globals.css @@ -0,0 +1,21 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + @apply bg-slate-950 text-slate-100 font-body min-h-screen; + background-image: radial-gradient(circle at top left, rgba(53, 211, 255, 0.25), transparent 45%), + radial-gradient(circle at top right, rgba(255, 45, 149, 0.2), transparent 50%); +} + +a { + @apply text-neon-blue hover:text-neon-pink transition-colors; +} + +.glass-panel { + @apply bg-glass-dark backdrop-blur-xl border border-slate-700/40 rounded-3xl shadow-neon; +} + +.neon-text { + @apply text-transparent bg-clip-text bg-gradient-to-r from-neon-pink via-neon-blue to-neon-purple font-heading; +} diff --git a/ambassadors/interchained-node-operator-portal/frontend/tailwind.config.js b/ambassadors/interchained-node-operator-portal/frontend/tailwind.config.js new file mode 100644 index 000000000..faf25dad0 --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/frontend/tailwind.config.js @@ -0,0 +1,30 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + "./pages/**/*.{js,jsx}", + "./components/**/*.{js,jsx}", + ], + theme: { + extend: { + colors: { + neon: { + pink: "#ff2d95", + blue: "#35d3ff", + purple: "#8a63ff", + }, + glass: { + dark: "rgba(10, 12, 29, 0.85)", + light: "rgba(81, 92, 140, 0.35)", + } + }, + fontFamily: { + heading: ["Orbitron", "sans-serif"], + body: ["Inter", "sans-serif"], + }, + boxShadow: { + neon: "0 0 20px rgba(53, 211, 255, 0.45)", + }, + }, + }, + plugins: [], +};