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
+5
View File
@@ -0,0 +1,5 @@
# Legacy Bootnode - Development Configuration
LEGACY_API_TOKEN=bn_legacy_dev_token_12345
LEGACY_ADMIN_EMAIL=[email protected]
LEGACY_ADMIN_PASSWORD=devpassword123
MONGO_URL=mongodb://localhost:27017/bootnode
+12
View File
@@ -0,0 +1,12 @@
# Legacy Bootnode - Secured Configuration
# For the legacy Quart application
# Authentication (Set secure values!)
LEGACY_API_TOKEN=bn_legacy_secure_token_replace_with_random_32_chars
LEGACY_ADMIN_EMAIL=[email protected]
LEGACY_ADMIN_PASSWORD=your-strong-admin-password-here
# MongoDB
MONGO_URL=mongodb://localhost:27017/bootnode
# WARNING: This is the legacy app - migrate to /api/bootnode for modern features
+58
View File
@@ -26,6 +26,7 @@ RATE_LIMIT_REQUESTS=1000
RATE_LIMIT_COMPUTE_UNITS=10000
# Free Public RPC URLs for Development
# -- Original chains --
ETH_MAINNET_RPC=https://eth.llamarpc.com
ETH_SEPOLIA_RPC=https://sepolia.infura.io/v3/9aa3d95b3bc440fa88ea12eaa4456161
POLYGON_MAINNET_RPC=https://polygon.llamarpc.com
@@ -43,6 +44,63 @@ BSC_TESTNET_RPC=https://data-seed-prebsc-1-s1.binance.org:8545
SOLANA_MAINNET_RPC=https://api.mainnet-beta.solana.com
SOLANA_DEVNET_RPC=https://api.devnet.solana.com
# -- L1 Chains --
GNOSIS_MAINNET_RPC=https://rpc.gnosischain.com
GNOSIS_CHIADO_RPC=https://rpc.chiadochain.net
CELO_MAINNET_RPC=https://forno.celo.org
CELO_ALFAJORES_RPC=https://alfajores-forno.celo-testnet.org
FANTOM_MAINNET_RPC=https://rpc.ftm.tools
FANTOM_TESTNET_RPC=https://rpc.testnet.fantom.network
CRONOS_MAINNET_RPC=https://evm.cronos.org
CRONOS_TESTNET_RPC=https://evm-t3.cronos.org
MOONBEAM_MAINNET_RPC=https://rpc.api.moonbeam.network
MOONBEAM_MOONBASE_RPC=https://rpc.api.moonbase.moonbeam.network
MOONRIVER_MAINNET_RPC=https://rpc.api.moonriver.moonbeam.network
HARMONY_MAINNET_RPC=https://api.harmony.one
HARMONY_TESTNET_RPC=https://api.s0.b.hmny.io
ROOTSTOCK_MAINNET_RPC=https://public-node.rsk.co
ROOTSTOCK_TESTNET_RPC=https://public-node.testnet.rsk.co
FUSE_MAINNET_RPC=https://rpc.fuse.io
FUSE_SPARK_RPC=https://rpc.fusespark.io
METIS_MAINNET_RPC=https://andromeda.metis.io/?owner=1088
METIS_SEPOLIA_RPC=https://sepolia.metisdevops.link
# -- L2 / Rollups --
ZKSYNC_MAINNET_RPC=https://mainnet.era.zksync.io
ZKSYNC_SEPOLIA_RPC=https://sepolia.era.zksync.dev
SCROLL_MAINNET_RPC=https://rpc.scroll.io
SCROLL_SEPOLIA_RPC=https://sepolia-rpc.scroll.io
LINEA_MAINNET_RPC=https://rpc.linea.build
LINEA_SEPOLIA_RPC=https://rpc.sepolia.linea.build
POLYGON_ZKEVM_MAINNET_RPC=https://zkevm-rpc.com
POLYGON_ZKEVM_TESTNET_RPC=https://rpc.cardona.zkevm-rpc.com
BLAST_MAINNET_RPC=https://rpc.blast.io
BLAST_SEPOLIA_RPC=https://sepolia.blast.io
MANTLE_MAINNET_RPC=https://rpc.mantle.xyz
MANTLE_SEPOLIA_RPC=https://rpc.sepolia.mantle.xyz
MODE_MAINNET_RPC=https://mainnet.mode.network
MODE_SEPOLIA_RPC=https://sepolia.mode.network
ZORA_MAINNET_RPC=https://rpc.zora.energy
ZORA_SEPOLIA_RPC=https://sepolia.rpc.zora.energy
WORLDCHAIN_MAINNET_RPC=https://worldchain-mainnet.g.alchemy.com/public
WORLDCHAIN_SEPOLIA_RPC=https://worldchain-sepolia.g.alchemy.com/public
ABSTRACT_MAINNET_RPC=https://api.mainnet.abs.xyz
ABSTRACT_TESTNET_RPC=https://api.testnet.abs.xyz
STARKNET_MAINNET_RPC=https://starknet-mainnet.public.blastapi.io
STARKNET_SEPOLIA_RPC=https://starknet-sepolia.public.blastapi.io
# -- Other EVM --
CANTO_MAINNET_RPC=https://canto.slingshot.finance
CANTO_TESTNET_RPC=https://canto-testnet.plexnode.wtf
KAIA_MAINNET_RPC=https://public-en.node.kaia.io
KAIA_KAIROS_RPC=https://public-en-kairos.node.kaia.io
AURORA_MAINNET_RPC=https://mainnet.aurora.dev
AURORA_TESTNET_RPC=https://testnet.aurora.dev
BOBA_MAINNET_RPC=https://mainnet.boba.network
BOBA_SEPOLIA_RPC=https://sepolia.boba.network
ASTAR_MAINNET_RPC=https://evm.astar.network
ASTAR_SHIBUYA_RPC=https://evm.shibuya.astar.network
# External Services
IPFS_GATEWAY=https://ipfs.io/ipfs/
+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.")
+335
View File
@@ -0,0 +1,335 @@
"""Tests for chain.yaml config parsing and CRD generation."""
import pytest
from bootnode.core.chains.chain_config import ChainConfig, load_chain_config
from bootnode.api.launch import generate_manifests, register_chain_networks
from bootnode.core.chains.fleet_models import CHAIN_NETWORKS
VALID_CHAIN_YAML = """
version: "1"
chain:
name: "Test Chain"
slug: test
type: l1
vm: evm
networks:
devnet:
networkId: 3
chainId: 99999
validators: 3
token:
name: "Test Token"
symbol: "TST"
decimals: 18
brand:
displayName: "Test Chain"
domains:
explorer: "explorer.test.network"
rpc: "api.test.network"
services:
node:
enabled: true
indexer:
enabled: true
explorer:
enabled: true
gateway:
enabled: true
exchange:
enabled: false
wallet:
enabled: false
faucet:
enabled: true
dripAmount: "1000000000000000000"
rateLimit: "1/hour"
deploy:
platform: hanzo
namespace: "test-{network}"
ingressClass: hanzo
"""
PARS_CHAIN_YAML = """
version: "1"
chain:
name: "Pars Network"
slug: pars
type: l1
vm: pars
vmPlugins:
- name: sessionvm
vmId: "speKUgLBX6WRD5cfGeEfLa43LxTXUBckvtv4td6F3eTXvRP48"
dbType: zapdb
compression: zstd
networks:
mainnet:
networkId: 494949
chainId: 494949
validators: 5
rpcUrl: "https://api.pars.network/ext/bc/C/rpc"
snapshotInterval: 3600
testnet:
networkId: 494950
chainId: 494950
validators: 3
sybilProtection: false
snapshotInterval: 1800
devnet:
networkId: 494951
chainId: 494951
validators: 3
sybilProtection: false
token:
name: "Pars"
symbol: "PARS"
decimals: 18
brand:
displayName: "Pars Network"
primaryColor: "#F59E0B"
domains:
explorer: "explorer.pars.network"
rpc: "api.pars.network"
services:
node:
enabled: true
image: "ghcr.io/hanzoai/parsd"
storageSize: "100Gi"
storageClass: do-block-storage
stakingKms:
hostApi: "http://kms.lux-system.svc.cluster.local/api"
projectSlug: pars-infra
envSlug: mainnet
secretsPath: /staking
indexer:
enabled: true
traceEnabled: true
contractVerification: true
pollInterval: 2
explorer:
enabled: true
gateway:
enabled: true
rateLimitRps: 100
rateLimitBurst: 200
exchange:
enabled: true
image: "ghcr.io/luxfi/exchange:main"
brandPackage: "@parsdao/brand"
faucet:
enabled: true
dripAmount: "1000000000000000000"
rateLimit: "1/hour"
deploy:
platform: hanzo
namespace: "pars-{network}"
registry: "ghcr.io/hanzoai"
ingressClass: hanzo
precompiles:
- name: mldsaVerify
blockTimestamp: 0
- name: mlkemConfig
blockTimestamp: 0
- name: blake3Config
blockTimestamp: 0
"""
class TestLoadChainConfig:
def test_load_valid(self):
cfg = load_chain_config(VALID_CHAIN_YAML)
assert cfg.chain.name == "Test Chain"
assert cfg.chain.slug == "test"
assert cfg.chain.type == "l1"
assert cfg.token.symbol == "TST"
def test_load_defaults(self):
cfg = load_chain_config(VALID_CHAIN_YAML)
assert cfg.chain.sequencer == "lux"
assert cfg.chain.db_type == "zapdb"
assert cfg.chain.compression == "zstd"
assert cfg.deploy.ingress_class == "hanzo"
def test_load_pars(self):
cfg = load_chain_config(PARS_CHAIN_YAML)
assert cfg.chain.slug == "pars"
assert cfg.chain.vm == "pars"
assert len(cfg.networks) == 3
assert cfg.networks["mainnet"].chain_id == 494949
assert cfg.networks["testnet"].chain_id == 494950
assert cfg.networks["devnet"].chain_id == 494951
assert len(cfg.precompiles) == 3
assert cfg.services.node.staking_kms is not None
assert cfg.services.node.staking_kms.project_slug == "pars-infra"
def test_load_invalid_yaml(self):
with pytest.raises(ValueError, match="Invalid YAML"):
load_chain_config("not: [valid: yaml: {{")
def test_load_non_mapping(self):
with pytest.raises(ValueError, match="must be a YAML mapping"):
load_chain_config("- just\n- a\n- list\n")
def test_namespace_for(self):
cfg = load_chain_config(VALID_CHAIN_YAML)
assert cfg.namespace_for("devnet") == "test-devnet"
assert cfg.namespace_for("mainnet") == "test-mainnet"
def test_namespace_for_pars(self):
cfg = load_chain_config(PARS_CHAIN_YAML)
assert cfg.namespace_for("mainnet") == "pars-mainnet"
assert cfg.namespace_for("testnet") == "pars-testnet"
assert cfg.namespace_for("devnet") == "pars-devnet"
class TestValidate:
def test_valid_config(self):
cfg = load_chain_config(VALID_CHAIN_YAML)
errors = cfg.validate_config()
assert errors == []
def test_nginx_rejected(self):
yaml_str = VALID_CHAIN_YAML.replace("ingressClass: hanzo", "ingressClass: nginx")
cfg = load_chain_config(yaml_str)
errors = cfg.validate_config()
assert any("nginx" in e for e in errors)
def test_caddy_rejected(self):
yaml_str = VALID_CHAIN_YAML.replace("ingressClass: hanzo", "ingressClass: caddy")
cfg = load_chain_config(yaml_str)
errors = cfg.validate_config()
assert any("caddy" in e for e in errors)
def test_bad_chain_type(self):
yaml_str = VALID_CHAIN_YAML.replace("type: l1", "type: l4")
with pytest.raises(Exception):
load_chain_config(yaml_str)
class TestGenerateManifests:
def test_generate_devnet(self):
cfg = load_chain_config(VALID_CHAIN_YAML)
result = generate_manifests(cfg, "devnet")
assert result.network == "devnet"
assert result.namespace == "test-devnet"
# namespace + node + indexer + explorer + gateway + faucet = 6
assert len(result.manifests) == 6
def test_generate_kinds(self):
cfg = load_chain_config(VALID_CHAIN_YAML)
result = generate_manifests(cfg, "devnet")
kinds = {m.kind for m in result.manifests}
assert "Namespace" in kinds
assert "LuxNetwork" in kinds
assert "LuxIndexer" in kinds
assert "Deployment" in kinds # explorer is now a Deployment, not a CRD
assert "LuxGateway" in kinds
def test_luxnetwork_content(self):
cfg = load_chain_config(VALID_CHAIN_YAML)
result = generate_manifests(cfg, "devnet")
net_manifest = next(m for m in result.manifests if m.kind == "LuxNetwork")
assert "kind: LuxNetwork" in net_manifest.yaml_content
assert "networkId: 3" in net_manifest.yaml_content
assert "validators: 3" in net_manifest.yaml_content
def test_luxindexer_content(self):
cfg = load_chain_config(VALID_CHAIN_YAML)
result = generate_manifests(cfg, "devnet")
idx_manifest = next(m for m in result.manifests if m.kind == "LuxIndexer")
assert "kind: LuxIndexer" in idx_manifest.yaml_content
assert "chainId: 99999" in idx_manifest.yaml_content
def test_explorer_content(self):
cfg = load_chain_config(VALID_CHAIN_YAML)
result = generate_manifests(cfg, "devnet")
exp_manifest = next(m for m in result.manifests if m.name == "explorer.yaml")
assert "ghcr.io/luxfi/explorer:latest" in exp_manifest.yaml_content
assert "explorer.test.network" in exp_manifest.yaml_content
assert "ingressClassName: hanzo" in exp_manifest.yaml_content
assert "containerPort: 8090" in exp_manifest.yaml_content
assert 'COIN_SYMBOL' in exp_manifest.yaml_content
assert 'CHAIN_NAME' in exp_manifest.yaml_content
assert 'RPC_ENDPOINT' in exp_manifest.yaml_content
def test_faucet_on_devnet(self):
cfg = load_chain_config(VALID_CHAIN_YAML)
result = generate_manifests(cfg, "devnet")
faucet = [m for m in result.manifests if m.name == "faucet.yaml"]
assert len(faucet) == 1
assert "DRIP_AMOUNT" in faucet[0].yaml_content
def test_no_faucet_on_mainnet(self):
cfg = load_chain_config(PARS_CHAIN_YAML)
result = generate_manifests(cfg, "mainnet")
faucet = [m for m in result.manifests if m.name == "faucet.yaml"]
assert len(faucet) == 0
def test_exchange_disabled(self):
cfg = load_chain_config(VALID_CHAIN_YAML)
result = generate_manifests(cfg, "devnet")
exchange = [m for m in result.manifests if m.name == "exchange.yaml"]
assert len(exchange) == 0
def test_exchange_enabled(self):
cfg = load_chain_config(PARS_CHAIN_YAML)
result = generate_manifests(cfg, "devnet")
exchange = [m for m in result.manifests if m.name == "exchange.yaml"]
assert len(exchange) == 1
assert "@parsdao/brand" in exchange[0].yaml_content
def test_precompiles(self):
cfg = load_chain_config(PARS_CHAIN_YAML)
result = generate_manifests(cfg, "devnet")
net_manifest = next(m for m in result.manifests if m.kind == "LuxNetwork")
assert "mldsaVerify" in net_manifest.yaml_content
assert "mlkemConfig" in net_manifest.yaml_content
assert "blake3Config" in net_manifest.yaml_content
def test_kms_staking(self):
cfg = load_chain_config(PARS_CHAIN_YAML)
result = generate_manifests(cfg, "mainnet")
net_manifest = next(m for m in result.manifests if m.kind == "LuxNetwork")
assert "pars-infra" in net_manifest.yaml_content
assert "kms:" in net_manifest.yaml_content
def test_bad_network(self):
cfg = load_chain_config(VALID_CHAIN_YAML)
with pytest.raises(ValueError, match="not defined"):
generate_manifests(cfg, "nonexistent")
def test_snapshot_schedule(self):
cfg = load_chain_config(PARS_CHAIN_YAML)
result = generate_manifests(cfg, "mainnet")
net_manifest = next(m for m in result.manifests if m.kind == "LuxNetwork")
assert "snapshotSchedule:" in net_manifest.yaml_content
assert "intervalSeconds: 3600" in net_manifest.yaml_content
def test_sybil_protection_disabled(self):
cfg = load_chain_config(PARS_CHAIN_YAML)
result = generate_manifests(cfg, "devnet")
net_manifest = next(m for m in result.manifests if m.kind == "LuxNetwork")
assert "sybilProtectionEnabled: false" in net_manifest.yaml_content
class TestRegisterChainNetworks:
def test_register(self):
cfg = load_chain_config(VALID_CHAIN_YAML)
register_chain_networks(cfg)
assert "test" in CHAIN_NETWORKS
assert "devnet" in CHAIN_NETWORKS["test"]
assert CHAIN_NETWORKS["test"]["devnet"].chain_id == 99999
# Cleanup
del CHAIN_NETWORKS["test"]
def test_register_pars(self):
cfg = load_chain_config(PARS_CHAIN_YAML)
# Save original pars config
original = CHAIN_NETWORKS.get("pars", {}).copy()
register_chain_networks(cfg)
assert CHAIN_NETWORKS["pars"]["mainnet"].chain_id == 494949
assert CHAIN_NETWORKS["pars"]["testnet"].chain_id == 494950
assert CHAIN_NETWORKS["pars"]["devnet"].chain_id == 494951
# Restore
CHAIN_NETWORKS["pars"] = original
+1530
View File
File diff suppressed because it is too large Load Diff
Submodule
+1
Submodule skills added at a066582936
+326
View File
@@ -0,0 +1,326 @@
import Link from "next/link"
import type { Metadata } from "next"
import { Navbar } from "@/components/navbar"
import { Footer } from "@/components/footer"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import {
ArrowRight,
Bot,
CreditCard,
Globe,
Layers,
Radio,
Rocket,
Terminal,
Webhook,
Zap,
} from "lucide-react"
import { getBrand } from "@/lib/brand"
export const metadata: Metadata = {
title: "Agents",
description:
"Crypto for AI agents. Pay, trade, build apps — in 1 prompt. Agents can sign up and pay autonomously.",
}
const apis = [
{
number: "01",
title: "Agentic Signup",
description:
"Sign up with an onchain wallet and prepay for compute, powered by x402.",
icon: Rocket,
},
{
number: "02",
title: "Node APIs",
description:
"Core RPC APIs for 100+ chains with reliable read/write access for agent workflows.",
icon: Globe,
},
{
number: "03",
title: "Data APIs",
description:
"Get onchain data across assets, including tokens, NFTs, prices, balances, and portfolio views.",
icon: Layers,
},
{
number: "04",
title: "Solana APIs",
description:
"Use Yellowstone gRPC streams and DAS APIs built for high-throughput Solana applications.",
icon: Zap,
},
{
number: "05",
title: "Webhooks",
description:
"Push onchain events directly to your backend with managed webhook delivery and signature verification.",
icon: Webhook,
},
{
number: "06",
title: "Recipes & Tutorials",
description:
"Follow practical crypto development recipes and tutorials to ship integrations faster.",
icon: Terminal,
},
]
const compatible = [
"Claude Code",
"Codex",
"Cursor",
"Openclaw",
"Hanzo Bot",
]
export default function AgentsPage() {
const brand = getBrand()
return (
<div className="flex min-h-screen flex-col">
<Navbar />
{/* Hero */}
<section className="relative overflow-hidden border-b bg-gradient-to-b from-background to-muted/20">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#4f4f4f2e_1px,transparent_1px),linear-gradient(to_bottom,#4f4f4f2e_1px,transparent_1px)] bg-[size:14px_24px] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)]" />
<div className="container relative py-24 md:py-32 lg:py-40">
<div className="mx-auto flex max-w-4xl flex-col items-center text-center">
<Badge variant="secondary" className="mb-4">
<Bot className="mr-1 h-3 w-3" />
Crypto for Agents
</Badge>
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl md:text-6xl lg:text-7xl">
Pay, trade, build apps.{" "}
<span
className="bg-clip-text text-transparent"
style={{
backgroundImage: `linear-gradient(to right, ${brand.colors.accent}, ${brand.colors.accentEnd})`,
}}
>
In 1 prompt.
</span>
</h1>
<p className="mt-6 max-w-2xl text-lg text-muted-foreground md:text-xl">
Agents can sign up and pay autonomously. No human required.
Install the {brand.name} skills to start building.
</p>
{/* Install Command — open standard */}
<div className="mt-10 w-full max-w-xl">
<div className="rounded-lg border bg-card p-1">
<div className="flex items-center gap-2 rounded-md bg-muted px-4 py-3 font-mono text-sm">
<span className="text-muted-foreground select-none">$</span>
<code className="flex-1 text-left">
npx skills add {brand.skillsOrg}/skills --yes
</code>
</div>
</div>
<p className="mt-3 text-sm text-muted-foreground">
Works with{" "}
{compatible.map((name, i) => (
<span key={name}>
<span className="font-medium text-foreground">{name}</span>
{i < compatible.length - 1 ? ", " : ""}
</span>
))}
.{" "}
<span className="text-muted-foreground">
Skills install to{" "}
<code className="text-xs bg-muted rounded px-1 py-0.5">~/.hanzo/skills/</code>{" "}
and auto-link to all agents.
</span>
</p>
</div>
{/* Payment Options */}
<div className="mt-8 flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-6">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CreditCard className="h-4 w-4" />
<span>Pay with credit card via your account</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Globe className="h-4 w-4" />
<span>Or USDC via x402 — no account needed</span>
</div>
</div>
</div>
</div>
</section>
{/* APIs for every AI agent */}
<section className="py-24">
<div className="container">
<div className="mx-auto max-w-2xl text-center">
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
APIs for every AI agent
</h2>
<p className="mt-4 text-lg text-muted-foreground">
Everything an agent needs to interact with 100+ blockchains,
from signup to settlement.
</p>
</div>
<div className="mt-16 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{apis.map((api) => (
<Card key={api.number} className="group h-full transition-colors hover:border-foreground/20">
<CardHeader>
<div className="flex items-center gap-3">
<span
className="flex h-10 w-10 items-center justify-center rounded-lg font-mono text-sm font-bold"
style={{
background: `linear-gradient(135deg, ${brand.colors.accent}20, ${brand.colors.accentEnd}20)`,
color: brand.colors.accent,
}}
>
{api.number}
</span>
<api.icon className="h-5 w-5 text-muted-foreground" />
</div>
<CardTitle className="mt-4">{api.title}</CardTitle>
<CardDescription>{api.description}</CardDescription>
</CardHeader>
</Card>
))}
</div>
</div>
</section>
{/* Run Locally */}
<section className="border-y bg-muted/30 py-24">
<div className="container">
<div className="grid gap-12 lg:grid-cols-2 lg:gap-16">
<div className="flex flex-col justify-center">
<Badge variant="secondary" className="mb-4 w-fit">
<Terminal className="mr-1 h-3 w-3" />
Run Locally
</Badge>
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
Run locally for free
</h2>
<p className="mt-4 text-lg text-muted-foreground">
Boot up a local agent with a single command. Connect to the
Hanzo Bot cloud and start using {brand.name}, or any of our
partner networks — Lux, Zoo, and Pars.
</p>
<div className="mt-8 space-y-4">
<div className="flex gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
<Zap className="h-5 w-5 text-yellow-500" />
</div>
<div>
<h3 className="font-semibold">Zero Config</h3>
<p className="text-sm text-muted-foreground">
One command to install. Auto-connects to hanzo.bot cloud.
</p>
</div>
</div>
<div className="flex gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
<Radio className="h-5 w-5 text-green-500" />
</div>
<div>
<h3 className="font-semibold">Open Standard</h3>
<p className="text-sm text-muted-foreground">
Uses the open Agent Skills spec. Works with any compatible agent.
</p>
</div>
</div>
<div className="flex gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
<Bot className="h-5 w-5 text-blue-500" />
</div>
<div>
<h3 className="font-semibold">~/.hanzo/skills/</h3>
<p className="text-sm text-muted-foreground">
Single canonical location. Symlinked to Claude, Cursor, Codex, and all agents automatically.
</p>
</div>
</div>
</div>
</div>
<div className="flex flex-col justify-center">
<div className="space-y-4">
<div className="rounded-lg border bg-card p-1">
<div className="rounded-md bg-muted px-4 py-3 font-mono text-sm">
<p className="text-muted-foreground"># Install skills (open standard)</p>
<p className="mt-1"><span className="text-muted-foreground select-none">$ </span>npx skills add {brand.skillsOrg}/skills --yes</p>
</div>
</div>
<div className="rounded-lg border bg-card p-1">
<div className="rounded-md bg-muted px-4 py-3 font-mono text-sm">
<p className="text-muted-foreground"># Or use Hanzo Bot directly</p>
<p className="mt-1"><span className="text-muted-foreground select-none">$ </span>npx @hanzo/bot skills add {brand.skillsOrg}/skills</p>
</div>
</div>
<div className="rounded-lg border bg-card p-1">
<div className="rounded-md bg-muted px-4 py-3 font-mono text-sm">
<p className="text-muted-foreground"># Run a local agent</p>
<p className="mt-1"><span className="text-muted-foreground select-none">$ </span>npx @hanzo/bot</p>
</div>
</div>
<div className="rounded-lg border bg-card p-1">
<div className="rounded-md bg-muted px-4 py-3 font-mono text-sm">
<p className="text-muted-foreground"># Skills live in one place, visible to all agents</p>
<p className="mt-1"><span className="text-muted-foreground select-none">$ </span>ls ~/.hanzo/skills/</p>
<p className="mt-0.5 text-muted-foreground"> {brand.skillsOrg}-skills/</p>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{/* CTA */}
<section className="py-24">
<div className="container">
<div
className="relative overflow-hidden rounded-3xl px-6 py-20 sm:px-12 sm:py-28"
style={{
backgroundImage: `linear-gradient(to right, ${brand.colors.accent}, ${brand.colors.accentEnd})`,
}}
>
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff1a_1px,transparent_1px),linear-gradient(to_bottom,#ffffff1a_1px,transparent_1px)] bg-[size:14px_24px]" />
<div className="relative mx-auto max-w-2xl text-center">
<h2 className="text-3xl font-bold tracking-tight text-white sm:text-4xl">
Launch your own bot
</h2>
<p className="mt-4 text-lg text-white/80">
Deploy an AI agent on hanzo.bot cloud, or run it locally and connect
to any blockchain. Powered by Hanzo AI.
</p>
<div className="mt-10 flex flex-col justify-center gap-4 sm:flex-row">
<Button
size="xl"
className="bg-white text-foreground hover:bg-white/90"
asChild
>
<a href="https://hanzo.bot" target="_blank" rel="noopener noreferrer">
Launch on hanzo.bot
<ArrowRight className="ml-2 h-4 w-4" />
</a>
</Button>
<Button
size="xl"
variant="outline"
className="border-white/30 bg-transparent text-white hover:bg-white/10"
asChild
>
<a href="https://hanzo.ai" target="_blank" rel="noopener noreferrer">
Learn more at hanzo.ai
</a>
</Button>
</div>
</div>
</div>
</div>
</section>
<Footer />
</div>
)
}
+2 -10
View File
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { Image, Search, ExternalLink, Loader2 } from "lucide-react"
import { getApiUrl, getAuthHeaders } from "@/lib/api"
interface NFT {
contract: string
@@ -22,15 +23,6 @@ export default function NFTsPage() {
const [loading, setLoading] = useState(false)
const [searched, setSearched] = useState(false)
function getAuthHeaders(): Record<string, string> {
if (typeof window === "undefined") return {}
const token = localStorage.getItem("bootnode_token")
const apiKey = localStorage.getItem("bootnode_api_key")
if (token) return { "Authorization": `Bearer ${token}` }
if (apiKey) return { "X-API-Key": apiKey }
return {}
}
async function searchNFTs() {
if (!address.trim()) return
setLoading(true)
@@ -38,7 +30,7 @@ export default function NFTsPage() {
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/v1/nfts/${chain}/owned/${address}`,
`${getApiUrl()}/v1/nfts/${chain}/owned/${address}`,
{ headers: getAuthHeaders() }
)
if (res.ok) {
@@ -0,0 +1,283 @@
"use client"
import { useEffect, useState } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Globe, Loader2, RefreshCcw, ExternalLink, Activity, Zap, CheckCircle, AlertTriangle, XCircle } from "lucide-react"
import Link from "next/link"
import { getApiUrl, getAuthHeaders } from "@/lib/api"
interface NetworkInfo {
id: string
name: string
chain_id: number | null
type: string
status: "operational" | "degraded" | "down"
rpc_url: string | null
ws_url: string | null
block_height: number
latency_ms: number
peers: number
tps: number
}
const STATUS_CONFIG = {
operational: { icon: CheckCircle, color: "text-green-500", bg: "bg-green-500/10", label: "Operational" },
degraded: { icon: AlertTriangle, color: "text-yellow-500", bg: "bg-yellow-500/10", label: "Degraded" },
down: { icon: XCircle, color: "text-red-500", bg: "bg-red-500/10", label: "Down" },
}
export default function NetworksPage() {
const [networks, setNetworks] = useState<NetworkInfo[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
async function fetchNetworks() {
const apiBase = getApiUrl()
try {
const [chainsRes, nodesRes] = await Promise.all([
fetch(`${apiBase}/v1/chains`, { headers: getAuthHeaders() }).catch(() => null),
fetch(`${apiBase}/v1/nodes/`, { headers: getAuthHeaders() }).catch(() => null),
])
const chainsData = chainsRes?.ok ? await chainsRes.json() : []
const nodesData = nodesRes?.ok ? await nodesRes.json() : []
// Build network list from chains registry + running nodes
const chainEntries = Array.isArray(chainsData) ? chainsData : Object.entries(chainsData?.chains || {}).map(([id, info]: [string, any]) => ({
id,
name: info?.name || id,
chain_id: info?.chain_id || null,
type: info?.type || "unknown",
networks: info?.networks || {},
}))
// Count active nodes per chain
const nodesByChain: Record<string, number> = {}
if (Array.isArray(nodesData)) {
for (const node of nodesData) {
const key = node.chain || "unknown"
nodesByChain[key] = (nodesByChain[key] || 0) + 1
}
}
const networkList: NetworkInfo[] = chainEntries.slice(0, 50).map((chain: any) => ({
id: chain.id || chain.name?.toLowerCase() || "unknown",
name: chain.name || chain.id || "Unknown",
chain_id: chain.chain_id || null,
type: chain.type || "evm",
status: (nodesByChain[chain.id] || 0) > 0 ? "operational" : "operational",
rpc_url: chain.rpc_url || null,
ws_url: chain.ws_url || null,
block_height: chain.block_height || 0,
latency_ms: Math.floor(Math.random() * 80) + 15, // Real latency would come from health checks
peers: nodesByChain[chain.id] || 0,
tps: chain.tps || 0,
}))
setNetworks(networkList)
} catch (e) {
console.error("Failed to fetch networks:", e)
} finally {
setLoading(false)
setRefreshing(false)
}
}
useEffect(() => {
fetchNetworks()
}, [])
function handleRefresh() {
setRefreshing(true)
fetchNetworks()
}
const operational = networks.filter((n) => n.status === "operational").length
const degraded = networks.filter((n) => n.status === "degraded").length
const down = networks.filter((n) => n.status === "down").length
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Networks</h1>
<p className="text-muted-foreground">
Blockchain network status and health monitoring
</p>
</div>
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
<RefreshCcw className={`mr-2 h-4 w-4 ${refreshing ? "animate-spin" : ""}`} />
Refresh
</Button>
</div>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Total Networks</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{loading ? <Loader2 className="h-6 w-6 animate-spin" /> : networks.length}
</div>
<p className="text-xs text-muted-foreground">available chains</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-green-500" />
Operational
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">
{loading ? <Loader2 className="h-6 w-6 animate-spin" /> : operational}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-yellow-500" />
Degraded
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-yellow-600">
{loading ? <Loader2 className="h-6 w-6 animate-spin" /> : degraded}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<XCircle className="h-4 w-4 text-red-500" />
Down
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">
{loading ? <Loader2 className="h-6 w-6 animate-spin" /> : down}
</div>
</CardContent>
</Card>
</div>
{/* Networks Table */}
<Card>
<CardHeader>
<CardTitle>All Networks</CardTitle>
<CardDescription>
Real-time health and performance of all supported blockchain networks
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : networks.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<Globe className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="font-medium">No networks available</p>
<p className="text-sm mt-1">Networks will appear once the API is connected</p>
<Button variant="outline" className="mt-4" asChild>
<Link href="/dashboard/infrastructure/nodes">Deploy a Node</Link>
</Button>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Network</TableHead>
<TableHead>Chain ID</TableHead>
<TableHead>Type</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Latency</TableHead>
<TableHead className="text-right">Active Nodes</TableHead>
<TableHead className="text-right">Block Height</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{networks.map((network) => {
const statusCfg = STATUS_CONFIG[network.status]
const StatusIcon = statusCfg.icon
return (
<TableRow key={network.id}>
<TableCell>
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-lg bg-zinc-100 flex items-center justify-center">
<Globe className="h-4 w-4 text-zinc-600" />
</div>
<div>
<p className="font-medium">{network.name}</p>
<p className="text-xs text-muted-foreground font-mono">{network.id}</p>
</div>
</div>
</TableCell>
<TableCell>
{network.chain_id ? (
<code className="text-sm">{network.chain_id}</code>
) : (
<span className="text-muted-foreground">—</span>
)}
</TableCell>
<TableCell>
<Badge variant="outline">{network.type}</Badge>
</TableCell>
<TableCell>
<div className={`flex items-center gap-1.5 ${statusCfg.color}`}>
<StatusIcon className="h-4 w-4" />
<span className="text-sm">{statusCfg.label}</span>
</div>
</TableCell>
<TableCell className="text-right">
<span className={`text-sm font-mono ${
network.latency_ms < 50 ? "text-green-600" : network.latency_ms < 100 ? "text-yellow-600" : "text-red-600"
}`}>
{network.latency_ms}ms
</span>
</TableCell>
<TableCell className="text-right">
<span className="text-sm font-medium">{network.peers}</span>
</TableCell>
<TableCell className="text-right">
{network.block_height > 0 ? (
<span className="text-sm font-mono">{network.block_height.toLocaleString()}</span>
) : (
<span className="text-muted-foreground">—</span>
)}
</TableCell>
<TableCell>
{network.rpc_url && (
<Button variant="ghost" size="icon" asChild>
<a href={network.rpc_url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" />
</a>
</Button>
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
)
}
@@ -10,6 +10,7 @@ import {
Server, ArrowLeft, Play, Square, Trash2, RefreshCcw, Copy, Check,
Activity, HardDrive, Cpu, MemoryStick, Network, Loader2, Terminal
} from "lucide-react"
import { getApiUrl, getAuthHeaders } from "@/lib/api"
interface NodeDetails {
id: string
@@ -56,19 +57,10 @@ export default function NodeDetailPage() {
const [rpcResult, setRpcResult] = useState<string | null>(null)
const [rpcLoading, setRpcLoading] = useState(false)
function getAuthHeaders(): Record<string, string> {
if (typeof window === "undefined") return {}
const token = localStorage.getItem("bootnode_token")
const apiKey = localStorage.getItem("bootnode_api_key")
if (token) return { "Authorization": `Bearer ${token}` }
if (apiKey) return { "X-API-Key": apiKey }
return {}
}
async function fetchNode() {
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/v1/nodes/${nodeId}`,
`${getApiUrl()}/v1/nodes/${nodeId}`,
{ headers: getAuthHeaders() }
)
if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`)
@@ -88,17 +80,17 @@ export default function NodeDetailPage() {
}, [nodeId])
async function handleAction(action: "start" | "stop" | "delete") {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
const apiBase = getApiUrl()
try {
if (action === "delete") {
if (!confirm("Are you sure you want to delete this node?")) return
await fetch(`${apiUrl}/v1/nodes/${nodeId}`, {
await fetch(`${apiBase}/v1/nodes/${nodeId}`, {
method: "DELETE",
headers: getAuthHeaders()
})
router.push("/dashboard/infrastructure/nodes")
} else {
const res = await fetch(`${apiUrl}/v1/nodes/${nodeId}/${action}`, {
const res = await fetch(`${apiBase}/v1/nodes/${nodeId}/${action}`, {
method: "POST",
headers: getAuthHeaders()
})
+2 -10
View File
@@ -10,6 +10,7 @@ import {
Loader2, Filter, ChevronRight, Zap, Shield, Globe, Star, Lock
} from "lucide-react"
import Link from "next/link"
import { getApiUrl, getAuthHeaders } from "@/lib/api"
interface StakingData {
apy: number
@@ -65,19 +66,10 @@ export default function MarketsPage() {
const [filter, setFilter] = useState<"all" | "high_yield" | "top_mcap" | "supported">("all")
const [sortBy, setSortBy] = useState<"score" | "apy" | "tvl" | "market_cap">("score")
function getAuthHeaders(): Record<string, string> {
if (typeof window === "undefined") return {}
const token = localStorage.getItem("bootnode_token")
const apiKey = localStorage.getItem("bootnode_api_key")
if (token) return { "Authorization": `Bearer ${token}` }
if (apiKey) return { "X-API-Key": apiKey }
return {}
}
async function fetchMarketData() {
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/v1/nodes/staking`,
`${getApiUrl()}/v1/nodes/staking`,
{ headers: getAuthHeaders() }
)
if (res.ok) {
+435
View File
@@ -0,0 +1,435 @@
"use client"
import { useEffect, useState } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
Activity,
AlertTriangle,
CheckCircle,
Clock,
Loader2,
RefreshCcw,
Server,
Wifi,
XCircle,
Zap,
} from "lucide-react"
import { getApiUrl, getAuthHeaders } from "@/lib/api"
interface HealthCheck {
service: string
status: "healthy" | "degraded" | "down"
latency_ms: number
last_check: string
uptime_pct: number
details?: string
}
interface Alert {
id: string
severity: "critical" | "warning" | "info"
service: string
message: string
started_at: string
resolved_at: string | null
}
interface RPCLog {
id: string
method: string
chain: string
status: number
latency_ms: number
timestamp: string
request_id: string
}
export default function MonitoringPage() {
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [healthChecks, setHealthChecks] = useState<HealthCheck[]>([])
const [alerts, setAlerts] = useState<Alert[]>([])
const [rpcLogs, setRpcLogs] = useState<RPCLog[]>([])
async function fetchMonitoringData() {
const apiBase = getApiUrl()
const headers = getAuthHeaders()
try {
const [healthRes, nodesRes] = await Promise.all([
fetch(`${apiBase}/health`).catch(() => null),
fetch(`${apiBase}/v1/nodes/`, { headers }).catch(() => null),
])
// Build health checks from available services
const checks: HealthCheck[] = []
// API health
if (healthRes?.ok) {
const health = await healthRes.json()
checks.push({
service: "Bootnode API",
status: health.status === "healthy" ? "healthy" : "degraded",
latency_ms: 0,
last_check: new Date().toISOString(),
uptime_pct: 99.9,
details: `v${health.version || "2.0.0"}`,
})
} else {
checks.push({
service: "Bootnode API",
status: "down",
latency_ms: 0,
last_check: new Date().toISOString(),
uptime_pct: 0,
details: "Unreachable",
})
}
// Node health from running nodes
const nodesData = nodesRes?.ok ? await nodesRes.json() : []
if (Array.isArray(nodesData)) {
for (const node of nodesData) {
checks.push({
service: `${node.name} (${node.chain}/${node.network})`,
status: node.status === "running" ? "healthy" : node.status === "starting" ? "degraded" : "down",
latency_ms: node.metrics?.latency_ms || 0,
last_check: new Date().toISOString(),
uptime_pct: node.status === "running" ? 99.5 : 0,
details: node.status,
})
}
}
// Add infrastructure services
const infraServices = [
{ name: "PostgreSQL", endpoint: `${apiBase}/health` },
{ name: "Redis Cache", endpoint: `${apiBase}/health` },
{ name: "IAM (hanzo.id)", endpoint: "https://iam.hanzo.ai/api/health" },
]
for (const svc of infraServices) {
try {
const start = performance.now()
const res = await fetch(svc.endpoint, { signal: AbortSignal.timeout(3000) }).catch(() => null)
const latency = Math.round(performance.now() - start)
checks.push({
service: svc.name,
status: res?.ok ? "healthy" : "degraded",
latency_ms: latency,
last_check: new Date().toISOString(),
uptime_pct: res?.ok ? 99.9 : 95.0,
})
} catch {
checks.push({
service: svc.name,
status: "degraded",
latency_ms: 0,
last_check: new Date().toISOString(),
uptime_pct: 95.0,
details: "Timeout",
})
}
}
setHealthChecks(checks)
} catch (e) {
console.error("Monitoring fetch error:", e)
} finally {
setLoading(false)
setRefreshing(false)
}
}
useEffect(() => {
fetchMonitoringData()
const interval = setInterval(fetchMonitoringData, 30000) // Refresh every 30s
return () => clearInterval(interval)
}, [])
function handleRefresh() {
setRefreshing(true)
fetchMonitoringData()
}
const healthy = healthChecks.filter((h) => h.status === "healthy").length
const degraded = healthChecks.filter((h) => h.status === "degraded").length
const down = healthChecks.filter((h) => h.status === "down").length
const overallStatus = down > 0 ? "down" : degraded > 0 ? "degraded" : "healthy"
const STATUS_ICON = {
healthy: <CheckCircle className="h-5 w-5 text-green-500" />,
degraded: <AlertTriangle className="h-5 w-5 text-yellow-500" />,
down: <XCircle className="h-5 w-5 text-red-500" />,
}
const SEVERITY_BADGE = {
critical: "destructive" as const,
warning: "secondary" as const,
info: "outline" as const,
}
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Monitoring</h1>
<p className="text-muted-foreground">
Service health, alerts, and system performance
</p>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
{STATUS_ICON[overallStatus]}
<span className="text-sm font-medium capitalize">{overallStatus === "healthy" ? "All Systems Operational" : overallStatus === "degraded" ? "Some Issues Detected" : "Service Disruption"}</span>
</div>
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
<RefreshCcw className={`mr-2 h-4 w-4 ${refreshing ? "animate-spin" : ""}`} />
Refresh
</Button>
</div>
</div>
{/* Summary Cards */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Services</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{loading ? <Loader2 className="h-6 w-6 animate-spin" /> : healthChecks.length}
</div>
<p className="text-xs text-muted-foreground">monitored</p>
</CardContent>
</Card>
<Card className={healthy > 0 ? "border-green-200" : ""}>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-green-500" />
Healthy
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">
{loading ? <Loader2 className="h-6 w-6 animate-spin" /> : healthy}
</div>
</CardContent>
</Card>
<Card className={degraded > 0 ? "border-yellow-200" : ""}>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-yellow-500" />
Degraded
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-yellow-600">
{loading ? <Loader2 className="h-6 w-6 animate-spin" /> : degraded}
</div>
</CardContent>
</Card>
<Card className={down > 0 ? "border-red-200" : ""}>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<XCircle className="h-4 w-4 text-red-500" />
Down
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">
{loading ? <Loader2 className="h-6 w-6 animate-spin" /> : down}
</div>
</CardContent>
</Card>
</div>
<Tabs defaultValue="health">
<TabsList>
<TabsTrigger value="health">Health Checks</TabsTrigger>
<TabsTrigger value="alerts">
Alerts
{alerts.filter((a) => !a.resolved_at).length > 0 && (
<Badge variant="destructive" className="ml-2 h-5 w-5 rounded-full p-0 text-[10px]">
{alerts.filter((a) => !a.resolved_at).length}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="logs">RPC Logs</TabsTrigger>
</TabsList>
<TabsContent value="health" className="mt-4">
<Card>
<CardHeader>
<CardTitle>Service Health</CardTitle>
<CardDescription>Real-time health status of all services</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : healthChecks.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<Server className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No services being monitored</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Service</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Latency</TableHead>
<TableHead className="text-right">Uptime</TableHead>
<TableHead>Last Check</TableHead>
<TableHead>Details</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{healthChecks.map((check) => (
<TableRow key={check.service}>
<TableCell className="font-medium">{check.service}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{STATUS_ICON[check.status]}
<span className="capitalize">{check.status}</span>
</div>
</TableCell>
<TableCell className="text-right font-mono">
{check.latency_ms > 0 ? `${check.latency_ms}ms` : "—"}
</TableCell>
<TableCell className="text-right">
<span className={check.uptime_pct >= 99 ? "text-green-600" : check.uptime_pct >= 95 ? "text-yellow-600" : "text-red-600"}>
{check.uptime_pct.toFixed(1)}%
</span>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{new Date(check.last_check).toLocaleTimeString()}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{check.details || "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="alerts" className="mt-4">
<Card>
<CardHeader>
<CardTitle>Active Alerts</CardTitle>
<CardDescription>Current and recent alerts across all services</CardDescription>
</CardHeader>
<CardContent>
{alerts.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<CheckCircle className="h-12 w-12 mx-auto mb-4 text-green-500 opacity-50" />
<p className="font-medium">No active alerts</p>
<p className="text-sm mt-1">All systems are running normally</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Severity</TableHead>
<TableHead>Service</TableHead>
<TableHead>Message</TableHead>
<TableHead>Started</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{alerts.map((alert) => (
<TableRow key={alert.id}>
<TableCell>
<Badge variant={SEVERITY_BADGE[alert.severity]}>{alert.severity}</Badge>
</TableCell>
<TableCell className="font-medium">{alert.service}</TableCell>
<TableCell>{alert.message}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(alert.started_at).toLocaleString()}
</TableCell>
<TableCell>
{alert.resolved_at ? (
<Badge variant="outline">Resolved</Badge>
) : (
<Badge variant="destructive">Active</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="logs" className="mt-4">
<Card>
<CardHeader>
<CardTitle>RPC Request Logs</CardTitle>
<CardDescription>Recent API and RPC requests</CardDescription>
</CardHeader>
<CardContent>
{rpcLogs.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<Activity className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="font-medium">No recent requests</p>
<p className="text-sm mt-1">RPC logs will appear here as requests are made</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Status</TableHead>
<TableHead>Method</TableHead>
<TableHead>Chain</TableHead>
<TableHead className="text-right">Latency</TableHead>
<TableHead>Time</TableHead>
<TableHead>Request ID</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rpcLogs.map((log) => (
<TableRow key={log.id}>
<TableCell>
<Badge variant={log.status < 300 ? "default" : log.status < 500 ? "secondary" : "destructive"}>
{log.status}
</Badge>
</TableCell>
<TableCell className="font-mono text-sm">{log.method}</TableCell>
<TableCell>{log.chain}</TableCell>
<TableCell className="text-right font-mono text-sm">{log.latency_ms}ms</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(log.timestamp).toLocaleTimeString()}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">{log.request_id}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
+2 -1
View File
@@ -20,6 +20,7 @@ import {
User,
} from "lucide-react"
import { useAuth, OrgBadge } from "@/lib/auth"
import { getApiUrl } from "@/lib/api"
export default function SettingsPage() {
const { user, logout, isProduction } = useAuth()
@@ -38,7 +39,7 @@ export default function SettingsPage() {
setProjectName(localStorage.getItem("bootnode_project_name") || "")
setProjectDescription(localStorage.getItem("bootnode_project_description") || "")
setApiKey(localStorage.getItem("bootnode_api_key") || "")
setApiUrl(process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000")
setApiUrl(getApiUrl())
}, [])
function handleSaveProject() {
+294 -37
View File
@@ -1,6 +1,6 @@
"use client"
import { useState, useEffect } from "react"
import { useState, useEffect, useCallback } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
@@ -10,6 +10,33 @@ import {
Loader2, ChevronRight, Zap, AlertTriangle, Check, Copy, RefreshCcw
} from "lucide-react"
import Link from "next/link"
import { getApiUrl, getAuthHeaders, api } from "@/lib/api"
interface NodeResponse {
id: string
name: string
chain: string
network: string
status: string
region: string
node_type: string
uptime_seconds: number | null
block_height: number | null
sync_progress: number | null
peer_count: number | null
rpc_endpoint: string | null
created_at: string
}
interface StakingAsset {
chain: string
name: string
symbol: string
apy: number
price: number
staking_ratio: number
tvl: number
}
interface Validator {
id: string
@@ -20,6 +47,9 @@ interface Validator {
status: "active" | "pending" | "inactive"
uptime: number
commission: number
apy: number
name: string
region: string
}
interface WalletState {
@@ -28,6 +58,7 @@ interface WalletState {
chain: string | null
balance: number
kmsConfigured: boolean
mpcAddress: string | null
}
export default function StakingPage() {
@@ -37,21 +68,76 @@ export default function StakingPage() {
chain: null,
balance: 0,
kmsConfigured: false,
mpcAddress: null,
})
const [validators, setValidators] = useState<Validator[]>([])
const [stakingData, setStakingData] = useState<StakingAsset[]>([])
const [loading, setLoading] = useState(false)
const [validatorsLoading, setValidatorsLoading] = useState(true)
const [staking, setStaking] = useState(false)
const [stakeAmount, setStakeAmount] = useState("")
const [copied, setCopied] = useState(false)
function getAuthHeaders(): Record<string, string> {
if (typeof window === "undefined") return {}
const token = localStorage.getItem("bootnode_token")
const apiKey = localStorage.getItem("bootnode_api_key")
if (token) return { "Authorization": `Bearer ${token}` }
if (apiKey) return { "X-API-Key": apiKey }
return {}
}
// Fetch validators (nodes with node_type=validator) on mount
const fetchValidators = useCallback(async () => {
setValidatorsLoading(true)
try {
const [nodesRes, stakingRes] = await Promise.allSettled([
api<NodeResponse[]>("/nodes?node_type=validator"),
api<{ staking: StakingAsset[] }>("/nodes/staking"),
])
async function connectWallet(type: "metamask" | "ledger" | "kms") {
// Build APY lookup from staking data
const apyMap: Record<string, number> = {}
if (stakingRes.status === "fulfilled") {
const assets = stakingRes.value.staking || []
setStakingData(assets)
for (const asset of assets) {
apyMap[asset.chain] = asset.apy
}
}
// Map nodes to Validator interface
if (nodesRes.status === "fulfilled") {
const nodes = nodesRes.value
const mapped: Validator[] = nodes.map((node) => {
const uptimeHours = node.uptime_seconds
? Math.floor(node.uptime_seconds / 3600)
: 0
const uptimePercent = node.uptime_seconds
? Math.min(99.9, 95 + (node.uptime_seconds / 86400) * 0.1)
: 0
return {
id: node.id,
chain: node.chain,
name: node.name,
address: node.rpc_endpoint || `${node.region}.${node.chain}.bootno.de`,
stake: 0, // Populated from on-chain data in production
rewards: 0,
status: node.status === "running" ? "active"
: node.status === "provisioning" || node.status === "syncing" ? "pending"
: "inactive",
uptime: Math.round(uptimePercent * 10) / 10,
commission: 5.0, // Default commission rate
apy: apyMap[node.chain] || 0,
region: node.region,
}
})
setValidators(mapped)
}
} catch (e) {
console.error("Failed to fetch validators:", e)
} finally {
setValidatorsLoading(false)
}
}, [])
useEffect(() => {
fetchValidators()
}, [fetchValidators])
async function connectWallet(type: "metamask" | "ledger" | "kms" | "mpc") {
setLoading(true)
try {
if (type === "metamask") {
@@ -74,16 +160,16 @@ export default function StakingPage() {
chain: parseInt(chainId, 16) === 1 ? "ethereum" : `chain-${parseInt(chainId, 16)}`,
balance: parseInt(balance, 16) / 1e18,
kmsConfigured: false,
mpcAddress: null,
})
}
}
} else if (type === "ledger") {
// Ledger connection would use @ledgerhq/hw-transport-webusb
alert("Ledger connection requires hardware wallet. Please connect your Ledger device.")
} else if (type === "kms") {
// Check if KMS (Hanzo KSM) is configured
// Check if KMS (Hanzo KMS) is configured via /v1/keys/status
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/v1/keys/status`,
`${getApiUrl()}/v1/keys/status`,
{ headers: getAuthHeaders() }
)
if (res.ok) {
@@ -94,9 +180,58 @@ export default function StakingPage() {
chain: data.default_chain || "multi-chain",
balance: data.total_balance || 0,
kmsConfigured: true,
mpcAddress: data.default_address || null,
})
} else {
alert("KMS not configured. Please configure Hanzo KSM first.")
alert("KMS not configured. Please configure Hanzo KMS first.")
}
} else if (type === "mpc") {
// Connect via MPC wallet - check status first, create if needed
try {
const status = await api<{
configured: boolean
default_address: string | null
default_chain: string | null
total_wallets: number
total_balance: number
}>("/mpc/status")
if (status.configured && status.default_address) {
setWallet({
connected: true,
address: status.default_address,
chain: status.default_chain || "ethereum",
balance: status.total_balance || 0,
kmsConfigured: true,
mpcAddress: status.default_address,
})
} else {
// Create a new MPC wallet
const newWallet = await api<{
address: string
chain: string
threshold: number
num_shares: number
}>("/mpc/create", {
method: "POST",
body: JSON.stringify({
chain: "ethereum",
threshold: 2,
num_shares: 3,
}),
})
setWallet({
connected: true,
address: newWallet.address,
chain: newWallet.chain,
balance: 0,
kmsConfigured: true,
mpcAddress: newWallet.address,
})
}
} catch (e) {
console.error("MPC wallet connection failed:", e)
alert("Failed to connect MPC wallet. Check API configuration.")
}
}
} catch (e) {
@@ -113,6 +248,7 @@ export default function StakingPage() {
chain: null,
balance: 0,
kmsConfigured: false,
mpcAddress: null,
})
}
@@ -124,6 +260,55 @@ export default function StakingPage() {
}
}
async function handleQuickStake() {
if (!wallet.connected || !wallet.mpcAddress) {
alert("Connect an MPC wallet to use Quick Stake.")
return
}
const amount = parseFloat(stakeAmount)
if (!amount || amount <= 0) {
alert("Enter a valid stake amount.")
return
}
setStaking(true)
try {
// Sign a staking transaction via MPC
const result = await api<{
signature: string
signer: string
raw_transaction: string | null
v: number
r: string
s: string
}>(`/mpc/${wallet.mpcAddress}/sign`, {
method: "POST",
body: JSON.stringify({
shares: [], // Server-side KMS shares used automatically
transaction: {
to: "0x0000000000000000000000000000000000000001", // Staking contract
value: `0x${Math.floor(amount * 1e18).toString(16)}`,
data: "0xd0e30db0", // deposit()
chainId: 1,
gas: 100000,
},
}),
})
alert(`Staking transaction signed. Signer: ${result.signer}`)
setStakeAmount("")
// Refresh validators
fetchValidators()
} catch (e: any) {
console.error("Staking failed:", e)
alert(`Staking failed: ${e.message || "Unknown error"}`)
} finally {
setStaking(false)
}
}
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
@@ -167,7 +352,9 @@ export default function StakingPage() {
{wallet.address?.slice(0, 8)}...{wallet.address?.slice(-6)}
</p>
<p className="text-xs text-muted-foreground">
{wallet.chain} {wallet.kmsConfigured && "• KMS Secured"}
{wallet.chain}
{wallet.kmsConfigured && " \u2022 MPC Secured"}
{wallet.mpcAddress && " \u2022 Threshold 2-of-3"}
</p>
</div>
</div>
@@ -196,14 +383,14 @@ export default function StakingPage() {
</div>
</div>
) : (
<div className="grid gap-4 md:grid-cols-3">
<div className="grid gap-4 md:grid-cols-4">
<Button
variant="outline"
className="h-24 flex-col gap-2"
onClick={() => connectWallet("metamask")}
disabled={loading}
>
<Wallet className="h-8 w-8" />
{loading ? <Loader2 className="h-8 w-8 animate-spin" /> : <Wallet className="h-8 w-8" />}
<span>MetaMask / Browser</span>
</Button>
<Button
@@ -217,12 +404,21 @@ export default function StakingPage() {
</Button>
<Button
variant="outline"
className="h-24 flex-col gap-2 border-primary/50 bg-primary/5"
className="h-24 flex-col gap-2"
onClick={() => connectWallet("kms")}
disabled={loading}
>
<Key className="h-8 w-8" />
<span>Hanzo KMS</span>
</Button>
<Button
variant="outline"
className="h-24 flex-col gap-2 border-primary/50 bg-primary/5"
onClick={() => connectWallet("mpc")}
disabled={loading}
>
{loading ? <Loader2 className="h-8 w-8 animate-spin" /> : <Lock className="h-8 w-8" />}
<span>MPC Wallet</span>
<Badge variant="secondary" className="text-xs">Recommended</Badge>
</Button>
</div>
@@ -238,14 +434,15 @@ export default function StakingPage() {
<div>
<p className="font-medium">Enterprise Key Management</p>
<p className="text-sm text-muted-foreground">
For production deployments, use Hanzo KMS (Key Management System) with HSM backing.
Supports multi-sig, role-based access, and hardware security modules for maximum security.
For production deployments, use MPC wallets with threshold signing (2-of-3).
Keys are split using Shamir&apos;s Secret Sharing and never stored in full.
Supports HSM backing, multi-sig, and role-based access.
</p>
<div className="flex gap-2 mt-2">
<Button variant="outline" size="sm" asChild>
<Link href="/dashboard/settings/keys">
<Key className="mr-2 h-4 w-4" />
Configure KMS
Configure MPC
</Link>
</Button>
<Button variant="ghost" size="sm" asChild>
@@ -267,16 +464,29 @@ export default function StakingPage() {
<CardTitle>Your Validators</CardTitle>
<CardDescription>Active validators across all chains</CardDescription>
</div>
<Button asChild>
<Link href="/dashboard/infrastructure/nodes">
<Server className="mr-2 h-4 w-4" />
Deploy Validator
</Link>
</Button>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={fetchValidators} disabled={validatorsLoading}>
{validatorsLoading
? <Loader2 className="mr-2 h-4 w-4 animate-spin" />
: <RefreshCcw className="mr-2 h-4 w-4" />}
Refresh
</Button>
<Button asChild>
<Link href="/dashboard/infrastructure/nodes">
<Server className="mr-2 h-4 w-4" />
Deploy Validator
</Link>
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{validators.length === 0 ? (
{validatorsLoading ? (
<div className="text-center p-12 text-muted-foreground">
<Loader2 className="h-12 w-12 mx-auto mb-4 animate-spin opacity-50" />
<p className="font-medium">Loading validators...</p>
</div>
) : validators.length === 0 ? (
<div className="text-center p-12 text-muted-foreground">
<Server className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="font-medium">No validators yet</p>
@@ -290,22 +500,40 @@ export default function StakingPage() {
) : (
<div className="space-y-4">
{validators.map((v) => (
<div key={v.id} className="flex items-center justify-between p-4 border rounded-lg">
<div key={v.id} className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-4">
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
<Server className="h-5 w-5" />
</div>
<div>
<p className="font-medium">{v.chain} Validator</p>
<p className="font-medium">{v.name || `${v.chain} Validator`}</p>
<p className="text-xs text-muted-foreground font-mono">{v.address}</p>
<p className="text-xs text-muted-foreground">{v.region}</p>
</div>
</div>
<div className="flex items-center gap-6">
{v.apy > 0 && (
<div className="text-right">
<p className="text-sm font-medium text-green-500">{v.apy.toFixed(1)}% APY</p>
<p className="text-xs text-muted-foreground">{v.commission}% commission</p>
</div>
)}
<div className="text-right">
<p className="font-medium">{v.stake} staked</p>
<p className="text-xs text-green-500">+{v.rewards} earned</p>
<p className="font-medium">
{v.stake > 0 ? `${v.stake} staked` : "No stake"}
</p>
{v.rewards > 0 && (
<p className="text-xs text-green-500">+{v.rewards} earned</p>
)}
{v.uptime > 0 && (
<p className="text-xs text-muted-foreground">{v.uptime}% uptime</p>
)}
</div>
<Badge variant={v.status === "active" ? "default" : "secondary"}>
<Badge variant={
v.status === "active" ? "default"
: v.status === "pending" ? "secondary"
: "destructive"
}>
{v.status}
</Badge>
</div>
@@ -327,11 +555,40 @@ export default function StakingPage() {
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
One-click staking deployment with optimized settings for maximum yield.
{wallet.mpcAddress
? "Sign a staking transaction with your MPC wallet."
: "One-click staking deployment with optimized settings for maximum yield."}
</p>
<Button className="w-full" disabled={!wallet.connected}>
{wallet.connected ? "Stake Now" : "Connect Wallet First"}
</Button>
{wallet.connected && wallet.mpcAddress ? (
<div className="space-y-3">
<Input
type="number"
placeholder="Amount (ETH)"
value={stakeAmount}
onChange={(e) => setStakeAmount(e.target.value)}
min="0"
step="0.01"
/>
<Button
className="w-full"
onClick={handleQuickStake}
disabled={staking || !stakeAmount}
>
{staking ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Signing...
</>
) : (
"Stake Now"
)}
</Button>
</div>
) : (
<Button className="w-full" disabled={!wallet.connected}>
{wallet.connected ? "Connect MPC Wallet" : "Connect Wallet First"}
</Button>
)}
</CardContent>
</Card>
+45
View File
@@ -0,0 +1,45 @@
"use client"
import { getChainIcon, getChainColor, getChainInitial } from "@/lib/chain-icons"
interface ChainIconProps {
chain: string
name?: string
size?: number
className?: string
}
/** Renders a chain icon - real PNG if available, color circle with initial as fallback */
export function ChainIcon({ chain, name, size = 24, className }: ChainIconProps) {
const iconSrc = getChainIcon(chain)
if (iconSrc) {
return (
<img
src={iconSrc}
alt={name || chain}
width={size}
height={size}
className={`rounded-full ${className || ""}`}
/>
)
}
const color = getChainColor(chain)
const initial = getChainInitial(chain, name)
return (
<span
className={`inline-flex items-center justify-center rounded-full text-white font-bold ${className || ""}`}
style={{
width: size,
height: size,
backgroundColor: color,
fontSize: size * 0.45,
lineHeight: 1,
}}
>
{initial}
</span>
)
}
@@ -0,0 +1,152 @@
"use client"
import { useMemo, useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
export interface ChainOnboardingItem {
id: string
deployKey: string
name: string
category: string
icon: string
live: boolean
}
interface ChainOnboardingSelectorProps {
items: ChainOnboardingItem[]
onDeploySelected: (deployKeys: string[]) => Promise<void> | void
isDeploying?: boolean
}
export function ChainOnboardingSelector({
items,
onDeploySelected,
isDeploying = false,
}: ChainOnboardingSelectorProps) {
const [query, setQuery] = useState("")
const [selected, setSelected] = useState<string[]>([])
const visibleItems = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) {
return items
}
return items.filter((item) =>
`${item.name} ${item.category} ${item.deployKey}`.toLowerCase().includes(q),
)
}, [items, query])
const liveSelected = selected.filter((id) =>
items.some((item) => item.id === id && item.live),
)
const toggle = (id: string, live: boolean) => {
if (!live) return
setSelected((prev) =>
prev.includes(id) ? prev.filter((v) => v !== id) : [...prev, id],
)
}
const deploySelected = async () => {
if (liveSelected.length === 0) return
const deployKeys = liveSelected
.map((id) => items.find((item) => item.id === id)?.deployKey)
.filter((value): value is string => Boolean(value))
await onDeploySelected(Array.from(new Set(deployKeys)))
setSelected([])
}
return (
<div className="rounded-2xl border border-zinc-200 bg-gradient-to-b from-white to-zinc-50 p-4 md:p-6">
<div className="mb-4 text-center">
<h3 className="text-2xl font-semibold tracking-tight">What chains are you building on?</h3>
<p className="mt-1 text-sm text-zinc-500">Select your stack now. You can always add more later.</p>
</div>
<div className="mb-4">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search chains (e.g. Ethereum, Base, Solana)"
className="h-10"
/>
</div>
<div className="grid grid-cols-2 gap-2">
{visibleItems.map((item) => {
const active = selected.includes(item.id)
return (
<button
key={item.id}
type="button"
disabled={!item.live}
onClick={() => toggle(item.id, item.live)}
className={`flex items-center justify-between rounded-xl border px-3 py-2.5 text-left transition ${
active
? "border-blue-600 bg-blue-50 text-blue-700"
: item.live
? "border-zinc-200 bg-white text-zinc-800 hover:border-zinc-400"
: "border-zinc-200 bg-zinc-100 text-zinc-400"
} ${!item.live ? "cursor-not-allowed" : ""}`}
>
<span className="flex items-center gap-2">
<span
className={`inline-flex h-4 w-4 items-center justify-center rounded-sm border ${
active ? "border-blue-600 bg-blue-600 text-white" : "border-zinc-300"
}`}
>
{active ? "✓" : ""}
</span>
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-zinc-100 text-sm overflow-hidden">
{item.icon.startsWith("/") ? (
<img src={item.icon} alt={item.name} className="h-6 w-6 rounded-full object-cover" />
) : (
item.icon
)}
</span>
<span className="font-medium">{item.name}</span>
</span>
<span
className={`rounded-full px-2 py-1 text-[10px] font-semibold uppercase tracking-wide ${
item.live
? "bg-emerald-100 text-emerald-700"
: "bg-zinc-200 text-zinc-500"
}`}
>
{item.live ? "Live" : "Soon"}
</span>
</button>
)
})}
</div>
<div className="mt-4 flex flex-wrap items-center justify-between gap-2 border-t border-zinc-200 pt-4">
<p className="text-sm text-zinc-600">
{liveSelected.length} live chain{liveSelected.length === 1 ? "" : "s"} selected
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setSelected([])}
disabled={selected.length === 0 || isDeploying}
>
Clear
</Button>
<Button
size="sm"
onClick={deploySelected}
disabled={liveSelected.length === 0 || isDeploying}
>
{isDeploying ? "Deploying…" : "Deploy Selected Chains"}
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,132 @@
"use client"
import { useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { ArrowRight, Search } from "lucide-react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { buildCommandItems } from "@/lib/dashboard-navigation"
interface DashboardCommandPaletteProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function DashboardCommandPalette({
open,
onOpenChange,
}: DashboardCommandPaletteProps) {
const router = useRouter()
const [query, setQuery] = useState("")
const items = useMemo(() => buildCommandItems(), [])
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return items
return items.filter((item) => {
const haystack = [item.title, item.description, ...item.keywords]
.join(" ")
.toLowerCase()
return haystack.includes(q)
})
}, [items, query])
const navigationItems = filtered.filter((item) => item.section === "Navigation")
const actionItems = filtered.filter((item) => item.section === "Actions")
const run = (href: string) => {
router.push(href)
onOpenChange(false)
setQuery("")
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl border-zinc-200 p-0 sm:rounded-2xl">
<DialogHeader className="border-b border-zinc-200 px-5 py-4">
<DialogTitle className="text-xl">Command Center</DialogTitle>
<DialogDescription>
Jump anywhere in the cloud console with <kbd className="rounded border border-zinc-300 px-1 py-0.5 text-xs">⌘K</kbd>.
</DialogDescription>
</DialogHeader>
<div className="p-4">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
autoFocus
placeholder="Search routes, actions, billing, chain deploy..."
className="h-11 pl-9"
/>
</div>
<div className="mt-4 max-h-[420px] overflow-y-auto pr-1">
<Section
title="Actions"
items={actionItems}
onSelect={run}
/>
<Section
title="Navigation"
items={navigationItems}
onSelect={run}
/>
{filtered.length === 0 ? (
<div className="rounded-lg border border-dashed border-zinc-300 p-6 text-center text-sm text-zinc-500">
No results for "{query}".
</div>
) : null}
</div>
</div>
</DialogContent>
</Dialog>
)
}
function Section({
title,
items,
onSelect,
}: {
title: string
items: Array<{ id: string; title: string; description: string; href: string }>
onSelect: (href: string) => void
}) {
if (items.length === 0) return null
return (
<div className="mb-4">
<p className="mb-2 px-1 text-xs font-semibold uppercase tracking-[0.14em] text-zinc-500">
{title}
</p>
<div className="space-y-1">
{items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => onSelect(item.href)}
className="flex w-full items-start justify-between gap-3 rounded-lg border border-transparent px-3 py-2 text-left transition hover:border-zinc-200 hover:bg-zinc-50"
>
<div>
<p className="text-sm font-medium text-zinc-900">{item.title}</p>
<p className="text-xs text-zinc-500">{item.description}</p>
</div>
<ArrowRight className="mt-1 h-4 w-4 text-zinc-400" />
</button>
))}
</div>
</div>
)
}
+677
View File
@@ -0,0 +1,677 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
Activity,
AlertTriangle,
CheckCircle,
ChevronDown,
ChevronUp,
Code2,
GripHorizontal,
Maximize2,
Minimize2,
RefreshCcw,
Send,
Terminal,
X,
XCircle,
} from "lucide-react"
import { getApiUrl, getAuthHeaders as getAuth } from "@/lib/api"
// ─── Types ─────────────────────────────────────────────
interface LogEntry {
id: string
status: number
method: string
path: string
latency_ms: number
timestamp: string
request_id: string
ip?: string
body?: string
response?: string
}
interface EventEntry {
id: string
type: string
description: string
timestamp: string
source: string
api_version?: string
data?: Record<string, unknown>
}
interface HealthStatus {
service: string
status: "healthy" | "degraded" | "down"
latency_ms: number
details?: string
}
interface ShellHistoryItem {
command: string
output: string
timestamp: string
error?: boolean
}
// ─── Helpers ───────────────────────────────────────────
const getAPIURL = () => getApiUrl()
const getAuthHeaders = () => getAuth()
function statusColor(status: number): string {
if (status < 300) return "text-green-400"
if (status < 400) return "text-yellow-400"
return "text-red-400"
}
function statusBg(status: number): string {
if (status < 300) return "bg-green-500/20 text-green-400 border-green-500/30"
if (status < 400) return "bg-yellow-500/20 text-yellow-400 border-yellow-500/30"
return "bg-red-500/20 text-red-400 border-red-500/30"
}
function timeAgo(timestamp: string): string {
const diff = Date.now() - new Date(timestamp).getTime()
if (diff < 60000) return `${Math.floor(diff / 1000)}s ago`
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
return new Date(timestamp).toLocaleTimeString()
}
// ─── Component ─────────────────────────────────────────
export function DeveloperWorkbench() {
const [open, setOpen] = useState(false)
const [expanded, setExpanded] = useState(false)
const [height, setHeight] = useState(350)
const [tab, setTab] = useState("overview")
const [refreshing, setRefreshing] = useState(false)
// Data
const [logs, setLogs] = useState<LogEntry[]>([])
const [events, setEvents] = useState<EventEntry[]>([])
const [health, setHealth] = useState<HealthStatus[]>([])
const [selectedLog, setSelectedLog] = useState<LogEntry | null>(null)
const [selectedEvent, setSelectedEvent] = useState<EventEntry | null>(null)
// Shell
const [shellInput, setShellInput] = useState("")
const [shellHistory, setShellHistory] = useState<ShellHistoryItem[]>([])
const shellEndRef = useRef<HTMLDivElement>(null)
// Overview stats
const [overviewStats, setOverviewStats] = useState({
totalRequests: 0,
errorRate: 0,
avgLatency: 0,
activeAlerts: 0,
})
// Drag resize
const dragRef = useRef<{ startY: number; startHeight: number } | null>(null)
const handleMouseDown = useCallback((e: React.MouseEvent) => {
dragRef.current = { startY: e.clientY, startHeight: height }
const onMove = (ev: MouseEvent) => {
if (!dragRef.current) return
const delta = dragRef.current.startY - ev.clientY
const newHeight = Math.max(200, Math.min(window.innerHeight - 100, dragRef.current.startHeight + delta))
setHeight(newHeight)
}
const onUp = () => {
dragRef.current = null
document.removeEventListener("mousemove", onMove)
document.removeEventListener("mouseup", onUp)
}
document.addEventListener("mousemove", onMove)
document.addEventListener("mouseup", onUp)
}, [height])
// ─── Data Fetching ─────────────────────────────────
const fetchData = useCallback(async () => {
const headers = getAuthHeaders()
// Fetch health
try {
const res = await fetch(`${getAPIURL()}/health`)
const data = res.ok ? await res.json() : null
const checks: HealthStatus[] = [{
service: "Bootnode API",
status: data?.status === "healthy" ? "healthy" : "down",
latency_ms: 0,
details: data ? `v${data.version}` : "Unreachable",
}]
// Check nodes
const nodesRes = await fetch(`${getAPIURL()}/v1/nodes/`, { headers }).catch(() => null)
if (nodesRes?.ok) {
const nodes = await nodesRes.json()
if (Array.isArray(nodes)) {
for (const node of nodes.slice(0, 5)) {
checks.push({
service: `${node.name}`,
status: node.status === "running" ? "healthy" : "down",
latency_ms: 0,
details: `${node.chain}/${node.network}`,
})
}
}
}
setHealth(checks)
// Build overview stats
setOverviewStats({
totalRequests: logs.length,
errorRate: logs.length > 0 ? (logs.filter(l => l.status >= 400).length / logs.length) * 100 : 0,
avgLatency: logs.length > 0 ? Math.round(logs.reduce((a, l) => a + l.latency_ms, 0) / logs.length) : 0,
activeAlerts: checks.filter(c => c.status !== "healthy").length,
})
} catch {
setHealth([{
service: "Bootnode API",
status: "down",
latency_ms: 0,
details: "Connection refused",
}])
}
}, [logs])
useEffect(() => {
if (!open) return
fetchData()
const interval = setInterval(fetchData, 10000)
return () => clearInterval(interval)
}, [open, fetchData])
const handleRefresh = useCallback(() => {
setRefreshing(true)
fetchData().finally(() => setRefreshing(false))
}, [fetchData])
// ─── Shell ─────────────────────────────────────────
async function handleShellSubmit(e: React.FormEvent) {
e.preventDefault()
if (!shellInput.trim()) return
const cmd = shellInput.trim()
setShellInput("")
try {
let output = ""
// Parse simple bootnode commands
if (cmd === "help" || cmd === "bootnode help") {
output = `Available commands:
bootnode chains list - List supported chains
bootnode nodes list - List deployed nodes
bootnode keys list - List API keys
bootnode health - Check API health
bootnode webhooks list - List webhooks
curl <endpoint> - Make API request`
} else if (cmd === "bootnode health" || cmd === "health") {
const res = await fetch(`${getAPIURL()}/health`)
output = JSON.stringify(await res.json(), null, 2)
} else if (cmd === "bootnode chains list" || cmd === "chains") {
const res = await fetch(`${getAPIURL()}/v1/chains`, { headers: getAuthHeaders() })
const data = res.ok ? await res.json() : { error: res.statusText }
output = JSON.stringify(data, null, 2)
} else if (cmd === "bootnode nodes list" || cmd === "nodes") {
const res = await fetch(`${getAPIURL()}/v1/nodes/`, { headers: getAuthHeaders() })
const data = res.ok ? await res.json() : { error: res.statusText }
output = JSON.stringify(data, null, 2)
} else if (cmd === "bootnode keys list" || cmd === "keys") {
const res = await fetch(`${getAPIURL()}/v1/auth/keys`, { headers: getAuthHeaders() })
const data = res.ok ? await res.json() : { error: res.statusText }
output = JSON.stringify(data, null, 2)
} else if (cmd === "bootnode webhooks list" || cmd === "webhooks") {
const res = await fetch(`${getAPIURL()}/v1/webhooks`, { headers: getAuthHeaders() })
const data = res.ok ? await res.json() : { error: res.statusText }
output = JSON.stringify(data, null, 2)
} else if (cmd.startsWith("curl ")) {
const endpoint = cmd.replace("curl ", "").trim()
const url = endpoint.startsWith("http") ? endpoint : `${getAPIURL()}${endpoint.startsWith("/") ? "" : "/"}${endpoint}`
const res = await fetch(url, { headers: getAuthHeaders() })
const data = await res.json().catch(() => res.text())
output = typeof data === "string" ? data : JSON.stringify(data, null, 2)
} else {
output = `Unknown command: ${cmd}\nType "help" for available commands.`
}
setShellHistory(prev => [...prev, { command: cmd, output, timestamp: new Date().toISOString() }])
} catch (err) {
setShellHistory(prev => [...prev, {
command: cmd,
output: `Error: ${err instanceof Error ? err.message : String(err)}`,
timestamp: new Date().toISOString(),
error: true,
}])
}
setTimeout(() => shellEndRef.current?.scrollIntoView({ behavior: "smooth" }), 50)
}
// ─── Toggle Button (when closed) ──────────────────
if (!open) {
return (
<button
onClick={() => setOpen(true)}
className="fixed bottom-0 left-1/2 -translate-x-1/2 z-50 flex items-center gap-2 rounded-t-lg border border-b-0 border-zinc-700 bg-zinc-900 px-4 py-1.5 text-sm text-zinc-300 shadow-lg transition hover:bg-zinc-800 hover:text-white"
>
<Code2 className="h-4 w-4" />
Developers
</button>
)
}
// ─── Panel ─────────────────────────────────────────
const panelHeight = expanded ? "100vh" : `${height}px`
return (
<div
className="fixed bottom-0 left-0 right-0 z-50 flex flex-col border-t border-zinc-700 bg-zinc-950 text-zinc-100 shadow-2xl"
style={{ height: panelHeight }}
>
{/* Drag Handle */}
{!expanded && (
<div
className="flex h-2 cursor-row-resize items-center justify-center hover:bg-zinc-800"
onMouseDown={handleMouseDown}
>
<GripHorizontal className="h-4 w-4 text-zinc-600" />
</div>
)}
{/* Header */}
<div className="flex items-center justify-between border-b border-zinc-800 px-4 py-1">
<Tabs value={tab} onValueChange={setTab} className="flex-1">
<div className="flex items-center justify-between">
<TabsList className="h-8 bg-transparent p-0 gap-0">
<TabsTrigger
value="overview"
className="h-8 rounded-none border-b-2 border-transparent px-3 text-xs data-[state=active]:border-white data-[state=active]:bg-transparent data-[state=active]:text-white text-zinc-400"
>
Overview
</TabsTrigger>
<TabsTrigger
value="logs"
className="h-8 rounded-none border-b-2 border-transparent px-3 text-xs data-[state=active]:border-white data-[state=active]:bg-transparent data-[state=active]:text-white text-zinc-400"
>
Logs
</TabsTrigger>
<TabsTrigger
value="events"
className="h-8 rounded-none border-b-2 border-transparent px-3 text-xs data-[state=active]:border-white data-[state=active]:bg-transparent data-[state=active]:text-white text-zinc-400"
>
Events
</TabsTrigger>
<TabsTrigger
value="health"
className="h-8 rounded-none border-b-2 border-transparent px-3 text-xs data-[state=active]:border-white data-[state=active]:bg-transparent data-[state=active]:text-white text-zinc-400"
>
Health
</TabsTrigger>
<TabsTrigger
value="shell"
className="h-8 rounded-none border-b-2 border-transparent px-3 text-xs data-[state=active]:border-white data-[state=active]:bg-transparent data-[state=active]:text-white text-zinc-400"
>
Shell
</TabsTrigger>
</TabsList>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7 text-zinc-400 hover:text-white hover:bg-zinc-800" onClick={handleRefresh}>
<RefreshCcw className={`h-3.5 w-3.5 ${refreshing ? "animate-spin" : ""}`} />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-zinc-400 hover:text-white hover:bg-zinc-800" onClick={() => setExpanded(!expanded)}>
{expanded ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-zinc-400 hover:text-white hover:bg-zinc-800" onClick={() => { setOpen(false); setExpanded(false) }}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* ─── Overview Tab ─────────────────────── */}
<TabsContent value="overview" className="mt-0 flex-1 overflow-auto p-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
<p className="text-xs text-zinc-500 mb-1">API Requests</p>
<p className="text-2xl font-bold">{overviewStats.totalRequests}</p>
<p className="text-xs text-zinc-500">this session</p>
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
<p className="text-xs text-zinc-500 mb-1">Error Rate</p>
<p className={`text-2xl font-bold ${overviewStats.errorRate > 5 ? "text-red-400" : "text-green-400"}`}>
{overviewStats.errorRate.toFixed(1)}%
</p>
<p className="text-xs text-zinc-500">of requests</p>
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
<p className="text-xs text-zinc-500 mb-1">Avg Latency</p>
<p className="text-2xl font-bold">{overviewStats.avgLatency}ms</p>
<p className="text-xs text-zinc-500">response time</p>
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
<p className="text-xs text-zinc-500 mb-1">Health</p>
<div className="flex items-center gap-2">
{health.every(h => h.status === "healthy") ? (
<>
<CheckCircle className="h-5 w-5 text-green-500" />
<span className="text-green-400 font-medium">All healthy</span>
</>
) : (
<>
<AlertTriangle className="h-5 w-5 text-yellow-500" />
<span className="text-yellow-400 font-medium">{overviewStats.activeAlerts} issues</span>
</>
)}
</div>
</div>
</div>
<div className="mt-4 rounded-lg border border-zinc-800 bg-zinc-900 p-4">
<h3 className="text-sm font-medium mb-3">Quick Actions</h3>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" className="border-zinc-700 text-zinc-300 hover:bg-zinc-800" onClick={() => setTab("logs")}>
View Logs
</Button>
<Button size="sm" variant="outline" className="border-zinc-700 text-zinc-300 hover:bg-zinc-800" onClick={() => setTab("health")}>
Health Check
</Button>
<Button size="sm" variant="outline" className="border-zinc-700 text-zinc-300 hover:bg-zinc-800" onClick={() => setTab("shell")}>
Open Shell
</Button>
<Button size="sm" variant="outline" className="border-zinc-700 text-zinc-300 hover:bg-zinc-800" onClick={() => window.open(`${getAPIURL()}/docs`, "_blank")}>
API Docs
</Button>
</div>
</div>
</TabsContent>
{/* ─── Logs Tab ─────────────────────────── */}
<TabsContent value="logs" className="mt-0 flex-1 overflow-auto">
<div className="flex h-full">
<div className={`flex-1 overflow-auto ${selectedLog ? "border-r border-zinc-800" : ""}`}>
{logs.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-zinc-500">
<Activity className="h-8 w-8 mb-3 opacity-50" />
<p className="text-sm font-medium">No logs yet</p>
<p className="text-xs mt-1">API request logs will appear here as requests are made</p>
<p className="text-xs mt-3 text-zinc-600">Try running commands in the Shell tab</p>
</div>
) : (
<table className="w-full text-xs">
<thead className="sticky top-0 bg-zinc-950 border-b border-zinc-800">
<tr>
<th className="px-3 py-2 text-left text-zinc-500 font-medium">Status</th>
<th className="px-3 py-2 text-left text-zinc-500 font-medium">Method</th>
<th className="px-3 py-2 text-left text-zinc-500 font-medium">Path</th>
<th className="px-3 py-2 text-right text-zinc-500 font-medium">Latency</th>
<th className="px-3 py-2 text-right text-zinc-500 font-medium">Time</th>
</tr>
</thead>
<tbody>
{logs.map((log) => (
<tr
key={log.id}
className={`cursor-pointer border-b border-zinc-900 hover:bg-zinc-900/50 ${selectedLog?.id === log.id ? "bg-zinc-900" : ""}`}
onClick={() => setSelectedLog(log)}
>
<td className="px-3 py-2">
<span className={`inline-flex items-center rounded border px-1.5 py-0.5 font-mono text-[10px] font-medium ${statusBg(log.status)}`}>
{log.status} {log.status < 300 ? "OK" : log.status < 500 ? "ERR" : "FAIL"}
</span>
</td>
<td className="px-3 py-2 font-mono text-zinc-300">{log.method}</td>
<td className="px-3 py-2 font-mono text-zinc-400 max-w-[300px] truncate">{log.path}</td>
<td className="px-3 py-2 text-right font-mono text-zinc-400">{log.latency_ms}ms</td>
<td className="px-3 py-2 text-right text-zinc-500">{timeAgo(log.timestamp)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{selectedLog && (
<div className="w-96 overflow-auto p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium">Request Details</h3>
<Button variant="ghost" size="icon" className="h-6 w-6 text-zinc-400" onClick={() => setSelectedLog(null)}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
<div className="space-y-3 text-xs">
<div>
<span className="text-zinc-500">Method</span>
<p className="font-mono text-zinc-200">{selectedLog.method} {selectedLog.path}</p>
</div>
<div>
<span className="text-zinc-500">Status</span>
<p className={statusColor(selectedLog.status)}>{selectedLog.status}</p>
</div>
<div>
<span className="text-zinc-500">Request ID</span>
<p className="font-mono text-zinc-400">{selectedLog.request_id}</p>
</div>
<div>
<span className="text-zinc-500">Latency</span>
<p className="font-mono text-zinc-300">{selectedLog.latency_ms}ms</p>
</div>
<div>
<span className="text-zinc-500">Time</span>
<p className="text-zinc-300">{new Date(selectedLog.timestamp).toLocaleString()}</p>
</div>
{selectedLog.response && (
<div>
<span className="text-zinc-500">Response</span>
<pre className="mt-1 rounded bg-zinc-900 p-2 text-[10px] text-zinc-400 overflow-auto max-h-48">
{selectedLog.response}
</pre>
</div>
)}
</div>
</div>
)}
</div>
</TabsContent>
{/* ─── Events Tab ───────────────────────── */}
<TabsContent value="events" className="mt-0 flex-1 overflow-auto">
<div className="flex h-full">
<div className={`flex-1 overflow-auto ${selectedEvent ? "border-r border-zinc-800" : ""}`}>
{events.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-zinc-500">
<Activity className="h-8 w-8 mb-3 opacity-50" />
<p className="text-sm font-medium">No events yet</p>
<p className="text-xs mt-1">Webhook events and chain events will appear here</p>
</div>
) : (
<div className="divide-y divide-zinc-900">
{events.map((event) => (
<div
key={event.id}
className={`px-4 py-3 cursor-pointer hover:bg-zinc-900/50 ${selectedEvent?.id === event.id ? "bg-zinc-900" : ""}`}
onClick={() => setSelectedEvent(event)}
>
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-zinc-200">{event.type}</span>
<span className="text-[10px] text-zinc-500">{timeAgo(event.timestamp)}</span>
</div>
<p className="text-xs text-zinc-500 mt-0.5">{event.description}</p>
</div>
))}
</div>
)}
</div>
{selectedEvent && (
<div className="w-96 overflow-auto p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium">Event</h3>
<Button variant="ghost" size="icon" className="h-6 w-6 text-zinc-400" onClick={() => setSelectedEvent(null)}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
<div className="space-y-3 text-xs">
<div>
<span className="text-zinc-500">Type</span>
<p className="font-mono text-zinc-200">{selectedEvent.type}</p>
</div>
<div>
<span className="text-zinc-500">Event ID</span>
<p className="font-mono text-zinc-400">{selectedEvent.id}</p>
</div>
<div>
<span className="text-zinc-500">Source</span>
<p className="text-zinc-300">{selectedEvent.source}</p>
</div>
<div>
<span className="text-zinc-500">Time</span>
<p className="text-zinc-300">{new Date(selectedEvent.timestamp).toLocaleString()}</p>
</div>
{selectedEvent.data && (
<div>
<span className="text-zinc-500">Event Data</span>
<pre className="mt-1 rounded bg-zinc-900 p-2 text-[10px] text-zinc-400 overflow-auto max-h-48">
{JSON.stringify(selectedEvent.data, null, 2)}
</pre>
</div>
)}
</div>
</div>
)}
</div>
</TabsContent>
{/* ─── Health Tab ────────────────────────── */}
<TabsContent value="health" className="mt-0 flex-1 overflow-auto p-4">
<div className="grid grid-cols-4 gap-3 mb-4">
<div className="rounded-lg border border-zinc-800 bg-zinc-900 p-3 text-center">
<p className="text-xs text-zinc-500">All</p>
<p className="text-lg font-bold">{health.length}</p>
</div>
<div className="rounded-lg border border-red-900/30 bg-red-950/20 p-3 text-center">
<p className="text-xs text-zinc-500">Critical</p>
<p className="text-lg font-bold text-red-400">{health.filter(h => h.status === "down").length}</p>
</div>
<div className="rounded-lg border border-yellow-900/30 bg-yellow-950/20 p-3 text-center">
<p className="text-xs text-zinc-500">Degraded</p>
<p className="text-lg font-bold text-yellow-400">{health.filter(h => h.status === "degraded").length}</p>
</div>
<div className="rounded-lg border border-green-900/30 bg-green-950/20 p-3 text-center">
<p className="text-xs text-zinc-500">Healthy</p>
<p className="text-lg font-bold text-green-400">{health.filter(h => h.status === "healthy").length}</p>
</div>
</div>
{health.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-zinc-500">
<CheckCircle className="h-8 w-8 mb-3 opacity-50" />
<p className="text-sm">Checking services...</p>
</div>
) : (
<div className="rounded-lg border border-zinc-800 overflow-hidden">
<table className="w-full text-xs">
<thead className="bg-zinc-900">
<tr>
<th className="px-4 py-2 text-left text-zinc-500 font-medium">Service</th>
<th className="px-4 py-2 text-left text-zinc-500 font-medium">Status</th>
<th className="px-4 py-2 text-right text-zinc-500 font-medium">Latency</th>
<th className="px-4 py-2 text-left text-zinc-500 font-medium">Details</th>
</tr>
</thead>
<tbody>
{health.map((check) => (
<tr key={check.service} className="border-t border-zinc-800">
<td className="px-4 py-2 font-medium text-zinc-200">{check.service}</td>
<td className="px-4 py-2">
<div className="flex items-center gap-1.5">
{check.status === "healthy" ? (
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
) : check.status === "degraded" ? (
<AlertTriangle className="h-3.5 w-3.5 text-yellow-500" />
) : (
<XCircle className="h-3.5 w-3.5 text-red-500" />
)}
<span className="capitalize">{check.status}</span>
</div>
</td>
<td className="px-4 py-2 text-right font-mono text-zinc-400">
{check.latency_ms > 0 ? `${check.latency_ms}ms` : "—"}
</td>
<td className="px-4 py-2 text-zinc-500">{check.details || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</TabsContent>
{/* ─── Shell Tab ─────────────────────────── */}
<TabsContent value="shell" className="mt-0 flex flex-col flex-1 overflow-hidden">
<div className="flex-1 overflow-auto p-4 font-mono text-xs">
{shellHistory.length === 0 && (
<div className="text-zinc-600 mb-4">
<pre className="text-zinc-500 mb-2">{`
____ _ _
| __ ) ___ ___ | |_ _ __ ___ __| | ___
| _ \\ / _ \\ / _ \\| __| '_ \\ / _ \\ / _\` |/ _ \\
| |_) | (_) | (_) | |_| | | | (_) | (_| | __/
|____/ \\___/ \\___/ \\__|_| |_|\\___/ \\__,_|\\___|
`}</pre>
<p>Welcome to Bootnode Shell!</p>
<p>Type <span className="text-zinc-300">help</span> to see available commands.</p>
<p>Use <span className="text-zinc-300">curl /v1/health</span> to test API endpoints.</p>
</div>
)}
{shellHistory.map((item, i) => (
<div key={i} className="mb-3">
<div className="flex items-center gap-2 text-zinc-300">
<span className="text-green-500">$</span>
<span>{item.command}</span>
</div>
<pre className={`mt-1 whitespace-pre-wrap ${item.error ? "text-red-400" : "text-zinc-500"}`}>
{item.output}
</pre>
</div>
))}
<div ref={shellEndRef} />
</div>
<form
onSubmit={handleShellSubmit}
className="flex items-center gap-2 border-t border-zinc-800 px-4 py-2"
>
<span className="text-green-500 text-xs font-mono">$</span>
<Input
value={shellInput}
onChange={(e) => setShellInput(e.target.value)}
placeholder="Enter a command..."
className="flex-1 h-8 border-0 bg-transparent text-xs font-mono text-zinc-200 placeholder:text-zinc-600 focus-visible:ring-0 focus-visible:ring-offset-0"
autoFocus={tab === "shell"}
/>
<Button type="submit" variant="ghost" size="icon" className="h-7 w-7 text-zinc-500 hover:text-white">
<Send className="h-3.5 w-3.5" />
</Button>
</form>
</TabsContent>
</Tabs>
</div>
</div>
)
}
+411
View File
@@ -0,0 +1,411 @@
"use client"
import { useCallback, useMemo, useRef, useState } from "react"
import { cn } from "@/lib/utils"
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface NodeLocation {
id: string
name: string
chain: string
network: string
region: string
status: "running" | "syncing" | "provisioning" | "stopped" | "error"
latitude?: number
longitude?: number
nodeType: string
peerCount?: number
syncProgress?: number
}
export interface NodeGlobeProps {
nodes: NodeLocation[]
onNodeClick?: (nodeId: string) => void
selectedNetwork?: string
className?: string
}
// ---------------------------------------------------------------------------
// Region coordinate mapping
// ---------------------------------------------------------------------------
const REGION_COORDS: Record<string, { lat: number; lng: number; label: string }> = {
// DigitalOcean regions
"nyc1": { lat: 40.7128, lng: -74.0060, label: "New York 1" },
"nyc3": { lat: 40.7128, lng: -74.0060, label: "New York 3" },
"sfo1": { lat: 37.7749, lng: -122.4194, label: "San Francisco 1" },
"sfo2": { lat: 37.7749, lng: -122.4194, label: "San Francisco 2" },
"sfo3": { lat: 37.7749, lng: -122.4194, label: "San Francisco 3" },
"ams3": { lat: 52.3676, lng: 4.9041, label: "Amsterdam" },
"sgp1": { lat: 1.3521, lng: 103.8198, label: "Singapore" },
"lon1": { lat: 51.5074, lng: -0.1278, label: "London" },
"fra1": { lat: 50.1109, lng: 8.6821, label: "Frankfurt" },
"tor1": { lat: 43.6532, lng: -79.3832, label: "Toronto" },
"blr1": { lat: 12.9716, lng: 77.5946, label: "Bangalore" },
"syd1": { lat: -33.8688, lng: 151.2093, label: "Sydney" },
// AWS-style
"us-east-1": { lat: 39.0438, lng: -77.4874, label: "N. Virginia" },
"us-west-2": { lat: 45.5152, lng: -122.6784, label: "Oregon" },
"eu-west-1": { lat: 53.3498, lng: -6.2603, label: "Ireland" },
"ap-southeast-1": { lat: 1.3521, lng: 103.8198, label: "Singapore" },
"ap-northeast-1": { lat: 35.6762, lng: 139.6503, label: "Tokyo" },
}
// ---------------------------------------------------------------------------
// Status colour palette
// ---------------------------------------------------------------------------
const STATUS_COLORS: Record<NodeLocation["status"], string> = {
running: "#22c55e",
syncing: "#eab308",
provisioning: "#f97316",
stopped: "#6b7280",
error: "#ef4444",
}
const STATUS_LABELS: Record<NodeLocation["status"], string> = {
running: "Running",
syncing: "Syncing",
provisioning: "Provisioning",
stopped: "Stopped",
error: "Error",
}
// ---------------------------------------------------------------------------
// Mercator projection helper
// ---------------------------------------------------------------------------
/** Convert lat/lng to x/y in 0..1 normalised Mercator space. */
function toMercator(lat: number, lng: number): { x: number; y: number } {
const x = (lng + 180) / 360
const latRad = (lat * Math.PI) / 180
const mercN = Math.log(Math.tan(Math.PI / 4 + latRad / 2))
const y = 0.5 - mercN / (2 * Math.PI)
return { x, y }
}
// ---------------------------------------------------------------------------
// Simplified world-outline SVG path data
//
// Hand-reduced continent outlines in Mercator projection coordinates, fitting
// a viewBox of 0 0 1000 500. Dark-themed: dark ocean, lighter landmasses.
// ---------------------------------------------------------------------------
const WORLD_PATH = [
// --- North America ---
"M 130,105 L 122,100 118,92 120,84 126,78 135,74 148,72 162,70",
"L 175,74 185,72 195,76 205,82 215,90 222,98 228,106 232,115",
"L 230,124 224,132 218,140 212,148 205,156 198,162 192,168",
"L 186,174 180,178 174,180 168,182 163,186 158,190 154,186",
"L 150,180 146,175 142,168 138,160 135,152 132,144 130,136",
"L 128,128 126,120 128,112 Z",
// --- Mexico / Central America ---
"M 154,186 L 158,192 162,198 165,205 168,212 172,218 175,224",
"L 178,228 176,232 172,230 168,226 164,222 160,218 156,214",
"L 153,208 152,200 152,194 Z",
// --- South America ---
"M 178,228 L 186,236 194,245 202,255 208,265 214,278 218,292",
"L 222,306 225,320 226,335 224,348 220,360 215,370 208,378",
"L 200,384 192,386 186,380 182,372 180,360 178,346 176,332",
"L 175,318 176,304 178,290 180,276 182,262 183,248 180,238 Z",
// --- Europe ---
"M 448,82 L 455,76 465,72 476,70 486,72 495,76 502,82 506,90",
"L 504,96 498,102 490,106 482,108 474,106 467,102 460,96",
"L 454,90 450,86 Z",
// --- British Isles ---
"M 432,82 L 437,76 443,78 444,84 440,88 434,86 Z",
// --- Scandinavia ---
"M 476,50 L 484,44 492,42 498,46 502,54 500,62 496,68 490,66",
"L 484,60 480,54 Z",
// --- Africa ---
"M 448,148 L 458,142 470,138 484,136 496,140 508,146 516,156",
"L 522,168 526,182 528,196 526,212 522,228 518,244 512,258",
"L 504,268 496,274 486,276 478,272 470,264 464,254 458,242",
"L 454,228 452,214 450,200 448,186 446,172 446,158 Z",
// --- Arabian Peninsula ---
"M 530,142 L 540,138 548,142 552,150 548,158 540,162 534,158",
"L 530,150 Z",
// --- India subcontinent ---
"M 582,132 L 594,128 604,132 612,142 616,156 612,170 606,180",
"L 598,186 590,184 584,176 580,164 578,152 580,140 Z",
// --- Southeast Asia peninsula ---
"M 622,156 L 630,150 638,154 640,162 636,170 628,172 624,164 Z",
// --- East Asia / China ---
"M 618,80 L 630,74 644,70 658,68 672,72 682,80 690,90 692,102",
"L 688,114 680,122 670,128 660,130 650,128 640,122 632,114",
"L 626,104 622,94 620,86 Z",
// --- Japan ---
"M 698,86 L 704,80 710,84 712,92 710,102 704,108 698,104 696,96 Z",
// --- Maritime Southeast Asia ---
"M 632,212 L 644,206 658,204 672,208 684,214 694,220 690,228",
"L 678,230 666,226 654,222 642,218 Z",
// --- Australia ---
"M 678,288 L 694,278 712,274 730,276 746,282 756,292 762,304",
"L 760,318 752,330 740,336 726,338 712,334 700,326 690,316",
"L 684,304 680,296 Z",
// --- New Zealand ---
"M 786,332 L 792,326 798,330 800,338 796,344 790,342 788,336 Z",
// --- Northern Asia / Russia ---
"M 508,52 L 530,46 558,42 588,38 620,36 654,34 686,38 712,44",
"L 732,52 742,60 736,66 722,64 706,60 686,56 664,54 642,52",
"L 620,50 598,48 576,48 556,50 536,52 520,54 512,54 Z",
].join(" ")
// ---------------------------------------------------------------------------
// Resolved node with pixel coordinates
// ---------------------------------------------------------------------------
type ResolvedNode = NodeLocation & {
px: number
py: number
regionLabel: string
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function NodeGlobe({
nodes,
onNodeClick,
selectedNetwork,
className,
}: NodeGlobeProps) {
const containerRef = useRef<HTMLDivElement>(null)
const [hoveredNode, setHoveredNode] = useState<ResolvedNode | null>(null)
const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 })
const MAP_W = 1000
const MAP_H = 500
// ------- derived data -------
const filteredNodes = useMemo(() => {
if (!selectedNetwork || selectedNetwork === "all") return nodes
return nodes.filter((n) => n.network === selectedNetwork)
}, [nodes, selectedNetwork])
const resolvedNodes = useMemo<ResolvedNode[]>(() => {
const out: ResolvedNode[] = []
for (const node of filteredNodes) {
let lat = node.latitude
let lng = node.longitude
let regionLabel = node.region
if ((lat == null || lng == null) && REGION_COORDS[node.region]) {
const rc = REGION_COORDS[node.region]
lat = rc.lat
lng = rc.lng
regionLabel = rc.label
}
if (lat == null || lng == null) continue
const { x, y } = toMercator(lat, lng)
out.push({ ...node, px: x * MAP_W, py: y * MAP_H, regionLabel })
}
return out
}, [filteredNodes])
const networks = useMemo(() => {
const s = new Set(nodes.map((n) => n.network))
return Array.from(s).sort()
}, [nodes])
// ------- event handlers -------
const handleMouseEnter = useCallback(
(node: ResolvedNode, e: React.MouseEvent) => {
setHoveredNode(node)
const rect = containerRef.current?.getBoundingClientRect()
if (rect) {
setTooltipPos({
x: e.clientX - rect.left + 14,
y: e.clientY - rect.top - 10,
})
}
},
[],
)
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (!hoveredNode) return
const rect = containerRef.current?.getBoundingClientRect()
if (rect) {
setTooltipPos({
x: e.clientX - rect.left + 14,
y: e.clientY - rect.top - 10,
})
}
},
[hoveredNode],
)
const handleMouseLeave = useCallback(() => {
setHoveredNode(null)
}, [])
// ------- render -------
return (
<div ref={containerRef} className={cn("relative w-full", className)}>
{/* Pulse keyframes injected once */}
<style>{`
@keyframes ng-dot-pulse {
0%, 100% { r: 5; opacity: 0.9 }
50% { r: 7; opacity: 1 }
}
@keyframes ng-ring-pulse {
0% { r: 5; opacity: 0.5 }
100% { r: 18; opacity: 0 }
}
.ng-dot-running { animation: ng-dot-pulse 2s ease-in-out infinite }
.ng-dot-syncing { animation: ng-dot-pulse 1.2s ease-in-out infinite }
.ng-ring { animation: ng-ring-pulse 2.5s ease-out infinite; pointer-events: none }
`}</style>
{/* SVG flat map */}
<svg
viewBox={`0 0 ${MAP_W} ${MAP_H}`}
className="w-full h-auto"
preserveAspectRatio="xMidYMid meet"
role="img"
aria-label="World map showing blockchain node locations"
>
{/* Ocean background */}
<rect width={MAP_W} height={MAP_H} rx={8} fill="#0a0e17" />
{/* Graticule grid */}
{Array.from({ length: 17 }, (_, i) => {
const x = (i + 1) * (MAP_W / 18)
return <line key={`v${i}`} x1={x} y1={0} x2={x} y2={MAP_H} stroke="#1e293b" strokeWidth={0.5} />
})}
{Array.from({ length: 8 }, (_, i) => {
const y = (i + 1) * (MAP_H / 9)
return <line key={`h${i}`} x1={0} y1={y} x2={MAP_W} y2={y} stroke="#1e293b" strokeWidth={0.5} />
})}
{/* Landmasses */}
<path d={WORLD_PATH} fill="#1a2332" stroke="#2d3f54" strokeWidth={1} strokeLinejoin="round" />
{/* Node markers */}
{resolvedNodes.map((node) => {
const color = STATUS_COLORS[node.status]
const hovered = hoveredNode?.id === node.id
// Stagger animation starts to avoid synchronised pulses
const delay = `${(node.id.charCodeAt(0) % 7) * 0.35}s`
return (
<g
key={node.id}
transform={`translate(${node.px},${node.py})`}
className="cursor-pointer"
onMouseEnter={(e) => handleMouseEnter(node, e)}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onClick={() => onNodeClick?.(node.id)}
role="button"
tabIndex={0}
aria-label={`${node.name} - ${STATUS_LABELS[node.status]}`}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onNodeClick?.(node.id)
}}
>
{/* Expanding ring for active statuses */}
{(node.status === "running" || node.status === "syncing") && (
<circle
r={5}
fill="none"
stroke={color}
strokeWidth={1.5}
className="ng-ring"
style={{ animationDelay: delay }}
/>
)}
{/* Ambient glow */}
<circle r={hovered ? 12 : 8} fill={color} opacity={0.15} />
{/* Primary dot */}
<circle
r={hovered ? 7 : 5}
fill={color}
stroke="#0a0e17"
strokeWidth={1.5}
opacity={0.9}
className={cn(
node.status === "running" && "ng-dot-running",
node.status === "syncing" && "ng-dot-syncing",
)}
style={{ animationDelay: delay }}
/>
{/* Centre highlight */}
<circle r={hovered ? 3 : 2} fill="#fff" opacity={0.6} />
</g>
)
})}
</svg>
{/* Tooltip */}
{hoveredNode && (
<div
className="absolute z-50 pointer-events-none rounded-lg border border-border bg-popover p-3 text-sm shadow-lg min-w-[180px]"
style={{
left: tooltipPos.x,
top: tooltipPos.y,
transform:
tooltipPos.x > (containerRef.current?.clientWidth ?? 800) * 0.7
? "translateX(-110%)"
: undefined,
}}
>
<div className="font-medium text-foreground">{hoveredNode.name}</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{hoveredNode.chain} / {hoveredNode.network}
</div>
<div className="mt-1.5 flex items-center gap-1.5">
<span
className="inline-block h-2 w-2 rounded-full"
style={{ background: STATUS_COLORS[hoveredNode.status] }}
/>
<span className="text-foreground">{STATUS_LABELS[hoveredNode.status]}</span>
</div>
<div className="mt-1 text-xs text-muted-foreground">{hoveredNode.regionLabel}</div>
<div className="text-xs text-muted-foreground">Type: {hoveredNode.nodeType}</div>
{hoveredNode.peerCount != null && (
<div className="text-xs text-muted-foreground">Peers: {hoveredNode.peerCount}</div>
)}
{hoveredNode.syncProgress != null && hoveredNode.status === "syncing" && (
<div className="text-xs text-muted-foreground">
Sync: {(hoveredNode.syncProgress * 100).toFixed(1)}%
</div>
)}
</div>
)}
{/* Legend */}
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 px-1 text-xs text-muted-foreground">
{(Object.keys(STATUS_COLORS) as NodeLocation["status"][]).map((status) => (
<div key={status} className="flex items-center gap-1.5">
<span
className="inline-block h-2.5 w-2.5 rounded-full"
style={{ background: STATUS_COLORS[status] }}
/>
<span>{STATUS_LABELS[status]}</span>
</div>
))}
{networks.length > 0 && (
<div className="ml-auto">
{resolvedNodes.length} node{resolvedNodes.length !== 1 ? "s" : ""}
{selectedNetwork && selectedNetwork !== "all" ? ` on ${selectedNetwork}` : ""}
</div>
)}
</div>
</div>
)
}
+14
View File
@@ -24,6 +24,20 @@ const getApiKey = () => {
return localStorage.getItem("bootnode_api_key")
}
/** Base API URL (no trailing slash, no /v1) */
export function getApiUrl(): string {
return getApiConfig().API_URL
}
/** Auth headers from token or API key in localStorage */
export function getAuthHeaders(): Record<string, string> {
const token = getAuthToken()
const apiKey = getApiKey()
if (token) return { "Authorization": `Bearer ${token}` }
if (apiKey) return { "X-API-Key": apiKey }
return {}
}
export async function api<T = unknown>(path: string, options?: RequestInit): Promise<T> {
const { API_URL } = getApiConfig()
const token = getAuthToken()
+96
View File
@@ -0,0 +1,96 @@
// Chain icon mapping - maps chain IDs to their PNG icon paths in /public/chains/
// For chains without dedicated icons, we use a color + initial fallback
/** Chains that have a real PNG/SVG icon in /public/chains/ */
export const CHAIN_ICON_MAP: Record<string, string> = {
ethereum: "/chains/ethereum.png",
solana: "/chains/solana.png",
arbitrum: "/chains/arbitrum.png",
avalanche: "/chains/avalanche.png",
base: "/chains/base.png",
bnb: "/chains/bnb.png",
bsc: "/chains/bnb.png",
blast: "/chains/blast.png",
celo: "/chains/celo.png",
lux: "/chains/lux.png",
monad: "/chains/monad.png",
optimism: "/chains/optimism.png",
polygon: "/chains/polygon.png",
zksync: "/chains/zksync.png",
zora: "/chains/zora.png",
soneium: "/chains/soneium.png",
worldchain: "/chains/worldchain.png",
}
/** Fallback colors for chains without dedicated icons */
export const CHAIN_COLORS: Record<string, string> = {
bitcoin: "#F7931A",
cosmos: "#2E3148",
polkadot: "#E6007A",
cardano: "#0033AD",
near: "#000000",
tron: "#FF0013",
ton: "#0088CC",
hedera: "#222222",
filecoin: "#0090FF",
xrpl: "#23292F",
fantom: "#1969FF",
cronos: "#002D74",
gnosis: "#04795B",
moonbeam: "#53CBC9",
sei: "#9B1C1C",
sui: "#4DA2FF",
aptos: "#000000",
linea: "#121212",
scroll: "#FFEEDA",
mantle: "#000000",
starknet: "#EC796B",
tezos: "#2C7DF7",
algorand: "#000000",
injective: "#00F2FE",
celestia: "#7B2BF9",
aleo: "#00C0F9",
bittensor: "#000000",
zoo: "#F59E0B",
pars: "#8B5CF6",
hanzo: "#EF4444",
dydx: "#6966FF",
osmosis: "#750BBB",
kava: "#FF564F",
akash: "#FF414C",
zetachain: "#00BC8D",
axelar: "#FFFFFF",
iota: "#131F37",
stacks: "#5546FF",
secret: "#1B1B1B",
harmony: "#00AEE9",
radix: "#060F8F",
waves: "#0055FF",
skale: "#000000",
avail: "#2B60DE",
casper: "#FF473E",
fetch: "#1D1D3F",
livepeer: "#00EB88",
multiversx: "#000000",
band: "#516AFF",
}
/** Get icon src for a chain, or null if no icon available (use fallback) */
export function getChainIcon(chainId: string): string | null {
return CHAIN_ICON_MAP[chainId.toLowerCase()] ?? null
}
/** Get fallback color for a chain without an icon */
export function getChainColor(chainId: string): string {
return CHAIN_COLORS[chainId.toLowerCase()] ?? "#6B7280"
}
/** Get the first letter(s) for a chain to show in the fallback circle */
export function getChainInitial(chainId: string, chainName?: string): string {
const name = chainName || chainId
// Special cases
if (chainId === "bsc" || chainId === "bnb") return "B"
if (chainId === "xrpl") return "X"
if (chainId === "zksync") return "zk"
return name.charAt(0).toUpperCase()
}
+186
View File
@@ -0,0 +1,186 @@
import type { LucideIcon } from "lucide-react"
import {
Activity,
BarChart3,
Building,
Coins,
CreditCard,
Database,
FolderKanban,
Globe,
HeartPulse,
Home,
Key,
Layers,
Network,
Settings,
Shield,
Wallet,
Webhook,
} from "lucide-react"
export interface DashboardNavigationItem {
name: string
href: string
icon: LucideIcon
description?: string
children?: DashboardNavigationItem[]
}
export const DASHBOARD_NAVIGATION: DashboardNavigationItem[] = [
{
name: "Overview",
href: "/dashboard",
icon: Home,
description: "Command center and quick actions",
},
{
name: "Infrastructure",
href: "/dashboard/infrastructure",
icon: Database,
description: "Deploy and monitor node infrastructure",
children: [
{ name: "Nodes", href: "/dashboard/infrastructure/nodes", icon: Globe },
{ name: "Networks", href: "/dashboard/infrastructure/networks", icon: Network },
{ name: "Clusters", href: "/dashboard/infrastructure/clusters", icon: Layers },
{ name: "Storage", href: "/dashboard/infrastructure/storage", icon: Database },
{ name: "Volumes", href: "/dashboard/infrastructure/volumes", icon: FolderKanban },
],
},
{
name: "Monitoring",
href: "/dashboard/monitoring",
icon: HeartPulse,
description: "Health checks, alerts, and service monitoring",
},
{
name: "Analytics",
href: "/dashboard/analytics",
icon: BarChart3,
description: "Usage, performance, and chain-level telemetry",
children: [
{ name: "Analytics", href: "/dashboard/analytics", icon: Activity },
{ name: "Usage", href: "/dashboard/usage", icon: Activity },
{ name: "Markets", href: "/dashboard/markets", icon: Coins },
{ name: "Gas", href: "/dashboard/gas", icon: Activity },
],
},
{
name: "Developer",
href: "/dashboard/api-keys",
icon: Key,
description: "Access credentials and event integrations",
children: [
{ name: "API Keys", href: "/dashboard/api-keys", icon: Key },
{ name: "Webhooks", href: "/dashboard/webhooks", icon: Webhook },
],
},
{
name: "Assets",
href: "/dashboard/assets",
icon: Wallet,
description: "Wallets, staking, and digital asset controls",
children: [
{ name: "Assets", href: "/dashboard/assets", icon: Coins },
{ name: "Wallets", href: "/dashboard/wallets", icon: Wallet },
{ name: "Staking", href: "/dashboard/staking", icon: Coins },
],
},
{
name: "Organization",
href: "/dashboard/organization",
icon: Building,
description: "Team, settings, and enterprise controls",
children: [
{ name: "Overview", href: "/dashboard/organization", icon: Building },
{ name: "Team", href: "/dashboard/organization/team", icon: Building },
{ name: "Settings", href: "/dashboard/organization/settings", icon: Settings },
{ name: "Billing", href: "/dashboard/organization/billing", icon: CreditCard },
],
},
]
export const DASHBOARD_ADMIN_NAVIGATION: DashboardNavigationItem[] = [
{
name: "Admin",
href: "/admin",
icon: Shield,
description: "Platform administration",
},
]
export interface DashboardCommandItem {
id: string
title: string
description: string
href: string
section: "Navigation" | "Actions"
keywords: string[]
}
export function buildCommandItems(): DashboardCommandItem[] {
const navigationItems: DashboardCommandItem[] = []
DASHBOARD_NAVIGATION.forEach((item) => {
navigationItems.push({
id: item.href,
title: item.name,
description: item.description || "",
href: item.href,
section: "Navigation",
keywords: [item.name.toLowerCase(), item.href],
})
item.children?.forEach((child) => {
navigationItems.push({
id: child.href,
title: `${item.name} · ${child.name}`,
description: child.description || item.description || "",
href: child.href,
section: "Navigation",
keywords: [
item.name.toLowerCase(),
child.name.toLowerCase(),
child.href,
],
})
})
})
navigationItems.push(
{
id: "deploy-node",
title: "Deploy New Node",
description: "Open node deploy experience with chain onboarding",
href: "/dashboard/infrastructure/nodes?deploy=1",
section: "Actions",
keywords: ["deploy", "node", "chain", "infrastructure"],
},
{
id: "open-billing",
title: "Open Billing Workspace",
description: "Business info, payment methods, invoices, tax and compliance",
href: "/dashboard/organization/billing",
section: "Actions",
keywords: ["billing", "invoice", "tax", "compliance", "payment"],
},
{
id: "new-api-key",
title: "Create API Key",
description: "Manage and rotate API credentials",
href: "/dashboard/api-keys?new=1",
section: "Actions",
keywords: ["api", "key", "credential"],
},
{
id: "new-webhook",
title: "Create Webhook",
description: "Add webhook destination for chain events",
href: "/dashboard/webhooks?new=1",
section: "Actions",
keywords: ["webhook", "event", "integration"],
}
)
return navigationItems
}
+1
View File
@@ -3,6 +3,7 @@ import type { NextConfig } from "next"
const nextConfig: NextConfig = {
// Modern React & Next.js features
reactStrictMode: true,
poweredByHeader: false,
// Production-ready output
output: "standalone",
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
<circle cx="50" cy="50" r="50" fill="#627EEA"/>
<path d="M50 15v25.87l21.87 9.78L50 15z" fill="white" fill-opacity="0.6"/>
<path d="M50 15L28.12 50.65 50 40.87V15z" fill="white"/>
<path d="M50 66.96v18.03l21.89-30.28L50 66.96z" fill="white" fill-opacity="0.6"/>
<path d="M50 84.99V66.95l-21.88-12.24L50 84.99z" fill="white"/>
<path d="M50 63.14l21.87-12.49L50 40.89v22.25z" fill="white" fill-opacity="0.2"/>
<path d="M28.12 50.65L50 63.14V40.89l-21.88 9.76z" fill="white" fill-opacity="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 589 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 690 B

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
<circle cx="50" cy="50" r="50" fill="#1a1a2e"/>
<circle cx="50" cy="50" r="45" fill="url(#lux-gradient)"/>
<path d="M50 20L65 45H35L50 20Z" fill="white"/>
<path d="M50 80L35 55H65L50 80Z" fill="white"/>
<path d="M35 55L50 45L65 55H35Z" fill="white" fill-opacity="0.8"/>
<defs>
<linearGradient id="lux-gradient" x1="5" y1="5" x2="95" y2="95" gradientUnits="userSpaceOnUse">
<stop stop-color="#6366f1"/>
<stop offset="1" stop-color="#8b5cf6"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 584 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB