Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions ambassadors/interchained-node-operator-portal/README.md
Original file line number Diff line number Diff line change
@@ -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.
Empty file.
78 changes: 78 additions & 0 deletions ambassadors/interchained-node-operator-portal/backend/auth.py
Original file line number Diff line number Diff line change
@@ -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"]))
46 changes: 46 additions & 0 deletions ambassadors/interchained-node-operator-portal/backend/main.py
Original file line number Diff line number Diff line change
@@ -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"}
64 changes: 64 additions & 0 deletions ambassadors/interchained-node-operator-portal/backend/models.py
Original file line number Diff line number Diff line change
@@ -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]
Original file line number Diff line number Diff line change
@@ -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),
},
)
Original file line number Diff line number Diff line change
@@ -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
Loading