chore(repo): merge bootnode-org/main back into the Hanzo-side work

These two repos share root commit 8999f3c3 ("Initial commit") but
diverged long ago — hanzoai/bootnode evolved the recent stack
(api-go, brand package, etc.) while bootnode-org/main retained the
mainline 2026 history (175 commits we don't have).

Brand-leak fix: this work belongs in github.com/bootnode/bootnode,
not github.com/hanzoai/bootnode. Per the no-cross-brand-leak policy,
merge both histories together and push the result back to
bootnode/bootnode as the canonical home.

After this lands:
- bootnode/bootnode is the canonical repo
- hanzoai/bootnode gets archived with a redirect notice
- @bootnode-io/brand npm package's `repository.url` already points at
  bootnode/bootnode (set in the earlier package.json scaffold)

# Conflicts:
#	.github/workflows/ci.yml
#	api/bootnode/api/__init__.py
#	api/bootnode/api/billing/__init__.py
#	api/bootnode/config.py
#	api/bootnode/core/billing/__init__.py
#	api/bootnode/db/models.py
#	api/bootnode/main.py
#	api/tests/test_billing.py
#	web/app/dashboard/infrastructure/page.tsx
#	web/app/dashboard/organization/billing/page.tsx
#	web/app/dashboard/organization/page.tsx
#	web/app/dashboard/page.tsx
#	web/app/layout.tsx
#	web/components/navbar.tsx
#	web/components/unified-navigation.tsx
#	web/lib/brand.ts
#	web/public/logo/zoo-icon.svg
#	web/public/logo/zoo-logo-white.svg
#	web/public/logo/zoo-logo.svg
#	web/tsconfig.tsbuildinfo
This commit is contained in:
2026-05-19 10:23:43 -07:00
51 changed files with 8459 additions and 73 deletions
+49
View File
@@ -0,0 +1,49 @@
"""Key Management API - bridges staking UI to MPC wallet system."""
from fastapi import APIRouter
from sqlalchemy import select
from sqlalchemy import func as sa_func
from bootnode.api.deps import ApiKeyDep, DbDep, ProjectDep
from bootnode.db.models import MPCWalletModel
router = APIRouter()
@router.get("/status")
async def get_key_status(
api_key: ApiKeyDep,
project: ProjectDep,
db: DbDep,
) -> dict:
"""Get key management status for the project.
Called by the staking page to check if MPC wallet is configured.
Proxies to MPC wallet status data.
"""
result = await db.execute(
select(sa_func.count(MPCWalletModel.id)).where(
MPCWalletModel.project_id == project.id,
MPCWalletModel.status == "active",
)
)
total = result.scalar() or 0
# Get first active wallet as default
first_result = await db.execute(
select(MPCWalletModel).where(
MPCWalletModel.project_id == project.id,
MPCWalletModel.status == "active",
).limit(1)
)
default_wallet = first_result.scalar_one_or_none()
return {
"configured": total > 0,
"default_address": default_wallet.address if default_wallet else None,
"default_chain": default_wallet.chain if default_wallet else None,
"total_wallets": total,
"total_balance": 0.0,
"mpc_enabled": True,
"tfhe_enabled": True,
}
+751
View File
@@ -0,0 +1,751 @@
"""Launch API — deploy a complete blockchain ecosystem from chain.yaml.
POST /v1/launch
Accepts chain.yaml content, validates, generates lux-operator CRDs,
and applies them to the target cluster. One endpoint, full ecosystem.
POST /v1/launch/validate
Dry-run: validate chain.yaml without applying anything.
POST /v1/launch/{chain}/{network}
Deploy a specific network from an already-registered chain config.
"""
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from bootnode.api.deps import ProjectDep, DbDep
from bootnode.core.chains.chain_config import ChainConfig, load_chain_config
from bootnode.core.chains.fleet_models import CHAIN_NETWORKS, NetworkConfig
logger = logging.getLogger(__name__)
router = APIRouter()
# ---------------------------------------------------------------------------
# Request / Response models
# ---------------------------------------------------------------------------
class LaunchRequest(BaseModel):
"""Request body for chain launch."""
chain_yaml: str = Field(
...,
description="Raw chain.yaml content.",
min_length=10,
)
network: Optional[str] = Field(
default=None,
description="Target specific network (default: all networks).",
)
cluster_id: str = Field(
default="incluster",
description="Target K8s cluster ID.",
)
dry_run: bool = Field(
default=False,
description="Validate and generate manifests without applying.",
)
class ValidateRequest(BaseModel):
"""Request body for chain.yaml validation."""
chain_yaml: str = Field(..., min_length=10)
class ServiceManifest(BaseModel):
"""A single generated manifest."""
name: str
kind: str
namespace: str
yaml_content: str = Field(alias="yamlContent")
model_config = {"populate_by_name": True}
class NetworkResult(BaseModel):
"""Generated manifests for one network."""
network: str
namespace: str
manifests: list[ServiceManifest]
applied: bool = False
class LaunchResponse(BaseModel):
"""Response from chain launch."""
chain_name: str = Field(alias="chainName")
chain_slug: str = Field(alias="chainSlug")
networks: list[NetworkResult]
dry_run: bool = Field(alias="dryRun")
errors: list[str] = Field(default_factory=list)
model_config = {"populate_by_name": True}
class ValidateResponse(BaseModel):
"""Response from chain.yaml validation."""
valid: bool
chain_name: Optional[str] = Field(default=None, alias="chainName")
chain_slug: Optional[str] = Field(default=None, alias="chainSlug")
networks: list[str] = Field(default_factory=list)
services: list[str] = Field(default_factory=list)
errors: list[str] = Field(default_factory=list)
warnings: list[str] = Field(default_factory=list)
model_config = {"populate_by_name": True}
# ---------------------------------------------------------------------------
# CRD Generation (Python port of Go chainkit/generate.go)
# ---------------------------------------------------------------------------
def _generate_namespace(cfg: ChainConfig, network: str, namespace: str) -> str:
return f"""apiVersion: v1
kind: Namespace
metadata:
name: {namespace}
labels:
app.kubernetes.io/part-of: {cfg.chain.slug}-network
lux.network/chain: {cfg.chain.slug}
lux.network/network: {network}
"""
def _generate_lux_network(cfg: ChainConfig, network: str, net: "NetworkSpec", namespace: str) -> str:
sybil = "true" if net.sybil_protection is None or net.sybil_protection else "false"
lines = [
"apiVersion: lux.network/v1alpha1",
"kind: LuxNetwork",
"metadata:",
f" name: {cfg.chain.slug}d",
f" namespace: {namespace}",
" labels:",
f" app.kubernetes.io/part-of: {cfg.chain.slug}-network",
f" lux.network/network: {network}",
"spec:",
f" networkId: {net.network_id}",
f" validators: {net.validators}",
f" dbType: {cfg.chain.db_type}",
f" networkCompressionType: {cfg.chain.compression}",
" image:",
f" repository: {cfg.services.node.image}",
]
if net.image_tag:
lines.append(f' tag: "{net.image_tag}"')
lines += [
" ports:",
" http: 9650",
" staking: 9651",
" consensus:",
f" sybilProtectionEnabled: {sybil}",
" storage:",
f' size: "{cfg.services.node.storage_size}"',
]
if cfg.services.node.storage_class:
lines.append(f" storageClass: {cfg.services.node.storage_class}")
if cfg.services.node.staking_kms:
kms = cfg.services.node.staking_kms
lines += [
" staking:",
" kms:",
f" hostApi: {kms.host_api}",
f" projectSlug: {kms.project_slug}",
f" envSlug: {kms.env_slug}",
f" secretsPath: {kms.secrets_path}",
]
if net.snapshot_interval > 0:
lines += [
" snapshotSchedule:",
" enabled: true",
f" intervalSeconds: {net.snapshot_interval}",
]
lines += [
" chainTracking:",
" trackAllChains: true",
" startupGate:",
" onTimeout: StartAnyway",
" timeoutSeconds: 30",
]
if cfg.precompiles:
lines += [
" chainUpgradeConfig:",
" precompileUpgrades:",
]
for p in cfg.precompiles:
lines.append(f" - {p.name}: {{blockTimestamp: {p.block_timestamp}}}")
return "\n".join(lines) + "\n"
def _generate_lux_indexer(cfg: ChainConfig, network: str, net: "NetworkSpec", namespace: str) -> str:
idx = cfg.services.indexer
return f"""apiVersion: lux.network/v1alpha1
kind: LuxIndexer
metadata:
name: indexer-{cfg.chain.slug}
namespace: {namespace}
labels:
app.kubernetes.io/part-of: {cfg.chain.slug}-network
lux.network/network: {network}
lux.network/chain: {cfg.chain.slug}
spec:
networkRef: {cfg.chain.slug}d
chainAlias: "{cfg.chain.slug}"
chainId: {net.chain_id}
image:
repository: {idx.image}
tag: {idx.image_tag}
database:
managed: true
storageSize: "{idx.db_storage_size}"
port: 4000
replicas: {idx.replicas}
traceEnabled: {str(idx.trace_enabled).lower()}
contractVerification: {str(idx.contract_verification).lower()}
pollInterval: {idx.poll_interval}
storage:
size: "10Gi"
"""
def _generate_lux_explorer(cfg: ChainConfig, network: str, net: "NetworkSpec", namespace: str) -> str:
"""Generate Deployment + Service + PVC for the single-binary explorer.
Image: ghcr.io/luxfi/explorer (Go binary, SQLite, port 8090).
Replaces the old multi-container Blockscout (Elixir + PostgreSQL + Redis).
"""
ex = cfg.services.explorer
host = cfg.brand.domains.explorer or f"explorer.{network}.{cfg.chain.slug}.network"
rpc_url = net.rpc_url or f"http://{cfg.chain.slug}d-0.{cfg.chain.slug}d-headless.{namespace}.svc:9650/ext/bc/C/rpc"
port = ex.port
return f"""apiVersion: apps/v1
kind: Deployment
metadata:
name: explorer
namespace: {namespace}
labels:
app.kubernetes.io/part-of: {cfg.chain.slug}-network
app.kubernetes.io/component: explorer
lux.network/network: {network}
spec:
replicas: {ex.replicas}
selector:
matchLabels:
app: explorer
strategy:
type: Recreate
template:
metadata:
labels:
app: explorer
lux.network/network: {network}
spec:
containers:
- name: explorer
image: {ex.image}:{ex.image_tag}
ports:
- name: http
containerPort: {port}
env:
- name: RPC_ENDPOINT
value: "{rpc_url}"
- name: DATA_DIR
value: /data
- name: CHAIN_NAME
value: "{cfg.brand.display_name}"
- name: COIN_SYMBOL
value: "{cfg.token.symbol}"
- name: CHAIN_ID
value: "{net.chain_id}"
- name: HTTP_ADDR
value: ":{port}"
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: "2"
memory: 4Gi
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 15
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 5
periodSeconds: 10
volumeMounts:
- name: data
mountPath: /data
securityContext:
runAsNonRoot: true
runAsUser: 65532
allowPrivilegeEscalation: false
volumes:
- name: data
persistentVolumeClaim:
claimName: explorer-data
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: explorer-data
namespace: {namespace}
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: "{ex.data_storage_size}"
---
apiVersion: v1
kind: Service
metadata:
name: explorer
namespace: {namespace}
labels:
app: explorer
spec:
selector:
app: explorer
ports:
- name: http
port: {port}
targetPort: http
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: explorer
namespace: {namespace}
spec:
ingressClassName: {ex.ingress_class}
rules:
- host: {host}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: explorer
port:
number: {port}
"""
def _generate_lux_gateway(cfg: ChainConfig, network: str, namespace: str) -> str:
host = cfg.brand.domains.rpc or f"api.{network}.{cfg.chain.slug}.network"
gw = cfg.services.gateway
return f"""apiVersion: lux.network/v1alpha1
kind: LuxGateway
metadata:
name: api-gateway
namespace: {namespace}
labels:
app.kubernetes.io/part-of: {cfg.chain.slug}-network
lux.network/network: {network}
spec:
networkRef: {cfg.chain.slug}d
host: {host}
replicas: {gw.replicas}
port: 8080
autoRoutes: true
cors:
allowedOrigins:
- "*"
allowedMethods:
- "GET"
- "POST"
- "OPTIONS"
allowedHeaders:
- "Content-Type"
- "Authorization"
rateLimit:
requestsPerSecond: {gw.rate_limit_rps}
burst: {gw.rate_limit_burst}
"""
def _generate_exchange(cfg: ChainConfig, network: str, net: "NetworkSpec", namespace: str) -> str:
ex = cfg.services.exchange
return f"""apiVersion: apps/v1
kind: Deployment
metadata:
name: {cfg.chain.slug}-exchange
namespace: {namespace}
labels:
app.kubernetes.io/part-of: {cfg.chain.slug}-network
app.kubernetes.io/component: exchange
spec:
replicas: 1
selector:
matchLabels:
app: {cfg.chain.slug}-exchange
template:
metadata:
labels:
app: {cfg.chain.slug}-exchange
spec:
containers:
- name: exchange
image: {ex.image}
ports:
- containerPort: 3000
env:
- name: NEXT_PUBLIC_BRAND_PACKAGE
value: "{ex.brand_package}"
- name: NEXT_PUBLIC_CHAIN_ID
value: "{net.chain_id}"
---
apiVersion: v1
kind: Service
metadata:
name: {cfg.chain.slug}-exchange
namespace: {namespace}
spec:
selector:
app: {cfg.chain.slug}-exchange
ports:
- port: 3000
targetPort: 3000
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {cfg.chain.slug}-exchange
namespace: {namespace}
spec:
ingressClassName: {cfg.deploy.ingress_class}
rules:
- host: {cfg.brand.domains.exchange}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {cfg.chain.slug}-exchange
port:
number: 3000
"""
def _generate_faucet(cfg: ChainConfig, network: str, net: "NetworkSpec", namespace: str) -> str:
faucet = cfg.services.faucet
return f"""apiVersion: apps/v1
kind: Deployment
metadata:
name: {cfg.chain.slug}-faucet
namespace: {namespace}
labels:
app.kubernetes.io/part-of: {cfg.chain.slug}-network
app.kubernetes.io/component: faucet
spec:
replicas: 1
selector:
matchLabels:
app: {cfg.chain.slug}-faucet
template:
metadata:
labels:
app: {cfg.chain.slug}-faucet
spec:
containers:
- name: faucet
image: {cfg.deploy.registry}/faucet:latest
ports:
- containerPort: 8080
env:
- name: CHAIN_ID
value: "{net.chain_id}"
- name: RPC_URL
value: "http://{cfg.chain.slug}d-0.{cfg.chain.slug}d:9650/ext/bc/C/rpc"
- name: DRIP_AMOUNT
value: "{faucet.drip_amount}"
- name: RATE_LIMIT
value: "{faucet.rate_limit}"
---
apiVersion: v1
kind: Service
metadata:
name: {cfg.chain.slug}-faucet
namespace: {namespace}
spec:
selector:
app: {cfg.chain.slug}-faucet
ports:
- port: 8080
targetPort: 8080
"""
def generate_manifests(cfg: ChainConfig, network: str) -> NetworkResult:
"""Generate all CRD manifests for a single network."""
net = cfg.networks.get(network)
if not net:
raise ValueError(f"Network {network!r} not defined. Available: {list(cfg.networks.keys())}")
ns = cfg.namespace_for(network)
manifests: list[ServiceManifest] = []
# Namespace
manifests.append(ServiceManifest(
name="namespace.yaml",
kind="Namespace",
namespace=ns,
yamlContent=_generate_namespace(cfg, network, ns),
))
# LuxNetwork CR
if cfg.services.node.enabled:
manifests.append(ServiceManifest(
name="luxnetwork.yaml",
kind="LuxNetwork",
namespace=ns,
yamlContent=_generate_lux_network(cfg, network, net, ns),
))
# LuxIndexer CR
if cfg.services.indexer.enabled:
manifests.append(ServiceManifest(
name="luxindexer.yaml",
kind="LuxIndexer",
namespace=ns,
yamlContent=_generate_lux_indexer(cfg, network, net, ns),
))
# Explorer Deployment (single-binary ghcr.io/luxfi/explorer)
if cfg.services.explorer.enabled:
manifests.append(ServiceManifest(
name="explorer.yaml",
kind="Deployment",
namespace=ns,
yamlContent=_generate_lux_explorer(cfg, network, net, ns),
))
# LuxGateway CR
if cfg.services.gateway.enabled:
manifests.append(ServiceManifest(
name="luxgateway.yaml",
kind="LuxGateway",
namespace=ns,
yamlContent=_generate_lux_gateway(cfg, network, ns),
))
# Exchange Deployment
if cfg.services.exchange.enabled:
manifests.append(ServiceManifest(
name="exchange.yaml",
kind="Deployment",
namespace=ns,
yamlContent=_generate_exchange(cfg, network, net, ns),
))
# Faucet (not on mainnet)
if cfg.services.faucet.enabled and network != "mainnet":
manifests.append(ServiceManifest(
name="faucet.yaml",
kind="Deployment",
namespace=ns,
yamlContent=_generate_faucet(cfg, network, net, ns),
))
return NetworkResult(network=network, namespace=ns, manifests=manifests)
def register_chain_networks(cfg: ChainConfig) -> None:
"""Register chain networks in CHAIN_NETWORKS so FleetManager can track them."""
slug = cfg.chain.slug
if slug not in CHAIN_NETWORKS:
CHAIN_NETWORKS[slug] = {}
for name, net in cfg.networks.items():
CHAIN_NETWORKS[slug][name] = NetworkConfig(
chain=slug,
network_id=net.network_id,
chain_id=net.chain_id,
http_port=9650,
staking_port=9651,
namespace=cfg.namespace_for(name),
)
# Register as lux-family if using lux sequencer
if cfg.chain.sequencer == "lux":
from bootnode.core.chains.fleet_manager import LUX_FAMILY_CHAINS
LUX_FAMILY_CHAINS.add(slug)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.post("", response_model=LaunchResponse)
async def launch_chain(
req: LaunchRequest,
project: ProjectDep,
db: DbDep,
) -> LaunchResponse:
"""Launch a complete blockchain ecosystem from chain.yaml.
Accepts raw chain.yaml content, generates lux-operator CRDs, and
optionally applies them to the target K8s cluster.
"""
# Parse and validate
try:
cfg = load_chain_config(req.chain_yaml)
except (ValueError, Exception) as e:
raise HTTPException(400, f"Invalid chain.yaml: {e}")
errors = cfg.validate_config()
if errors:
raise HTTPException(422, detail={"errors": errors})
# Register in fleet manager's chain network registry
register_chain_networks(cfg)
# Determine target networks
if req.network:
if req.network not in cfg.networks:
raise HTTPException(400, f"Network {req.network!r} not in chain.yaml")
target_networks = [req.network]
else:
target_networks = list(cfg.networks.keys())
# Generate manifests
results: list[NetworkResult] = []
for net_name in target_networks:
result = generate_manifests(cfg, net_name)
# Apply to cluster if not dry-run
if not req.dry_run:
try:
applied = await _apply_manifests(req.cluster_id, result, project, db)
result.applied = applied
except Exception as e:
logger.error("Failed to apply manifests for %s: %s", net_name, e)
result.applied = False
results.append(result)
return LaunchResponse(
chainName=cfg.chain.name,
chainSlug=cfg.chain.slug,
networks=results,
dryRun=req.dry_run,
)
@router.post("/validate", response_model=ValidateResponse)
async def validate_chain_yaml(req: ValidateRequest) -> ValidateResponse:
"""Validate chain.yaml without applying anything.
No auth required — useful for CLI pre-flight checks.
"""
try:
cfg = load_chain_config(req.chain_yaml)
except (ValueError, Exception) as e:
return ValidateResponse(valid=False, errors=[str(e)])
errors = cfg.validate_config()
warnings: list[str] = []
# Check for common issues
if not cfg.token.symbol:
warnings.append("token.symbol is empty")
if not cfg.brand.domains.explorer:
warnings.append("brand.domains.explorer not set — will use auto-generated hostname")
if not cfg.services.node.staking_kms:
warnings.append("services.node.stakingKms not configured — staking keys won't be managed by KMS")
services = []
if cfg.services.node.enabled:
services.append("node")
if cfg.services.indexer.enabled:
services.append("indexer")
if cfg.services.explorer.enabled:
services.append("explorer")
if cfg.services.gateway.enabled:
services.append("gateway")
if cfg.services.exchange.enabled:
services.append("exchange")
if cfg.services.faucet.enabled:
services.append("faucet")
return ValidateResponse(
valid=len(errors) == 0,
chainName=cfg.chain.name,
chainSlug=cfg.chain.slug,
networks=list(cfg.networks.keys()),
services=services,
errors=errors,
warnings=warnings,
)
# ---------------------------------------------------------------------------
# Internal: apply manifests to cluster
# ---------------------------------------------------------------------------
async def _apply_manifests(
cluster_id: str,
result: NetworkResult,
project: "ProjectDep",
db: "DbDep",
) -> bool:
"""Apply generated manifests to a K8s cluster via kubectl."""
from bootnode.core.chains.fleet_manager import FleetManager
manager = FleetManager()
# Get kubeconfig for cluster
kubeconfig = None
if cluster_id != "incluster":
from bootnode.api.fleets import _get_kubeconfig
kubeconfig = await _get_kubeconfig(cluster_id)
# Apply each manifest
import tempfile
import subprocess
for manifest in result.manifests:
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
f.write(manifest.yaml_content)
f.flush()
cmd = ["kubectl", "apply", "-f", f.name]
if kubeconfig:
# Write kubeconfig to temp file
import os
kc_path = f.name + ".kubeconfig"
with open(kc_path, "w") as kf:
kf.write(kubeconfig)
cmd = ["kubectl", "--kubeconfig", kc_path, "apply", "-f", f.name]
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if proc.returncode != 0:
logger.error("kubectl apply failed: %s", proc.stderr)
raise RuntimeError(f"kubectl apply failed: {proc.stderr}")
logger.info("Applied %s to %s", manifest.name, result.namespace)
return True
+377
View File
@@ -0,0 +1,377 @@
"""MPC Wallet API - Threshold ECDSA with Shamir's Secret Sharing."""
import os
import uuid
from datetime import UTC, datetime
from typing import Any
import structlog
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy import func as sa_func
from bootnode.api.deps import ApiKeyDep, DbDep, ProjectDep
from bootnode.core.mpc import (
KeyShare,
MPCWallet,
decrypt_share,
encrypt_share,
)
from bootnode.db.models import MPCWalletModel
logger = structlog.get_logger()
router = APIRouter()
# Encryption key for share storage (should come from KMS in production)
SHARE_ENCRYPTION_KEY = os.getenv(
"MPC_SHARE_ENCRYPTION_KEY", "bootnode-mpc-dev-key"
)
class CreateMPCWalletRequest(BaseModel):
chain: str = "ethereum"
network: str = "mainnet"
threshold: int = 2
num_shares: int = 3
share_holders: dict[str, str] | None = None
class MPCWalletResponse(BaseModel):
id: uuid.UUID
address: str
chain: str
network: str
threshold: int
num_shares: int
share_holders: dict[str, str] | None
status: str
signing_count: int
created_at: datetime
class Config:
from_attributes = True
class MPCSignRequest(BaseModel):
shares: list[dict[str, Any]]
message: str | None = None
message_hash: str | None = None
transaction: dict | None = None
class MPCSignResponse(BaseModel):
signature: str
signer: str
v: int
r: str
s: str
raw_transaction: str | None = None
class MPCKeyStatusResponse(BaseModel):
configured: bool
default_address: str | None
default_chain: str | None
total_wallets: int
total_balance: float
mpc_enabled: bool
tfhe_enabled: bool
@router.post("/create", response_model=MPCWalletResponse)
async def create_mpc_wallet(
request: CreateMPCWalletRequest,
api_key: ApiKeyDep,
project: ProjectDep,
db: DbDep,
) -> MPCWalletModel:
"""Create a new MPC wallet with threshold key shares."""
if request.threshold < 2:
raise HTTPException(400, "Threshold must be at least 2")
if request.threshold > request.num_shares:
raise HTTPException(
400, "Threshold cannot exceed number of shares"
)
if request.num_shares > 10:
raise HTTPException(400, "Maximum 10 shares supported")
# Generate MPC keypair
keypair = MPCWallet.generate(
threshold=request.threshold,
num_shares=request.num_shares,
)
# Encrypt shares for storage
encrypted_shares = {}
for share in keypair.shares:
encrypted_shares[str(share.index)] = encrypt_share(
share.value, SHARE_ENCRYPTION_KEY
)
# Default share holders
holders = request.share_holders or {
"1": "owner",
"2": "kms",
"3": "backup",
}
wallet = MPCWalletModel(
project_id=project.id,
address=keypair.address,
chain=request.chain,
network=request.network,
threshold=keypair.threshold,
num_shares=keypair.num_shares,
share_holders=holders,
encrypted_shares=encrypted_shares,
status="active",
)
db.add(wallet)
await db.commit()
await db.refresh(wallet)
logger.info(
"MPC wallet created",
address=keypair.address,
threshold=keypair.threshold,
shares=keypair.num_shares,
)
return wallet
@router.get("", response_model=list[MPCWalletResponse])
async def list_mpc_wallets(
api_key: ApiKeyDep,
project: ProjectDep,
db: DbDep,
chain: str | None = None,
) -> list[MPCWalletModel]:
"""List all MPC wallets for the project."""
query = select(MPCWalletModel).where(
MPCWalletModel.project_id == project.id
)
if chain:
query = query.where(MPCWalletModel.chain == chain)
result = await db.execute(query)
return list(result.scalars().all())
@router.get("/status", response_model=MPCKeyStatusResponse)
async def get_key_status(
api_key: ApiKeyDep,
project: ProjectDep,
db: DbDep,
) -> MPCKeyStatusResponse:
"""Get MPC wallet status for the project (used by staking page)."""
result = await db.execute(
select(sa_func.count(MPCWalletModel.id)).where(
MPCWalletModel.project_id == project.id,
MPCWalletModel.status == "active",
)
)
total = result.scalar() or 0
# Get first wallet as default
first = await db.execute(
select(MPCWalletModel)
.where(
MPCWalletModel.project_id == project.id,
MPCWalletModel.status == "active",
)
.limit(1)
)
default_wallet = first.scalar_one_or_none()
return MPCKeyStatusResponse(
configured=total > 0,
default_address=(
default_wallet.address if default_wallet else None
),
default_chain=(
default_wallet.chain if default_wallet else None
),
total_wallets=total,
total_balance=0.0, # Would query chain for real balance
mpc_enabled=True,
tfhe_enabled=True,
)
@router.get("/{address}", response_model=MPCWalletResponse)
async def get_mpc_wallet(
address: str,
api_key: ApiKeyDep,
project: ProjectDep,
db: DbDep,
) -> MPCWalletModel:
"""Get MPC wallet details."""
result = await db.execute(
select(MPCWalletModel).where(
MPCWalletModel.address == address.lower(),
MPCWalletModel.project_id == project.id,
)
)
wallet = result.scalar_one_or_none()
if not wallet:
raise HTTPException(404, "MPC wallet not found")
return wallet
@router.post("/{address}/sign", response_model=MPCSignResponse)
async def sign_with_mpc(
address: str,
request: MPCSignRequest,
api_key: ApiKeyDep,
project: ProjectDep,
db: DbDep,
) -> MPCSignResponse:
"""Sign a message or transaction using threshold shares."""
result = await db.execute(
select(MPCWalletModel).where(
MPCWalletModel.address == address.lower(),
MPCWalletModel.project_id == project.id,
)
)
db_wallet = result.scalar_one_or_none()
if not db_wallet:
raise HTTPException(404, "MPC wallet not found")
if db_wallet.status != "active":
raise HTTPException(400, f"Wallet is {db_wallet.status}")
# Validate shares
shares = [
KeyShare(index=s["index"], value=s["value"])
for s in request.shares
]
if len(shares) < db_wallet.threshold:
raise HTTPException(
400,
f"Need at least {db_wallet.threshold} shares, "
f"got {len(shares)}",
)
# Reconstruct and sign
try:
wallet = MPCWallet.from_shares(
shares, db_wallet.threshold, address=db_wallet.address
)
except ValueError as e:
raise HTTPException(400, f"Invalid shares: {e}") from e
if request.transaction:
signed = wallet.sign_transaction(request.transaction)
response = MPCSignResponse(
signature=signed.get("raw_transaction", ""),
signer=db_wallet.address,
v=signed["v"],
r=signed["r"],
s=signed["s"],
raw_transaction=signed.get("raw_transaction"),
)
elif request.message:
signed = wallet.sign_message(request.message)
response = MPCSignResponse(
signature=signed["signature"],
signer=db_wallet.address,
v=signed["v"],
r=signed["r"],
s=signed["s"],
)
elif request.message_hash:
msg_hash = bytes.fromhex(
request.message_hash[2:]
if request.message_hash.startswith("0x")
else request.message_hash
)
signed = wallet.sign_hash(msg_hash)
response = MPCSignResponse(
signature=signed["signature"],
signer=db_wallet.address,
v=signed["v"],
r=signed["r"],
s=signed["s"],
)
else:
raise HTTPException(
400, "Provide message, message_hash, or transaction"
)
# Update signing count
db_wallet.signing_count += 1
db_wallet.last_signed_at = datetime.now(UTC)
await db.commit()
return response
@router.get("/{address}/shares")
async def get_share_info(
address: str,
api_key: ApiKeyDep,
project: ProjectDep,
db: DbDep,
) -> dict:
"""Get share holder info (not the actual shares)."""
result = await db.execute(
select(MPCWalletModel).where(
MPCWalletModel.address == address.lower(),
MPCWalletModel.project_id == project.id,
)
)
wallet = result.scalar_one_or_none()
if not wallet:
raise HTTPException(404, "MPC wallet not found")
return {
"address": wallet.address,
"threshold": wallet.threshold,
"num_shares": wallet.num_shares,
"share_holders": wallet.share_holders,
"status": wallet.status,
}
@router.post("/{address}/decrypt-share")
async def decrypt_wallet_share(
address: str,
share_index: int,
api_key: ApiKeyDep,
project: ProjectDep,
db: DbDep,
) -> dict:
"""Decrypt a specific share for signing (requires auth)."""
result = await db.execute(
select(MPCWalletModel).where(
MPCWalletModel.address == address.lower(),
MPCWalletModel.project_id == project.id,
)
)
wallet = result.scalar_one_or_none()
if not wallet:
raise HTTPException(404, "MPC wallet not found")
idx = str(share_index)
if not wallet.encrypted_shares or idx not in wallet.encrypted_shares:
raise HTTPException(404, f"Share {share_index} not found")
try:
decrypted = decrypt_share(
wallet.encrypted_shares[idx], SHARE_ENCRYPTION_KEY
)
return {
"index": share_index,
"value": decrypted,
"holder": (
wallet.share_holders.get(idx, "unknown")
if wallet.share_holders
else "unknown"
),
}
except Exception as e:
raise HTTPException(
500, f"Failed to decrypt share: {e}"
) from e
+565
View File
@@ -0,0 +1,565 @@
"""
DB-backed node management API endpoints.
Replaces the in-memory dict approach with SQLAlchemy async CRUD.
"""
from fastapi import APIRouter, HTTPException, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, delete
from pydantic import BaseModel
from typing import Optional, List, Dict, Any
import uuid
from datetime import datetime, timezone
from bootnode.db import get_db
from bootnode.db.models import Node, Cluster
router = APIRouter()
# ---------------------------------------------------------------------------
# Region coordinates for globe visualization
# ---------------------------------------------------------------------------
REGION_COORDS: Dict[str, tuple[float, float]] = {
"sfo3": (37.7749, -122.4194),
"nyc1": (40.7128, -74.0060),
"nyc3": (40.7128, -74.0060),
"ams3": (52.3676, 4.9041),
"sgp1": (1.3521, 103.8198),
"lon1": (51.5074, -0.1278),
"fra1": (50.1109, 8.6821),
"tor1": (43.6532, -79.3832),
"blr1": (12.9716, 77.5946),
"syd1": (-33.8688, 151.2093),
}
# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------
class NodeCreate(BaseModel):
name: str
chain: str
network: str = "mainnet"
provider: str = "kubernetes"
region: str = "sfo3"
cluster_id: Optional[str] = None
node_type: str = "rpc" # rpc, validator, archive, full
execution_client: str = "luxd"
consensus_client: Optional[str] = None
sync_mode: Optional[str] = None
image: Optional[str] = None
class NodeResponse(BaseModel):
id: str
name: str
chain: str
network: str
provider: str
status: str
region: str
latitude: Optional[float] = None
longitude: Optional[float] = None
cluster_id: Optional[str] = None
rpc_endpoint: Optional[str] = None
ws_endpoint: Optional[str] = None
beacon_endpoint: Optional[str] = None
execution_client: Optional[str] = None
consensus_client: Optional[str] = None
sync_mode: Optional[str] = None
node_type: str
container_id: Optional[str] = None
pod_name: Optional[str] = None
image: Optional[str] = None
sync_progress: Optional[float] = None
peer_count: Optional[int] = None
block_height: Optional[int] = None
cpu_usage: Optional[float] = None
memory_usage_mb: Optional[float] = None
disk_usage_gb: Optional[float] = None
uptime_seconds: Optional[int] = None
error: Optional[str] = None
last_health_check: Optional[str] = None
created_at: str
class Config:
from_attributes = True
class ClusterCreate(BaseModel):
name: str
provider: str = "digitalocean"
provider_id: Optional[str] = None
region: str = "sfo3"
kubernetes_version: Optional[str] = None
endpoint: Optional[str] = None
node_count: int = 3
node_size: str = "s-4vcpu-8gb"
class ClusterResponse(BaseModel):
id: str
name: str
provider: str
provider_id: Optional[str] = None
region: str
status: str
kubernetes_version: Optional[str] = None
endpoint: Optional[str] = None
node_count: int
node_size: str
created_at: str
class Config:
from_attributes = True
class NodeStats(BaseModel):
total: int
running: int
syncing: int
stopped: int
error: int
chains: Dict[str, int]
networks: Dict[str, int]
regions: Dict[str, int]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _node_to_response(node: Node) -> NodeResponse:
"""Convert a SQLAlchemy Node to a NodeResponse dict."""
return NodeResponse(
id=str(node.id),
name=node.name,
chain=node.chain,
network=node.network,
provider=node.provider,
status=node.status,
region=node.region,
latitude=node.latitude,
longitude=node.longitude,
cluster_id=str(node.cluster_id) if node.cluster_id else None,
rpc_endpoint=node.rpc_endpoint,
ws_endpoint=node.ws_endpoint,
beacon_endpoint=node.beacon_endpoint,
execution_client=node.execution_client,
consensus_client=node.consensus_client,
sync_mode=node.sync_mode,
node_type=node.node_type,
container_id=node.container_id,
pod_name=node.pod_name,
image=node.image,
sync_progress=node.sync_progress,
peer_count=node.peer_count,
block_height=node.block_height,
cpu_usage=node.cpu_usage,
memory_usage_mb=node.memory_usage_mb,
disk_usage_gb=node.disk_usage_gb,
uptime_seconds=node.uptime_seconds,
error=node.error,
last_health_check=(
node.last_health_check.isoformat() if node.last_health_check else None
),
created_at=node.created_at.isoformat() if node.created_at else "",
)
def _cluster_to_response(cluster: Cluster) -> ClusterResponse:
"""Convert a SQLAlchemy Cluster to a ClusterResponse dict."""
return ClusterResponse(
id=str(cluster.id),
name=cluster.name,
provider=cluster.provider,
provider_id=cluster.provider_id,
region=cluster.region,
status=cluster.status,
kubernetes_version=cluster.kubernetes_version,
endpoint=cluster.endpoint,
node_count=cluster.node_count,
node_size=cluster.node_size,
created_at=cluster.created_at.isoformat() if cluster.created_at else "",
)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/", response_model=List[NodeResponse])
async def list_nodes(
chain: Optional[str] = None,
network: Optional[str] = None,
status: Optional[str] = None,
region: Optional[str] = None,
cluster_id: Optional[str] = None,
node_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
"""List all nodes with optional filters."""
stmt = select(Node)
if chain:
stmt = stmt.where(Node.chain == chain)
if network:
stmt = stmt.where(Node.network == network)
if status:
stmt = stmt.where(Node.status == status)
if region:
stmt = stmt.where(Node.region == region)
if cluster_id:
stmt = stmt.where(Node.cluster_id == uuid.UUID(cluster_id))
if node_type:
stmt = stmt.where(Node.node_type == node_type)
stmt = stmt.order_by(Node.created_at.desc())
result = await db.execute(stmt)
nodes = result.scalars().all()
return [_node_to_response(n) for n in nodes]
@router.get("/stats", response_model=NodeStats)
async def get_node_stats(db: AsyncSession = Depends(get_db)):
"""Return aggregate node statistics."""
result = await db.execute(select(Node))
nodes = result.scalars().all()
chains: Dict[str, int] = {}
networks: Dict[str, int] = {}
regions: Dict[str, int] = {}
running = syncing = stopped = error_count = 0
for n in nodes:
chains[n.chain] = chains.get(n.chain, 0) + 1
networks[n.network] = networks.get(n.network, 0) + 1
regions[n.region] = regions.get(n.region, 0) + 1
if n.status == "running":
running += 1
elif n.status == "syncing":
syncing += 1
elif n.status == "stopped":
stopped += 1
elif n.status == "error":
error_count += 1
return NodeStats(
total=len(nodes),
running=running,
syncing=syncing,
stopped=stopped,
error=error_count,
chains=chains,
networks=networks,
regions=regions,
)
@router.get("/clusters", response_model=List[ClusterResponse])
async def list_clusters(db: AsyncSession = Depends(get_db)):
"""List all registered clusters."""
result = await db.execute(select(Cluster).order_by(Cluster.created_at.desc()))
clusters = result.scalars().all()
return [_cluster_to_response(c) for c in clusters]
@router.post("/clusters", response_model=ClusterResponse, status_code=201)
async def create_cluster(body: ClusterCreate, db: AsyncSession = Depends(get_db)):
"""Register a new cluster."""
cluster = Cluster(
name=body.name,
provider=body.provider,
provider_id=body.provider_id,
region=body.region,
status="running",
kubernetes_version=body.kubernetes_version,
endpoint=body.endpoint,
node_count=body.node_count,
node_size=body.node_size,
)
db.add(cluster)
await db.flush()
await db.refresh(cluster)
return _cluster_to_response(cluster)
@router.get("/metrics/all")
async def get_all_node_metrics(db: AsyncSession = Depends(get_db)):
"""Return metrics snapshot for every node."""
result = await db.execute(select(Node))
nodes = result.scalars().all()
metrics: Dict[str, Dict[str, Any]] = {}
for n in nodes:
metrics[str(n.id)] = {
"sync_progress": n.sync_progress,
"peer_count": n.peer_count,
"block_height": n.block_height,
"cpu_usage": n.cpu_usage,
"memory_usage_mb": n.memory_usage_mb,
"disk_usage_gb": n.disk_usage_gb,
"uptime_seconds": n.uptime_seconds,
"status": n.status,
}
return metrics
@router.post("/seed")
async def seed(db: AsyncSession = Depends(get_db)):
"""Populate the database with demo clusters and nodes."""
created = await seed_database(db)
return {"status": "seeded", "clusters": created["clusters"], "nodes": created["nodes"]}
# ---------------------------------------------------------------------------
# Re-export staking & chain endpoints from legacy nodes module
# ---------------------------------------------------------------------------
@router.get("/staking")
async def get_staking_data():
"""Proxy to legacy staking data endpoint."""
from bootnode.api.nodes import get_staking_data as _get_staking_data
return await _get_staking_data()
@router.get("/supported-chains")
async def get_supported_chains():
"""Proxy to legacy supported chains endpoint."""
from bootnode.api.nodes import get_supported_chains as _get_supported_chains
return await _get_supported_chains()
@router.get("/presets")
async def list_presets():
"""Proxy to legacy presets endpoint."""
from bootnode.api.nodes import list_presets as _list_presets
return await _list_presets()
@router.get("/{node_id}", response_model=NodeResponse)
async def get_node(node_id: str, db: AsyncSession = Depends(get_db)):
"""Get a single node by ID."""
result = await db.execute(select(Node).where(Node.id == uuid.UUID(node_id)))
node = result.scalar_one_or_none()
if not node:
raise HTTPException(status_code=404, detail="Node not found")
return _node_to_response(node)
@router.post("/", response_model=NodeResponse, status_code=201)
async def create_node(body: NodeCreate, db: AsyncSession = Depends(get_db)):
"""Create a new node."""
lat, lon = REGION_COORDS.get(body.region, (0.0, 0.0))
cluster_uuid = uuid.UUID(body.cluster_id) if body.cluster_id else None
node = Node(
name=body.name,
chain=body.chain.lower(),
network=body.network.lower(),
provider=body.provider,
status="provisioning",
region=body.region,
latitude=lat,
longitude=lon,
cluster_id=cluster_uuid,
node_type=body.node_type,
execution_client=body.execution_client,
consensus_client=body.consensus_client,
sync_mode=body.sync_mode,
image=body.image,
)
db.add(node)
await db.flush()
await db.refresh(node)
return _node_to_response(node)
@router.delete("/{node_id}")
async def delete_node(node_id: str, db: AsyncSession = Depends(get_db)):
"""Delete a node."""
result = await db.execute(select(Node).where(Node.id == uuid.UUID(node_id)))
node = result.scalar_one_or_none()
if not node:
raise HTTPException(status_code=404, detail="Node not found")
await db.delete(node)
await db.flush()
return {"status": "deleted", "id": node_id}
@router.post("/{node_id}/start", response_model=NodeResponse)
async def start_node(node_id: str, db: AsyncSession = Depends(get_db)):
"""Set node status to running."""
result = await db.execute(select(Node).where(Node.id == uuid.UUID(node_id)))
node = result.scalar_one_or_none()
if not node:
raise HTTPException(status_code=404, detail="Node not found")
node.status = "running"
await db.flush()
await db.refresh(node)
return _node_to_response(node)
@router.post("/{node_id}/stop", response_model=NodeResponse)
async def stop_node(node_id: str, db: AsyncSession = Depends(get_db)):
"""Set node status to stopped."""
result = await db.execute(select(Node).where(Node.id == uuid.UUID(node_id)))
node = result.scalar_one_or_none()
if not node:
raise HTTPException(status_code=404, detail="Node not found")
node.status = "stopped"
await db.flush()
await db.refresh(node)
return _node_to_response(node)
# ---------------------------------------------------------------------------
# Seed data
# ---------------------------------------------------------------------------
SEED_CLUSTERS = [
{
"name": "hanzo-k8s",
"provider": "digitalocean",
"provider_id": "1a153000-90a6-48ad-9375-7ef901a9bf7f",
"region": "sfo3",
"kubernetes_version": "1.31.1-do.4",
"endpoint": "https://1a153000-90a6-48ad-9375-7ef901a9bf7f.k8s.ondigitalocean.com",
"node_count": 3,
"node_size": "s-4vcpu-8gb",
},
{
"name": "lux-k8s",
"provider": "digitalocean",
"provider_id": "04c46df5-5986-429a-9c7f-3f11a81c8539",
"region": "sfo3",
"kubernetes_version": "1.31.1-do.4",
"endpoint": "https://04c46df5-5986-429a-9c7f-3f11a81c8539.k8s.ondigitalocean.com",
"node_count": 3,
"node_size": "s-4vcpu-8gb",
},
{
"name": "eu-west-1",
"provider": "digitalocean",
"region": "ams3",
"node_count": 2,
"node_size": "s-2vcpu-4gb",
},
{
"name": "ap-southeast-1",
"provider": "digitalocean",
"region": "sgp1",
"node_count": 2,
"node_size": "s-2vcpu-4gb",
},
]
SEED_NODES = [
# Lux ecosystem
{"name": "lux-mainnet-1", "chain": "lux", "network": "mainnet", "region": "sfo3", "node_type": "validator", "execution_client": "luxd", "status": "running", "sync_progress": 100.0, "peer_count": 42, "block_height": 18_420_000, "cpu_usage": 23.5, "memory_usage_mb": 2048.0, "disk_usage_gb": 120.5, "uptime_seconds": 864000},
{"name": "lux-mainnet-2", "chain": "lux", "network": "mainnet", "region": "ams3", "node_type": "rpc", "execution_client": "luxd", "status": "running", "sync_progress": 100.0, "peer_count": 38, "block_height": 18_420_000, "cpu_usage": 15.2, "memory_usage_mb": 1536.0, "disk_usage_gb": 95.3, "uptime_seconds": 604800},
{"name": "lux-testnet-1", "chain": "lux", "network": "testnet", "region": "sfo3", "node_type": "rpc", "execution_client": "luxd", "status": "running", "sync_progress": 100.0, "peer_count": 12, "block_height": 5_200_000},
# Ethereum
{"name": "eth-mainnet-1", "chain": "ethereum", "network": "mainnet", "region": "nyc1", "node_type": "archive", "execution_client": "geth", "consensus_client": "lighthouse", "status": "running", "sync_progress": 100.0, "peer_count": 55, "block_height": 21_500_000, "cpu_usage": 45.0, "memory_usage_mb": 8192.0, "disk_usage_gb": 2800.0, "uptime_seconds": 2592000},
{"name": "eth-mainnet-2", "chain": "ethereum", "network": "mainnet", "region": "fra1", "node_type": "full", "execution_client": "nethermind", "consensus_client": "prysm", "status": "syncing", "sync_progress": 87.3, "peer_count": 40, "block_height": 18_700_000, "cpu_usage": 78.0, "memory_usage_mb": 6144.0, "disk_usage_gb": 1200.0},
{"name": "eth-sepolia-1", "chain": "ethereum", "network": "sepolia", "region": "sfo3", "node_type": "rpc", "execution_client": "geth", "consensus_client": "lighthouse", "status": "running", "sync_progress": 100.0, "peer_count": 20, "block_height": 7_500_000},
# Bitcoin
{"name": "btc-mainnet-1", "chain": "bitcoin", "network": "mainnet", "region": "nyc3", "node_type": "full", "execution_client": "bitcoind", "status": "running", "sync_progress": 100.0, "peer_count": 125, "block_height": 880_000, "cpu_usage": 12.0, "memory_usage_mb": 4096.0, "disk_usage_gb": 650.0, "uptime_seconds": 5184000},
# Solana
{"name": "sol-mainnet-1", "chain": "solana", "network": "mainnet", "region": "sgp1", "node_type": "rpc", "execution_client": "solana", "status": "running", "sync_progress": 100.0, "peer_count": 200, "block_height": 310_000_000, "cpu_usage": 65.0, "memory_usage_mb": 32768.0, "disk_usage_gb": 2000.0, "uptime_seconds": 1728000},
# Cosmos
{"name": "cosmos-mainnet-1", "chain": "cosmos", "network": "mainnet", "region": "lon1", "node_type": "validator", "execution_client": "gaiad", "status": "running", "sync_progress": 100.0, "peer_count": 35, "block_height": 24_000_000, "cpu_usage": 18.0, "memory_usage_mb": 2048.0, "disk_usage_gb": 180.0, "uptime_seconds": 3456000},
# Polkadot
{"name": "dot-mainnet-1", "chain": "polkadot", "network": "mainnet", "region": "tor1", "node_type": "full", "execution_client": "polkadot", "status": "running", "sync_progress": 100.0, "peer_count": 45, "block_height": 25_000_000},
# Arbitrum
{"name": "arb-mainnet-1", "chain": "arbitrum", "network": "mainnet", "region": "nyc1", "node_type": "rpc", "execution_client": "nitro", "status": "syncing", "sync_progress": 62.1, "peer_count": 15, "block_height": 180_000_000},
# Near
{"name": "near-mainnet-1", "chain": "near", "network": "mainnet", "region": "blr1", "node_type": "rpc", "execution_client": "nearcore", "status": "running", "sync_progress": 100.0, "peer_count": 30, "block_height": 140_000_000},
# Stopped / error nodes for realism
{"name": "avax-mainnet-1", "chain": "avalanche", "network": "mainnet", "region": "syd1", "node_type": "full", "execution_client": "avalanchego", "status": "stopped"},
{"name": "sui-devnet-1", "chain": "sui", "network": "devnet", "region": "sgp1", "node_type": "rpc", "execution_client": "sui-node", "status": "error", "error": "Disk full: /data 100% used"},
]
async def seed_database(db: AsyncSession) -> Dict[str, int]:
"""Insert seed clusters and nodes if the tables are empty."""
# Check if nodes already exist
count_result = await db.execute(select(func.count()).select_from(Node))
existing = count_result.scalar() or 0
if existing > 0:
return {"clusters": 0, "nodes": 0}
# Create clusters first, keep a map of name -> id
cluster_map: Dict[str, uuid.UUID] = {}
for c in SEED_CLUSTERS:
cluster = Cluster(
name=c["name"],
provider=c.get("provider", "digitalocean"),
provider_id=c.get("provider_id"),
region=c.get("region", "sfo3"),
status="running",
kubernetes_version=c.get("kubernetes_version"),
endpoint=c.get("endpoint"),
node_count=c.get("node_count", 3),
node_size=c.get("node_size", "s-4vcpu-8gb"),
)
db.add(cluster)
await db.flush()
await db.refresh(cluster)
cluster_map[cluster.name] = cluster.id
# Assign nodes to clusters based on region
region_to_cluster = {
"sfo3": cluster_map.get("hanzo-k8s"),
"nyc1": cluster_map.get("hanzo-k8s"),
"nyc3": cluster_map.get("hanzo-k8s"),
"ams3": cluster_map.get("eu-west-1"),
"fra1": cluster_map.get("eu-west-1"),
"lon1": cluster_map.get("eu-west-1"),
"sgp1": cluster_map.get("ap-southeast-1"),
"blr1": cluster_map.get("ap-southeast-1"),
"syd1": cluster_map.get("ap-southeast-1"),
"tor1": cluster_map.get("hanzo-k8s"),
}
nodes_created = 0
for n in SEED_NODES:
region = n.get("region", "sfo3")
lat, lon = REGION_COORDS.get(region, (0.0, 0.0))
node = Node(
name=n["name"],
chain=n["chain"],
network=n.get("network", "mainnet"),
provider=n.get("provider", "kubernetes"),
status=n.get("status", "provisioning"),
region=region,
latitude=lat,
longitude=lon,
cluster_id=region_to_cluster.get(region),
node_type=n.get("node_type", "rpc"),
execution_client=n.get("execution_client"),
consensus_client=n.get("consensus_client"),
sync_mode=n.get("sync_mode"),
image=n.get("image"),
sync_progress=n.get("sync_progress"),
peer_count=n.get("peer_count"),
block_height=n.get("block_height"),
cpu_usage=n.get("cpu_usage"),
memory_usage_mb=n.get("memory_usage_mb"),
disk_usage_gb=n.get("disk_usage_gb"),
uptime_seconds=n.get("uptime_seconds"),
error=n.get("error"),
)
db.add(node)
nodes_created += 1
await db.flush()
return {"clusters": len(SEED_CLUSTERS), "nodes": nodes_created}
+310
View File
@@ -0,0 +1,310 @@
"""chain.yaml Pydantic models — single source of truth for chain ecosystem config.
Mirrors the Go structs in lux/cli/pkg/chainkit/schema.go.
Used by:
- POST /v1/launch (accept chain.yaml, generate + apply CRDs)
- Dashboard chain management UI
- CLI integration (lux chain launch)
The chain.yaml is the canonical declarative config for an entire blockchain
ecosystem: nodes, indexer, explorer, gateway, exchange, faucet.
"""
from __future__ import annotations
import re
import string
from typing import Any, Optional
import yaml
from pydantic import BaseModel, Field, field_validator, model_validator
# ---------------------------------------------------------------------------
# Sub-models
# ---------------------------------------------------------------------------
class ChainSpec(BaseModel):
"""Core chain identity and configuration."""
name: str
slug: str = Field(..., pattern=r"^[a-z][a-z0-9-]*$")
type: str = Field(default="l1", description="l1, l2, l3")
sequencer: str = "lux"
vm: str = "evm"
vm_plugins: list[VMPluginSpec] = Field(default_factory=list, alias="vmPlugins")
db_type: str = Field(default="zapdb", alias="dbType")
compression: str = "zstd"
model_config = {"populate_by_name": True}
@field_validator("type")
@classmethod
def validate_type(cls, v: str) -> str:
if v not in ("l1", "l2", "l3"):
raise ValueError(f"chain.type must be l1, l2, or l3, got {v!r}")
return v
class VMPluginSpec(BaseModel):
name: str
vm_id: str = Field(alias="vmId")
image: str = ""
model_config = {"populate_by_name": True}
class NetworkSpec(BaseModel):
"""Per-network configuration (mainnet, testnet, devnet)."""
network_id: int = Field(alias="networkId")
chain_id: int = Field(alias="chainId")
validators: int = 3
image_tag: str = Field(default="latest", alias="imageTag")
rpc_url: str = Field(default="", alias="rpcUrl")
ws_url: str = Field(default="", alias="wsUrl")
bootstrap_nodes: list[str] = Field(default_factory=list, alias="bootstrapNodes")
sybil_protection: Optional[bool] = Field(default=None, alias="sybilProtection")
snapshot_interval: int = Field(default=0, alias="snapshotInterval")
seed_restore_url: str = Field(default="", alias="seedRestoreUrl")
blockchain_id: str = Field(default="", alias="blockchainId")
model_config = {"populate_by_name": True}
class TokenSpec(BaseModel):
name: str = ""
symbol: str = ""
decimals: int = 18
supply: str = ""
class GenesisSpec(BaseModel):
file: str = ""
gas_limit: int = Field(default=0, alias="gasLimit")
min_base_fee: int = Field(default=0, alias="minBaseFee")
block_rate: int = Field(default=0, alias="blockRate")
model_config = {"populate_by_name": True}
class BrandDomainsSpec(BaseModel):
explorer: str = ""
exchange: str = ""
wallet: str = ""
faucet: str = ""
rpc: str = ""
docs: str = ""
class BrandSocialSpec(BaseModel):
twitter: str = ""
discord: str = ""
github: str = ""
class BrandSpec(BaseModel):
display_name: str = Field(default="", alias="displayName")
legal_entity: str = Field(default="", alias="legalEntity")
primary_color: str = Field(default="", alias="primaryColor")
logo: str = ""
domains: BrandDomainsSpec = Field(default_factory=BrandDomainsSpec)
social: BrandSocialSpec = Field(default_factory=BrandSocialSpec)
model_config = {"populate_by_name": True}
# -- Service specs --
class StakingKMSSpec(BaseModel):
host_api: str = Field(alias="hostApi")
project_slug: str = Field(alias="projectSlug")
env_slug: str = Field(alias="envSlug")
secrets_path: str = Field(default="/staking", alias="secretsPath")
model_config = {"populate_by_name": True}
class NodeServiceSpec(BaseModel):
enabled: bool = True
image: str = "ghcr.io/luxfi/node"
storage_size: str = Field(default="100Gi", alias="storageSize")
storage_class: str = Field(default="", alias="storageClass")
staking_kms: Optional[StakingKMSSpec] = Field(default=None, alias="stakingKms")
model_config = {"populate_by_name": True}
class IndexerServiceSpec(BaseModel):
enabled: bool = False
image: str = "ghcr.io/luxfi/indexer"
image_tag: str = Field(default="latest", alias="imageTag")
replicas: int = 1
trace_enabled: bool = Field(default=False, alias="traceEnabled")
contract_verification: bool = Field(default=False, alias="contractVerification")
poll_interval: int = Field(default=2, alias="pollInterval")
db_storage_size: str = Field(default="50Gi", alias="dbStorageSize")
model_config = {"populate_by_name": True}
class ExplorerServiceSpec(BaseModel):
enabled: bool = False
image: str = "ghcr.io/luxfi/explorer"
image_tag: str = Field(default="latest", alias="imageTag")
replicas: int = 1
ingress_class: str = Field(default="hanzo", alias="ingressClass")
port: int = 8090
data_storage_size: str = Field(default="20Gi", alias="dataStorageSize")
model_config = {"populate_by_name": True}
class GatewayServiceSpec(BaseModel):
enabled: bool = False
replicas: int = 2
rate_limit_rps: int = Field(default=100, alias="rateLimitRps")
rate_limit_burst: int = Field(default=200, alias="rateLimitBurst")
cors_allow_origins: str = Field(default="", alias="corsAllowOrigins")
model_config = {"populate_by_name": True}
class ExchangeServiceSpec(BaseModel):
enabled: bool = False
image: str = "ghcr.io/luxfi/exchange:main"
brand_package: str = Field(default="", alias="brandPackage")
model_config = {"populate_by_name": True}
class WalletServiceSpec(BaseModel):
enabled: bool = False
class FaucetServiceSpec(BaseModel):
enabled: bool = False
drip_amount: str = Field(default="1000000000000000000", alias="dripAmount")
rate_limit: str = Field(default="1/hour", alias="rateLimit")
model_config = {"populate_by_name": True}
class ServicesSpec(BaseModel):
node: NodeServiceSpec = Field(default_factory=NodeServiceSpec)
indexer: IndexerServiceSpec = Field(default_factory=IndexerServiceSpec)
explorer: ExplorerServiceSpec = Field(default_factory=ExplorerServiceSpec)
gateway: GatewayServiceSpec = Field(default_factory=GatewayServiceSpec)
exchange: ExchangeServiceSpec = Field(default_factory=ExchangeServiceSpec)
wallet: WalletServiceSpec = Field(default_factory=WalletServiceSpec)
faucet: FaucetServiceSpec = Field(default_factory=FaucetServiceSpec)
class PrecompileSpec(BaseModel):
name: str
block_timestamp: int = Field(default=0, alias="blockTimestamp")
model_config = {"populate_by_name": True}
class DeploySpec(BaseModel):
platform: str = "hanzo"
namespace: str = "{slug}-{network}"
registry: str = ""
secrets_provider: str = Field(default="kms.hanzo.ai", alias="secretsProvider")
ingress_class: str = Field(default="hanzo", alias="ingressClass")
model_config = {"populate_by_name": True}
# ---------------------------------------------------------------------------
# Top-level ChainConfig
# ---------------------------------------------------------------------------
class ChainConfig(BaseModel):
"""Top-level chain.yaml configuration.
This is the single source of truth for an entire blockchain ecosystem.
It maps directly to the Go ChainConfig in lux/cli/pkg/chainkit/schema.go.
"""
version: str = "1"
chain: ChainSpec
networks: dict[str, NetworkSpec]
token: TokenSpec = Field(default_factory=TokenSpec)
genesis: GenesisSpec = Field(default_factory=GenesisSpec)
brand: BrandSpec = Field(default_factory=BrandSpec)
services: ServicesSpec = Field(default_factory=ServicesSpec)
deploy: DeploySpec = Field(default_factory=DeploySpec)
precompiles: list[PrecompileSpec] = Field(default_factory=list)
@model_validator(mode="after")
def apply_defaults(self) -> ChainConfig:
"""Fill in defaults that depend on other fields."""
if not self.brand.display_name:
self.brand.display_name = self.chain.name
if not self.deploy.registry:
self.deploy.registry = f"ghcr.io/{self.chain.slug}"
return self
def namespace_for(self, network: str) -> str:
"""Generate K8s namespace for a given network."""
tpl = self.deploy.namespace
return tpl.replace("{slug}", self.chain.slug).replace("{network}", network)
def validate_config(self) -> list[str]:
"""Validate the config beyond basic schema. Returns list of errors."""
errors: list[str] = []
if not self.chain.slug:
errors.append("chain.slug is required")
if not self.networks:
errors.append("at least one network must be defined")
if self.deploy.ingress_class in ("nginx", "caddy"):
errors.append(
f"deploy.ingressClass={self.deploy.ingress_class!r} is not allowed; "
"use hanzo ingress only"
)
for name, net in self.networks.items():
if net.chain_id == 0:
errors.append(f"networks.{name}.chainId is required")
if net.validators < 1:
errors.append(f"networks.{name}.validators must be >= 1")
return errors
# ---------------------------------------------------------------------------
# Loader
# ---------------------------------------------------------------------------
def load_chain_config(raw_yaml: str) -> ChainConfig:
"""Parse chain.yaml content into a ChainConfig.
Args:
raw_yaml: Raw YAML string content of chain.yaml.
Returns:
Validated ChainConfig instance.
Raises:
ValueError: If YAML is invalid or validation fails.
"""
try:
data = yaml.safe_load(raw_yaml)
except yaml.YAMLError as e:
raise ValueError(f"Invalid YAML: {e}") from e
if not isinstance(data, dict):
raise ValueError("chain.yaml must be a YAML mapping")
return ChainConfig(**data)
def load_chain_config_file(path: str) -> ChainConfig:
"""Load and parse a chain.yaml file from disk."""
with open(path) as f:
return load_chain_config(f.read())
+637
View File
@@ -13,6 +13,7 @@ class ChainType(str, Enum):
EVM = "evm"
SOLANA = "solana"
BITCOIN = "bitcoin"
STARKNET = "starknet"
@dataclass
@@ -320,6 +321,642 @@ class ChainRegistry:
},
)
# -----------------------------------------------------------
# L1 Chains
# -----------------------------------------------------------
# Gnosis (formerly xDai)
cls._chains["gnosis"] = Chain(
name="Gnosis",
slug="gnosis",
chain_type=ChainType.EVM,
networks={
"mainnet": Network(
name="Mainnet",
chain_id=100,
is_testnet=False,
rpc_env_var="gnosis_mainnet_rpc",
explorer_url="https://gnosisscan.io",
native_currency="xDAI",
),
"chiado": Network(
name="Chiado",
chain_id=10200,
is_testnet=True,
rpc_env_var="gnosis_chiado_rpc",
explorer_url="https://gnosis-chiado.blockscout.com",
native_currency="xDAI",
),
},
)
# Celo
cls._chains["celo"] = Chain(
name="Celo",
slug="celo",
chain_type=ChainType.EVM,
networks={
"mainnet": Network(
name="Mainnet",
chain_id=42220,
is_testnet=False,
rpc_env_var="celo_mainnet_rpc",
explorer_url="https://celoscan.io",
native_currency="CELO",
),
"alfajores": Network(
name="Alfajores",
chain_id=44787,
is_testnet=True,
rpc_env_var="celo_alfajores_rpc",
explorer_url="https://alfajores.celoscan.io",
native_currency="CELO",
),
},
)
# Fantom
cls._chains["fantom"] = Chain(
name="Fantom",
slug="fantom",
chain_type=ChainType.EVM,
networks={
"mainnet": Network(
name="Opera",
chain_id=250,
is_testnet=False,
rpc_env_var="fantom_mainnet_rpc",
explorer_url="https://ftmscan.com",
native_currency="FTM",
),
"testnet": Network(
name="Testnet",
chain_id=4002,
is_testnet=True,
rpc_env_var="fantom_testnet_rpc",
explorer_url="https://testnet.ftmscan.com",
native_currency="FTM",
),
},
)
# Cronos
cls._chains["cronos"] = Chain(
name="Cronos",
slug="cronos",
chain_type=ChainType.EVM,
networks={
"mainnet": Network(
name="Mainnet",
chain_id=25,
is_testnet=False,
rpc_env_var="cronos_mainnet_rpc",
explorer_url="https://cronoscan.com",
native_currency="CRO",
),
"testnet": Network(
name="Testnet",
chain_id=338,
is_testnet=True,
rpc_env_var="cronos_testnet_rpc",
explorer_url="https://testnet.cronoscan.com",
native_currency="CRO",
),
},
)
# Moonbeam
cls._chains["moonbeam"] = Chain(
name="Moonbeam",
slug="moonbeam",
chain_type=ChainType.EVM,
networks={
"mainnet": Network(
name="Mainnet",
chain_id=1284,
is_testnet=False,
rpc_env_var="moonbeam_mainnet_rpc",
explorer_url="https://moonscan.io",
native_currency="GLMR",
),
"moonbase": Network(
name="Moonbase Alpha",
chain_id=1287,
is_testnet=True,
rpc_env_var="moonbeam_moonbase_rpc",
explorer_url="https://moonbase.moonscan.io",
native_currency="DEV",
),
},
)
# Moonriver
cls._chains["moonriver"] = Chain(
name="Moonriver",
slug="moonriver",
chain_type=ChainType.EVM,
networks={
"mainnet": Network(
name="Mainnet",
chain_id=1285,
is_testnet=False,
rpc_env_var="moonriver_mainnet_rpc",
explorer_url="https://moonriver.moonscan.io",
native_currency="MOVR",
),
},
)
# Harmony
cls._chains["harmony"] = Chain(
name="Harmony",
slug="harmony",
chain_type=ChainType.EVM,
networks={
"mainnet": Network(
name="Mainnet Shard 0",
chain_id=1666600000,
is_testnet=False,
rpc_env_var="harmony_mainnet_rpc",
explorer_url="https://explorer.harmony.one",
native_currency="ONE",
),
"testnet": Network(
name="Testnet Shard 0",
chain_id=1666700000,
is_testnet=True,
rpc_env_var="harmony_testnet_rpc",
explorer_url="https://explorer.testnet.harmony.one",
native_currency="ONE",
),
},
)
# Rootstock (RSK)
cls._chains["rootstock"] = Chain(
name="Rootstock",
slug="rootstock",
chain_type=ChainType.EVM,
networks={
"mainnet": Network(
name="Mainnet",
chain_id=30,
is_testnet=False,
rpc_env_var="rootstock_mainnet_rpc",
explorer_url="https://explorer.rsk.co",
native_currency="RBTC",
),
"testnet": Network(
name="Testnet",
chain_id=31,
is_testnet=True,
rpc_env_var="rootstock_testnet_rpc",
explorer_url="https://explorer.testnet.rsk.co",
native_currency="tRBTC",
),
},
)
# Fuse
cls._chains["fuse"] = Chain(
name="Fuse",
slug="fuse",
chain_type=ChainType.EVM,
networks={
"mainnet": Network(
name="Mainnet",
chain_id=122,
is_testnet=False,
rpc_env_var="fuse_mainnet_rpc",
explorer_url="https://explorer.fuse.io",
native_currency="FUSE",
),
"spark": Network(
name="Spark",
chain_id=123,
is_testnet=True,
rpc_env_var="fuse_spark_rpc",
explorer_url="https://explorer.fusespark.io",
native_currency="FUSE",
),
},
)
# Metis
cls._chains["metis"] = Chain(
name="Metis",
slug="metis",
chain_type=ChainType.EVM,
networks={
"mainnet": Network(
name="Andromeda",
chain_id=1088,
is_testnet=False,
rpc_env_var="metis_mainnet_rpc",
explorer_url="https://andromeda-explorer.metis.io",
native_currency="METIS",
),
"sepolia": Network(
name="Sepolia",
chain_id=59902,
is_testnet=True,
rpc_env_var="metis_sepolia_rpc",
explorer_url="https://sepolia-explorer.metisdevops.link",
native_currency="tMETIS",
),
},
)
# -----------------------------------------------------------
# L2 / Rollups
# -----------------------------------------------------------
# zkSync Era
cls._chains["zksync"] = Chain(
name="zkSync Era",
slug="zksync",
chain_type=ChainType.EVM,
networks={
"mainnet": Network(
name="Mainnet",
chain_id=324,
is_testnet=False,
rpc_env_var="zksync_mainnet_rpc",
explorer_url="https://explorer.zksync.io",
),
"sepolia": Network(
name="Sepolia",
chain_id=300,
is_testnet=True,
rpc_env_var="zksync_sepolia_rpc",
explorer_url="https://sepolia.explorer.zksync.io",
),
},
)
# Scroll
cls._chains["scroll"] = Chain(
name="Scroll",
slug="scroll",
chain_type=ChainType.EVM,
networks={
"mainnet": Network(
name="Mainnet",
chain_id=534352,
is_testnet=False,
rpc_env_var="scroll_mainnet_rpc",
explorer_url="https://scrollscan.com",
),
"sepolia": Network(
name="Sepolia",
chain_id=534351,
is_testnet=True,
rpc_env_var="scroll_sepolia_rpc",
explorer_url="https://sepolia.scrollscan.com",
),
},
)
# Linea
cls._chains["linea"] = Chain(
name="Linea",
slug="linea",
chain_type=ChainType.EVM,
networks={
"mainnet": Network(
name="Mainnet",
chain_id=59144,
is_testnet=False,
rpc_env_var="linea_mainnet_rpc",
explorer_url="https://lineascan.build",
),
"sepolia": Network(
name="Sepolia",
chain_id=59141,
is_testnet=True,
rpc_env_var="linea_sepolia_rpc",
explorer_url="https://sepolia.lineascan.build",
),
},
)
# Polygon zkEVM
cls._chains["polygon-zkevm"] = Chain(
name="Polygon zkEVM",
slug="polygon-zkevm",
chain_type=ChainType.EVM,
networks={
"mainnet": Network(
name="Mainnet",
chain_id=1101,
is_testnet=False,
rpc_env_var="polygon_zkevm_mainnet_rpc",
explorer_url="https://zkevm.polygonscan.com",
native_currency="ETH",
),
"testnet": Network(
name="Cardona",
chain_id=2442,
is_testnet=True,
rpc_env_var="polygon_zkevm_testnet_rpc",
explorer_url="https://cardona-zkevm.polygonscan.com",
native_currency="ETH",
),
},
)
# Blast
cls._chains["blast"] = Chain(
name="Blast",
slug="blast",
chain_type=ChainType.EVM,
networks={
"mainnet": Network(
name="Mainnet",
chain_id=81457,
is_testnet=False,
rpc_env_var="blast_mainnet_rpc",
explorer_url="https://blastscan.io",
),
"sepolia": Network(
name="Sepolia",
chain_id=168587773,
is_testnet=True,
rpc_env_var="blast_sepolia_rpc",
explorer_url="https://sepolia.blastscan.io",
),
},
)
# Mantle
cls._chains["mantle"] = Chain(
name="Mantle",
slug="mantle",
chain_type=ChainType.EVM,
networks={
"mainnet": Network(
name="Mainnet",
chain_id=5000,
is_testnet=False,
rpc_env_var="mantle_mainnet_rpc",
explorer_url="https://mantlescan.xyz",
native_currency="MNT",
),
"sepolia": Network(
name="Sepolia",
chain_id=5003,
is_testnet=True,
rpc_env_var="mantle_sepolia_rpc",
explorer_url="https://sepolia.mantlescan.xyz",
native_currency="MNT",
),
},
)
# Mode
cls._chains["mode"] = Chain(
name="Mode",
slug="mode",
chain_type=ChainType.EVM,
networks={
"mainnet": Network(
name="Mainnet",
chain_id=34443,
is_testnet=False,
rpc_env_var="mode_mainnet_rpc",
explorer_url="https://explorer.mode.network",
),
"sepolia": Network(
name="Sepolia",
chain_id=919,
is_testnet=True,
rpc_env_var="mode_sepolia_rpc",
explorer_url="https://sepolia.explorer.mode.network",
),
},
)
# Zora
cls._chains["zora"] = Chain(
name="Zora",
slug="zora",
chain_type=ChainType.EVM,
networks={
"mainnet": Network(
name="Mainnet",
chain_id=7777777,
is_testnet=False,
rpc_env_var="zora_mainnet_rpc",
explorer_url="https://explorer.zora.energy",
),
"sepolia": Network(
name="Sepolia",
chain_id=999999999,
is_testnet=True,
rpc_env_var="zora_sepolia_rpc",
explorer_url="https://sepolia.explorer.zora.energy",
),
},
)
# World Chain
cls._chains["worldchain"] = Chain(
name="World Chain",
slug="worldchain",
chain_type=ChainType.EVM,
networks={
"mainnet": Network(
name="Mainnet",
chain_id=480,
is_testnet=False,
rpc_env_var="worldchain_mainnet_rpc",
explorer_url="https://worldscan.org",
native_currency="ETH",
),
"sepolia": Network(
name="Sepolia",
chain_id=4801,
is_testnet=True,
rpc_env_var="worldchain_sepolia_rpc",
explorer_url="https://sepolia.worldscan.org",
native_currency="ETH",
),
},
)
# Abstract
cls._chains["abstract"] = Chain(
name="Abstract",
slug="abstract",
chain_type=ChainType.EVM,
networks={
"mainnet": Network(
name="Mainnet",
chain_id=2741,
is_testnet=False,
rpc_env_var="abstract_mainnet_rpc",
explorer_url="https://abscan.org",
),
"testnet": Network(
name="Testnet",
chain_id=11124,
is_testnet=True,
rpc_env_var="abstract_testnet_rpc",
explorer_url="https://testnet.abscan.org",
),
},
)
# Starknet (non-EVM)
cls._chains["starknet"] = Chain(
name="Starknet",
slug="starknet",
chain_type=ChainType.STARKNET,
networks={
"mainnet": Network(
name="Mainnet",
chain_id=None,
is_testnet=False,
rpc_env_var="starknet_mainnet_rpc",
explorer_url="https://starkscan.co",
native_currency="ETH",
),
"sepolia": Network(
name="Sepolia",
chain_id=None,
is_testnet=True,
rpc_env_var="starknet_sepolia_rpc",
explorer_url="https://sepolia.starkscan.co",
native_currency="ETH",
),
},
)
# -----------------------------------------------------------
# Other EVM Chains
# -----------------------------------------------------------
# Canto
cls._chains["canto"] = Chain(
name="Canto",
slug="canto",
chain_type=ChainType.EVM,
networks={
"mainnet": Network(
name="Mainnet",
chain_id=7700,
is_testnet=False,
rpc_env_var="canto_mainnet_rpc",
explorer_url="https://cantoscan.com",
native_currency="CANTO",
),
"testnet": Network(
name="Testnet",
chain_id=7701,
is_testnet=True,
rpc_env_var="canto_testnet_rpc",
explorer_url="https://testnet.cantoscan.com",
native_currency="CANTO",
),
},
)
# Klaytn / Kaia
cls._chains["kaia"] = Chain(
name="Kaia",
slug="kaia",
chain_type=ChainType.EVM,
networks={
"mainnet": Network(
name="Mainnet",
chain_id=8217,
is_testnet=False,
rpc_env_var="kaia_mainnet_rpc",
explorer_url="https://kaiascan.io",
native_currency="KAIA",
),
"kairos": Network(
name="Kairos",
chain_id=1001,
is_testnet=True,
rpc_env_var="kaia_kairos_rpc",
explorer_url="https://kairos.kaiascan.io",
native_currency="KAIA",
),
},
)
# Aurora (NEAR EVM)
cls._chains["aurora"] = Chain(
name="Aurora",
slug="aurora",
chain_type=ChainType.EVM,
networks={
"mainnet": Network(
name="Mainnet",
chain_id=1313161554,
is_testnet=False,
rpc_env_var="aurora_mainnet_rpc",
explorer_url="https://explorer.aurora.dev",
),
"testnet": Network(
name="Testnet",
chain_id=1313161555,
is_testnet=True,
rpc_env_var="aurora_testnet_rpc",
explorer_url="https://testnet.explorer.aurora.dev",
),
},
)
# Boba
cls._chains["boba"] = Chain(
name="Boba",
slug="boba",
chain_type=ChainType.EVM,
networks={
"mainnet": Network(
name="Mainnet",
chain_id=288,
is_testnet=False,
rpc_env_var="boba_mainnet_rpc",
explorer_url="https://bobascan.com",
),
"sepolia": Network(
name="Sepolia",
chain_id=28882,
is_testnet=True,
rpc_env_var="boba_sepolia_rpc",
explorer_url="https://sepolia.bobascan.com",
),
},
)
# Astar
cls._chains["astar"] = Chain(
name="Astar",
slug="astar",
chain_type=ChainType.EVM,
networks={
"mainnet": Network(
name="Mainnet",
chain_id=592,
is_testnet=False,
rpc_env_var="astar_mainnet_rpc",
explorer_url="https://astar.blockscout.com",
native_currency="ASTR",
),
"shibuya": Network(
name="Shibuya",
chain_id=81,
is_testnet=True,
rpc_env_var="astar_shibuya_rpc",
explorer_url="https://shibuya.blockscout.com",
native_currency="SBY",
),
},
)
cls._initialized = True
@classmethod
+271
View File
@@ -0,0 +1,271 @@
"""
FHE (Fully Homomorphic Encryption) module for Bootnode.
Provides encrypted balance computation for confidential wallet operations.
Uses AES-GCM for practical encrypted storage with homomorphic-style API.
Can optionally use Zama's concrete-python for true FHE when available.
Usage:
from bootnode.core.fhe import FHEContext, EncryptedBalance
ctx = FHEContext.create()
enc_balance = ctx.encrypt(1000)
enc_result = ctx.add(enc_balance, ctx.encrypt(500))
assert ctx.decrypt(enc_result) == 1500
"""
from __future__ import annotations
import base64
import hashlib
import os
import struct
from dataclasses import dataclass, field
from typing import Any
import structlog
logger = structlog.get_logger()
# Try to import real TFHE backend
_HAS_CONCRETE = False
try:
import concrete.numpy as cnp # noqa: F401
_HAS_CONCRETE = True
logger.info("fhe.backend", backend="concrete-python")
except ImportError:
logger.debug("fhe.backend", backend="aes-gcm-simulation")
@dataclass
class EncryptedValue:
"""An encrypted numeric value."""
ciphertext: bytes
nonce: bytes
tag: str = "encrypted_uint64"
metadata: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"ciphertext": base64.b64encode(self.ciphertext).decode(),
"nonce": base64.b64encode(self.nonce).decode(),
"tag": self.tag,
"metadata": self.metadata,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> EncryptedValue:
return cls(
ciphertext=base64.b64decode(data["ciphertext"]),
nonce=base64.b64decode(data["nonce"]),
tag=data.get("tag", "encrypted_uint64"),
metadata=data.get("metadata", {}),
)
class FHEContext:
"""FHE encryption context for confidential operations."""
def __init__(self, key: bytes) -> None:
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
self._key = key
self._aesgcm = AESGCM(key)
@classmethod
def create(cls, secret: str | None = None) -> FHEContext:
"""Create a new FHE context with a derived or random key."""
key = (
hashlib.sha256(secret.encode()).digest()
if secret
else os.urandom(32)
)
return cls(key)
@classmethod
def from_env(cls) -> FHEContext:
"""Create context from FHE_ENCRYPTION_KEY environment variable."""
secret = os.getenv("FHE_ENCRYPTION_KEY", "bootnode-fhe-dev-key")
return cls.create(secret)
def encrypt(self, value: int | float) -> EncryptedValue:
"""Encrypt a numeric value (int64 or float64)."""
if isinstance(value, float):
packed = struct.pack("<d", value)
tag = "encrypted_float64"
else:
packed = struct.pack("<q", value)
tag = "encrypted_int64"
nonce = os.urandom(12)
ct = self._aesgcm.encrypt(nonce, packed, None)
return EncryptedValue(ciphertext=ct, nonce=nonce, tag=tag)
def decrypt(self, encrypted: EncryptedValue) -> int | float:
"""Decrypt an encrypted value back to int or float."""
plaintext = self._aesgcm.decrypt(
encrypted.nonce, encrypted.ciphertext, None
)
if encrypted.tag == "encrypted_float64":
return struct.unpack("<d", plaintext)[0]
return struct.unpack("<q", plaintext)[0]
def add(
self, a: EncryptedValue, b: EncryptedValue
) -> EncryptedValue:
"""Homomorphic addition: decrypt, add, re-encrypt."""
val_a = self.decrypt(a)
val_b = self.decrypt(b)
if isinstance(val_a, float) or isinstance(val_b, float):
return self.encrypt(float(val_a) + float(val_b))
return self.encrypt(val_a + val_b)
def subtract(
self, a: EncryptedValue, b: EncryptedValue
) -> EncryptedValue:
"""Homomorphic subtraction: decrypt, subtract, re-encrypt."""
val_a = self.decrypt(a)
val_b = self.decrypt(b)
if isinstance(val_a, float) or isinstance(val_b, float):
return self.encrypt(float(val_a) - float(val_b))
return self.encrypt(val_a - val_b)
def multiply(
self, a: EncryptedValue, scalar: int
) -> EncryptedValue:
"""Scalar multiplication on an encrypted value."""
val = self.decrypt(a)
if isinstance(val, float):
return self.encrypt(val * scalar)
return self.encrypt(val * scalar)
def compare_gt(
self, a: EncryptedValue, b: EncryptedValue
) -> bool:
"""Encrypted comparison: a > b."""
return self.decrypt(a) > self.decrypt(b)
def compare_gte(
self, a: EncryptedValue, b: EncryptedValue
) -> bool:
"""Encrypted comparison: a >= b."""
return self.decrypt(a) >= self.decrypt(b)
class EncryptedBalance:
"""Encrypted wallet balance with confidential operations."""
def __init__(
self,
ctx: FHEContext,
address: str,
chain: str = "ethereum",
) -> None:
self.ctx = ctx
self.address = address
self.chain = chain
self._balance: EncryptedValue | None = None
self._staked: EncryptedValue | None = None
self._rewards: EncryptedValue | None = None
def set_balance(self, amount_wei: int) -> EncryptedValue:
"""Set and encrypt the balance."""
self._balance = self.ctx.encrypt(amount_wei)
return self._balance
def set_staked(self, amount_wei: int) -> EncryptedValue:
"""Set and encrypt staked amount."""
self._staked = self.ctx.encrypt(amount_wei)
return self._staked
def set_rewards(self, amount_wei: int) -> EncryptedValue:
"""Set and encrypt rewards."""
self._rewards = self.ctx.encrypt(amount_wei)
return self._rewards
def get_total(self) -> EncryptedValue:
"""Get encrypted total (balance + staked + rewards)."""
total = self._balance or self.ctx.encrypt(0)
if self._staked:
total = self.ctx.add(total, self._staked)
if self._rewards:
total = self.ctx.add(total, self._rewards)
return total
def can_stake(self, amount_wei: int) -> bool:
"""Check if balance is sufficient for staking."""
if not self._balance:
return False
required = self.ctx.encrypt(amount_wei)
return self.ctx.compare_gte(self._balance, required)
def stake(
self, amount_wei: int
) -> tuple[EncryptedValue, EncryptedValue]:
"""Stake tokens: subtract from balance, add to staked.
Returns:
Tuple of (new_balance, new_staked) encrypted values.
Raises:
ValueError: If no balance is set or balance is insufficient.
"""
if not self._balance:
raise ValueError("No balance set")
amount_enc = self.ctx.encrypt(amount_wei)
if not self.ctx.compare_gte(self._balance, amount_enc):
raise ValueError("Insufficient balance")
self._balance = self.ctx.subtract(self._balance, amount_enc)
if self._staked:
self._staked = self.ctx.add(self._staked, amount_enc)
else:
self._staked = amount_enc
return self._balance, self._staked
def to_dict(self) -> dict[str, Any]:
"""Serialize encrypted balances."""
return {
"address": self.address,
"chain": self.chain,
"balance": (
self._balance.to_dict() if self._balance else None
),
"staked": (
self._staked.to_dict() if self._staked else None
),
"rewards": (
self._rewards.to_dict() if self._rewards else None
),
}
def decrypt_all(self) -> dict[str, Any]:
"""Decrypt all balances (privileged operation)."""
return {
"address": self.address,
"chain": self.chain,
"balance_wei": (
self.ctx.decrypt(self._balance)
if self._balance
else 0
),
"staked_wei": (
self.ctx.decrypt(self._staked)
if self._staked
else 0
),
"rewards_wei": (
self.ctx.decrypt(self._rewards)
if self._rewards
else 0
),
}
def has_tfhe_backend() -> bool:
"""Check if real TFHE backend is available."""
return _HAS_CONCRETE
+249
View File
@@ -0,0 +1,249 @@
"""
MPC Wallet - Threshold ECDSA with Shamir's Secret Sharing.
Implements 2-of-3 (configurable) threshold signing using:
- Shamir's Secret Sharing for key splitting
- eth-account for ECDSA operations
- Hanzo KMS for encrypted share storage
Usage:
from bootnode.core.mpc import MPCWallet
wallet = MPCWallet.generate(threshold=2, num_shares=3)
signature = wallet.sign(message_hash, shares=[share1, share2])
"""
import hashlib
import secrets
from dataclasses import dataclass
from eth_account import Account
from eth_account.messages import encode_defunct
# Shamir's Secret Sharing over secp256k1 order
SECP256K1_ORDER = (
0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
)
@dataclass
class KeyShare:
index: int # Share index (1-based, never 0)
value: str # Hex-encoded share value
@dataclass
class MPCKeyPair:
address: str
public_key: str
threshold: int
num_shares: int
shares: list[KeyShare]
class ShamirSecretSharing:
"""Shamir's Secret Sharing over the secp256k1 field."""
@staticmethod
def _mod_inverse(a: int, p: int) -> int:
"""Modular inverse using Fermat's little theorem."""
return pow(a, p - 2, p)
@staticmethod
def split(
secret: int, threshold: int, num_shares: int
) -> list[KeyShare]:
"""Split a secret into shares using polynomial interpolation."""
assert 2 <= threshold <= num_shares
assert 0 < secret < SECP256K1_ORDER
# Generate random polynomial coefficients (a_1 ... a_{t-1})
coefficients = [secret] # a_0 = secret
for _ in range(threshold - 1):
coefficients.append(
secrets.randbelow(SECP256K1_ORDER - 1) + 1
)
# Evaluate polynomial at points 1..num_shares
shares = []
for i in range(1, num_shares + 1):
y = 0
for j, coeff in enumerate(coefficients):
y = (
y + coeff * pow(i, j, SECP256K1_ORDER)
) % SECP256K1_ORDER
shares.append(KeyShare(index=i, value=hex(y)))
return shares
@staticmethod
def reconstruct(shares: list[KeyShare], threshold: int) -> int:
"""Reconstruct secret using Lagrange interpolation."""
assert len(shares) >= threshold
# Use only threshold shares
used = shares[:threshold]
xs = [s.index for s in used]
ys = [int(s.value, 16) for s in used]
secret = 0
for i in range(len(xs)):
# Lagrange basis polynomial
numerator = 1
denominator = 1
for j in range(len(xs)):
if i != j:
numerator = (
numerator * (0 - xs[j])
) % SECP256K1_ORDER
denominator = (
denominator * (xs[i] - xs[j])
) % SECP256K1_ORDER
lagrange = (
numerator
* ShamirSecretSharing._mod_inverse(
denominator, SECP256K1_ORDER
)
) % SECP256K1_ORDER
secret = (secret + ys[i] * lagrange) % SECP256K1_ORDER
return secret
class MPCWallet:
"""Threshold ECDSA wallet using Shamir's Secret Sharing."""
def __init__(
self,
address: str,
public_key: str,
threshold: int,
num_shares: int,
):
self.address = address
self.public_key = public_key
self.threshold = threshold
self.num_shares = num_shares
@classmethod
def generate(
cls, threshold: int = 2, num_shares: int = 3
) -> MPCKeyPair:
"""Generate a new MPC wallet with key shares."""
account = Account.create()
private_key_int = int(account.key.hex(), 16)
# Split into shares
shares = ShamirSecretSharing.split(
private_key_int, threshold, num_shares
)
return MPCKeyPair(
address=account.address,
# Shares replace the raw key; public_key stored for reference
public_key=account.key.hex(),
threshold=threshold,
num_shares=num_shares,
shares=shares,
)
@classmethod
def from_shares(
cls,
shares: list[KeyShare],
threshold: int,
address: str = "",
) -> "MPCWallet":
"""Reconstruct wallet from shares for signing."""
private_key_int = ShamirSecretSharing.reconstruct(
shares, threshold
)
private_key_hex = hex(private_key_int)
account = Account.from_key(private_key_hex)
if address and account.address.lower() != address.lower():
raise ValueError(
"Reconstructed key does not match expected address"
)
wallet = cls(
address=account.address,
public_key=account.address, # Don't expose private key
threshold=threshold,
num_shares=len(shares),
)
wallet._account = account
return wallet
def sign_message(self, message: str) -> dict:
"""Sign a message with the reconstructed key."""
if not hasattr(self, "_account"):
raise RuntimeError("Wallet not reconstructed from shares")
msg = encode_defunct(text=message)
signed = self._account.sign_message(msg)
return {
"message": message,
"signature": signed.signature.hex(),
"r": hex(signed.r),
"s": hex(signed.s),
"v": signed.v,
"signer": self.address,
}
def sign_transaction(self, tx: dict) -> dict:
"""Sign a transaction with the reconstructed key."""
if not hasattr(self, "_account"):
raise RuntimeError("Wallet not reconstructed from shares")
signed = self._account.sign_transaction(tx)
return {
"raw_transaction": signed.raw_transaction.hex(),
"hash": signed.hash.hex(),
"r": hex(signed.r),
"s": hex(signed.s),
"v": signed.v,
}
def sign_hash(self, message_hash: bytes) -> dict:
"""Sign a raw message hash."""
if not hasattr(self, "_account"):
raise RuntimeError("Wallet not reconstructed from shares")
signed = self._account.unsafe_sign_hash(message_hash)
return {
"signature": signed.signature.hex(),
"r": hex(signed.r),
"s": hex(signed.s),
"v": signed.v,
"signer": self.address,
}
def encrypt_share(share_value: str, encryption_key: str) -> str:
"""Encrypt a key share for storage using AES-GCM."""
import base64
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
# Derive 256-bit AES key from encryption_key
key = hashlib.sha256(encryption_key.encode()).digest()
nonce = os.urandom(12)
aesgcm = AESGCM(key)
ct = aesgcm.encrypt(nonce, share_value.encode(), None)
return base64.b64encode(nonce + ct).decode()
def decrypt_share(encrypted: str, encryption_key: str) -> str:
"""Decrypt a key share from storage."""
import base64
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
key = hashlib.sha256(encryption_key.encode()).digest()
data = base64.b64decode(encrypted)
nonce, ct = data[:12], data[12:]
aesgcm = AESGCM(key)
return aesgcm.decrypt(nonce, ct, None).decode()
+24 -2
View File
@@ -1,6 +1,28 @@
"""Database module."""
from bootnode.db.models import ApiKey, Base, Project, Usage, Webhook
from bootnode.db.models import (
ApiKey,
Base,
Cluster,
MPCWalletModel,
Node,
Project,
Usage,
Webhook,
)
from bootnode.db.session import async_session, engine, get_db, init_db
__all__ = ["get_db", "engine", "async_session", "init_db", "Base", "Project", "ApiKey", "Webhook", "Usage"]
__all__ = [
"get_db",
"engine",
"async_session",
"init_db",
"Base",
"Cluster",
"Node",
"Project",
"ApiKey",
"MPCWalletModel",
"Webhook",
"Usage",
]
+200
View File
@@ -0,0 +1,200 @@
"""Seed data for real Lux Network validator nodes on DOKS clusters."""
import random
import uuid
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from bootnode.db.models import Cluster, Node
# Reproducible randomness
random.seed(42)
# ---------------------------------------------------------------------------
# Cluster definitions -- real DOKS clusters in DigitalOcean SFO3
# ---------------------------------------------------------------------------
SEED_CLUSTERS: list[dict] = [
{
"name": "hanzo-k8s",
"provider": "digitalocean",
"provider_id": "1a153000-90a6-48ad-9375-7ef901a9bf7f",
"region": "sfo3",
"status": "running",
"kubernetes_version": "1.31",
"endpoint": "https://1a153000-90a6-48ad-9375-7ef901a9bf7f.k8s.ondigitalocean.com",
"node_count": 6,
"node_size": "s-4vcpu-8gb",
},
{
"name": "lux-k8s",
"provider": "digitalocean",
"provider_id": "04c46df5-5986-429a-9c7f-3f11a81c8539",
"region": "sfo3",
"status": "running",
"kubernetes_version": "1.31",
"endpoint": "https://04c46df5-5986-429a-9c7f-3f11a81c8539.k8s.ondigitalocean.com",
"node_count": 8,
"node_size": "s-4vcpu-8gb",
},
]
# ---------------------------------------------------------------------------
# Network-specific block heights and endpoint patterns
# ---------------------------------------------------------------------------
_NETWORK_CONFIG = {
"mainnet": {
"base_block_height": 28_500_000,
"rpc_endpoint": "https://api.lux.network/ext/bc/mainnet/rpc",
"ws_endpoint": "wss://api.lux.network/ext/bc/mainnet/ws",
},
"testnet": {
"base_block_height": 15_200_000,
"rpc_endpoint": "https://api.lux.network/ext/bc/testnet/rpc",
"ws_endpoint": "wss://api.lux.network/ext/bc/testnet/ws",
},
"devnet": {
"base_block_height": 5_100_000,
"rpc_endpoint": "https://api.lux.network/ext/bc/devnet/rpc",
"ws_endpoint": "wss://api.lux.network/ext/bc/devnet/ws",
},
}
def get_seed_clusters() -> list[dict]:
"""Return seed cluster definitions."""
return list(SEED_CLUSTERS)
def get_seed_nodes() -> list[dict]:
"""Return 15 Lux validator node definitions (5 per network).
Uses random.seed(42) set at module level for reproducibility.
"""
# Reset seed so results are identical regardless of call order
random.seed(42)
nodes: list[dict] = []
for network, cfg in _NETWORK_CONFIG.items():
for n in range(1, 6):
block_jitter = random.randint(-500, 500)
nodes.append(
{
"name": f"lux-{network}-validator-{n}",
"chain": "lux",
"network": network,
"provider": "kubernetes",
"status": "running",
"node_type": "validator",
"cluster_name": "lux-k8s", # resolved at insert time
"region": "sfo3",
"latitude": 37.7749,
"longitude": -122.4194,
"rpc_endpoint": cfg["rpc_endpoint"],
"ws_endpoint": cfg["ws_endpoint"],
"execution_client": "luxd",
"consensus_client": None,
"sync_mode": None,
"image": "luxfi/luxd:latest",
"pod_name": f"lux-validator-{network}-{n}",
"sync_progress": 100.0,
"peer_count": random.randint(15, 45),
"block_height": cfg["base_block_height"] + block_jitter,
"cpu_usage": round(random.uniform(15.0, 45.0), 1),
"memory_usage_mb": round(random.uniform(1024.0, 4096.0), 1),
"disk_usage_gb": round(random.uniform(50.0, 200.0), 1),
"uptime_seconds": random.randint(86400, 2592000),
}
)
return nodes
# ---------------------------------------------------------------------------
# Database seeder
# ---------------------------------------------------------------------------
async def seed_database(session: AsyncSession) -> None:
"""Populate the database with seed clusters and Lux validator nodes.
Skips creation if nodes already exist. Idempotent -- safe to call on
every startup.
"""
# Check if nodes already exist
result = await session.execute(
select(Node).where(Node.chain == "lux").limit(1)
)
if result.scalar_one_or_none() is not None:
print("[seed_nodes] Lux validator nodes already present -- skipping.")
return
# -- Clusters ----------------------------------------------------------
cluster_map: dict[str, uuid.UUID] = {}
for cdata in get_seed_clusters():
# Check if cluster already exists by provider_id
result = await session.execute(
select(Cluster).where(Cluster.provider_id == cdata["provider_id"])
)
existing = result.scalar_one_or_none()
if existing is not None:
cluster_map[cdata["name"]] = existing.id
print(f"[seed_nodes] Cluster '{cdata['name']}' already exists: {existing.id}")
continue
cluster = Cluster(
name=cdata["name"],
provider=cdata["provider"],
provider_id=cdata["provider_id"],
region=cdata["region"],
status=cdata["status"],
kubernetes_version=cdata["kubernetes_version"],
endpoint=cdata["endpoint"],
node_count=cdata["node_count"],
node_size=cdata["node_size"],
)
session.add(cluster)
await session.flush() # assigns cluster.id
cluster_map[cdata["name"]] = cluster.id
print(f"[seed_nodes] Created cluster '{cdata['name']}': {cluster.id}")
# -- Nodes -------------------------------------------------------------
seed_nodes = get_seed_nodes()
for ndata in seed_nodes:
cluster_name = ndata.pop("cluster_name")
cluster_id = cluster_map.get(cluster_name)
node = Node(
name=ndata["name"],
chain=ndata["chain"],
network=ndata["network"],
provider=ndata["provider"],
status=ndata["status"],
node_type=ndata["node_type"],
cluster_id=cluster_id,
region=ndata["region"],
latitude=ndata["latitude"],
longitude=ndata["longitude"],
rpc_endpoint=ndata["rpc_endpoint"],
ws_endpoint=ndata["ws_endpoint"],
execution_client=ndata["execution_client"],
consensus_client=ndata["consensus_client"],
sync_mode=ndata["sync_mode"],
image=ndata["image"],
pod_name=ndata["pod_name"],
sync_progress=ndata["sync_progress"],
peer_count=ndata["peer_count"],
block_height=ndata["block_height"],
cpu_usage=ndata["cpu_usage"],
memory_usage_mb=ndata["memory_usage_mb"],
disk_usage_gb=ndata["disk_usage_gb"],
uptime_seconds=ndata["uptime_seconds"],
last_health_check=datetime.now(UTC),
)
session.add(node)
await session.commit()
print(f"[seed_nodes] Seeded {len(seed_nodes)} Lux validator nodes across 3 networks.")