mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-10-25 02:31:30 +00:00
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:
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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.")
|
||||
Reference in New Issue
Block a user