wip: preserve in-flight work

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-29 16:29:33 -07:00
co-authored by Hanzo Dev
parent 2e11985646
commit 89f1e2bfc1
17 changed files with 1006 additions and 2 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/hanzoai/bootnode
go 1.26.4
go 1.26.5
require (
github.com/go-chi/chi/v5 v5.1.0
+16
View File
@@ -0,0 +1,16 @@
apiVersion: v2
name: hanzo
description: Hanzo sovereign-L1 node deployment for K8s (networkID == EVM chainID; 0x0300 AI-mining precompile active at genesis)
type: application
version: 1.0.0
# luxd binary the image ships — Hanzo runs the same node, differentiated by genesis (networkID 36963, NOT Lux C-Chain 96369)
appVersion: "1.23.15"
keywords:
- blockchain
- hanzo
- validator
- ai-mining
- kubernetes
maintainers:
- name: Hanzo AI
email: [email protected]
+53
View File
@@ -0,0 +1,53 @@
# Hanzo sovereign-L1 — provisioning
Deploy **target**: DOKS `hanzo-k8s`, via **platform.hanzo.ai** / the hanzo operator
(GitOps). Namespaces `hanzo-{mainnet,testnet,devnet}`. Exposure: LoadBalancer →
`rpc.hanzo.network` / `explorer.hanzo.network`.
This chart deploys Hanzo's **own sovereign primary network** — `networkID == EVM
chainID` (mainnet **36963** / testnet 36962 / devnet 36964). Hanzo does **not** run on
the Lux C-Chain (96369); that is Lux-network only. The C-Chain genesis embedded in
`genesis/<env>.json` activates the **`0x0300` AI-mining precompile at genesis**
(`aiMiningConfig: {blockTimestamp: 0}`) — this is what makes desktop AI-mining
`verifyAndMintWork` claims settle on-chain.
## What's in the chart (committed, safe)
- `Chart.yaml`, `values.yaml` + `values-<env>.yaml` — networkID=chainID, custom-network
`upgradeConfig` (REQUIRED — 36963/62/64 have no built-in upgrade schedule), branding.
- `genesis/<env>.json` — platform genesis: networkID + the **Hanzo C-Chain genesis**
(chainId + `aiMiningConfig`) embedded as `cChainGenesis`.
- `templates/` — reused from the lux chart (parameterized by `.Values`).
## What the operator MUST provision (from KMS — NEVER commit keys)
1. **Validator staking keys** → create the `hanzo-staking` Secret (per `values.yaml
staking.secretName`) from KMS-generated keys, then populate, in `genesis/<env>.json`:
- `initialStakers[]` — each `{nodeID, rewardAddress, delegationFee, signer:{publicKey,
proofOfPossession}}` (PUBLIC keys only).
- `initialStakedFunds[]` — the staked P-chain addresses.
And set `bootstrap.nodeIDs` (in `values-<env>.yaml`) to the same NodeIDs.
2. **Treasury / allocations** → `allocations[]` in `genesis/<env>.json` (P-chain `luxAddr`
+ C-chain `ethAddr` funded addresses; KMS treasury). The C-Chain genesis already
pre-funds the dev treasury `0x9011E888251AB053B7bD1cdB598Db4f9DEd94714`.
3. **Derived C-Chain blockchain ID** (computed at `CreateChainTx` time, only known after
the network is created) →
- `networks.<env>.cChainID` in `values.yaml`.
- **`templates/statefulset.yaml`** plugin-copy lines still carry the **lux** C-Chain
blockchain ID (`mgj786NP7uDwBCcq6YwThhaN8FLyybkCa4zBWTQbNgmK6k9A6`). Replace with
Hanzo's derived C-Chain plugin/blockchain ID, or parameterize via a new
`.Values.cChainPluginID` before the second deploy.
## Bring-up order
1. Operator generates validator keys (KMS) → `hanzo-staking` Secret + fills genesis
`initialStakers`/`allocations` + `bootstrap.nodeIDs`.
2. `helm upgrade --install hanzo ./charts/hanzo -f values-mainnet.yaml -n hanzo-mainnet`
(via platform.hanzo.ai GitOps, not `helm` by hand).
3. After first genesis, capture the C-Chain blockchain ID → set `cChainID` + the
statefulset plugin ID → redeploy.
4. Point `rpc.hanzo.network` / `explorer.hanzo.network` LoadBalancers; verify
`eth_chainId` == `0x9063` (36963), validators healthy, `0x0300` responds.
## Why not a one-command deploy here
Validator BLS keys and the derived blockchain ID are network-creation outputs that live
in KMS / are computed by the node at genesis — they are intentionally **not** in git.
The desktop side (chainId 36963, the Rust claim signer, the C-Chain genesis) is already
committed; this chart + the operator's KMS provisioning complete the loop.
+11
View File
@@ -0,0 +1,11 @@
{
"networkID": 36964,
"message": "Hanzo AI \u2014 sovereign L1 (networkID == chainID; 0x0300 AI-mining precompile active at genesis)",
"startTime": 1766704800,
"initialStakeDuration": 31536000,
"initialStakeDurationOffset": 5400,
"allocations": [],
"initialStakedFunds": [],
"initialStakers": [],
"cChainGenesis": "{\"alloc\":{\"0200000000000000000000000000000000000005\":{\"balance\":\"0x0\",\"code\":\"0x01\",\"nonce\":\"0x1\"},\"9011E888251AB053B7bD1cdB598Db4f9DEd94714\":{\"balance\":\"0x193e5939a08ce9dbd480000000\"}},\"baseFeePerGas\":\"0x5d21dba00\",\"coinbase\":\"0x0000000000000000000000000000000000000000\",\"config\":{\"aiMiningConfig\":{\"blockTimestamp\":0},\"arrowGlacierBlock\":0,\"berlinBlock\":0,\"blobSchedule\":{\"cancun\":{\"baseFeeUpdateFraction\":3338477,\"max\":6,\"target\":3}},\"byzantiumBlock\":0,\"cancunTime\":0,\"chainId\":36964,\"constantinopleBlock\":0,\"eip150Block\":0,\"eip155Block\":0,\"eip158Block\":0,\"evmTimestamp\":0,\"feeConfig\":{\"baseFeeChangeDenominator\":36,\"blockGasCostStep\":200000,\"gasLimit\":1000000000,\"maxBlockGasCost\":1000000,\"minBaseFee\":25000000000,\"minBlockGasCost\":0,\"targetBlockRate\":2,\"targetGas\":500000000},\"grayGlacierBlock\":0,\"homesteadBlock\":0,\"istanbulBlock\":0,\"londonBlock\":0,\"mergeNetsplitBlock\":0,\"muirGlacierBlock\":0,\"petersburgBlock\":0,\"shanghaiTime\":0,\"terminalTotalDifficulty\":0,\"warpConfig\":{\"blockTimestamp\":0,\"quorumNumerator\":67,\"requirePrimaryNetworkSigners\":false}},\"difficulty\":\"0x0\",\"extraData\":\"0x\",\"gasLimit\":\"0x3b9aca00\",\"gasUsed\":\"0x0\",\"mixHash\":\"0x0000000000000000000000000000000000000000000000000000000000000000\",\"nonce\":\"0x0\",\"number\":\"0x0\",\"parentHash\":\"0x0000000000000000000000000000000000000000000000000000000000000000\",\"timestamp\":\"0x0\"}"
}
+11
View File
@@ -0,0 +1,11 @@
{
"networkID": 36963,
"message": "Hanzo AI \u2014 sovereign L1 (networkID == chainID; 0x0300 AI-mining precompile active at genesis)",
"startTime": 1766704800,
"initialStakeDuration": 31536000,
"initialStakeDurationOffset": 5400,
"allocations": [],
"initialStakedFunds": [],
"initialStakers": [],
"cChainGenesis": "{\"alloc\":{\"0200000000000000000000000000000000000005\":{\"balance\":\"0x0\",\"code\":\"0x01\",\"nonce\":\"0x1\"},\"9011E888251AB053B7bD1cdB598Db4f9DEd94714\":{\"balance\":\"0x193e5939a08ce9dbd480000000\"}},\"baseFeePerGas\":\"0x5d21dba00\",\"coinbase\":\"0x0000000000000000000000000000000000000000\",\"config\":{\"aiMiningConfig\":{\"blockTimestamp\":0},\"arrowGlacierBlock\":0,\"berlinBlock\":0,\"blobSchedule\":{\"cancun\":{\"baseFeeUpdateFraction\":3338477,\"max\":6,\"target\":3}},\"byzantiumBlock\":0,\"cancunTime\":0,\"chainId\":36963,\"constantinopleBlock\":0,\"eip150Block\":0,\"eip155Block\":0,\"eip158Block\":0,\"evmTimestamp\":0,\"feeConfig\":{\"baseFeeChangeDenominator\":36,\"blockGasCostStep\":200000,\"gasLimit\":1000000000,\"maxBlockGasCost\":1000000,\"minBaseFee\":25000000000,\"minBlockGasCost\":0,\"targetBlockRate\":2,\"targetGas\":500000000},\"grayGlacierBlock\":0,\"homesteadBlock\":0,\"istanbulBlock\":0,\"londonBlock\":0,\"mergeNetsplitBlock\":0,\"muirGlacierBlock\":0,\"petersburgBlock\":0,\"shanghaiTime\":0,\"terminalTotalDifficulty\":0,\"warpConfig\":{\"blockTimestamp\":0,\"quorumNumerator\":67,\"requirePrimaryNetworkSigners\":false}},\"difficulty\":\"0x0\",\"extraData\":\"0x\",\"gasLimit\":\"0x3b9aca00\",\"gasUsed\":\"0x0\",\"mixHash\":\"0x0000000000000000000000000000000000000000000000000000000000000000\",\"nonce\":\"0x0\",\"number\":\"0x0\",\"parentHash\":\"0x0000000000000000000000000000000000000000000000000000000000000000\",\"timestamp\":\"0x0\"}"
}
+11
View File
@@ -0,0 +1,11 @@
{
"networkID": 36962,
"message": "Hanzo AI \u2014 sovereign L1 (networkID == chainID; 0x0300 AI-mining precompile active at genesis)",
"startTime": 1766704800,
"initialStakeDuration": 31536000,
"initialStakeDurationOffset": 5400,
"allocations": [],
"initialStakedFunds": [],
"initialStakers": [],
"cChainGenesis": "{\"alloc\":{\"0200000000000000000000000000000000000005\":{\"balance\":\"0x0\",\"code\":\"0x01\",\"nonce\":\"0x1\"},\"9011E888251AB053B7bD1cdB598Db4f9DEd94714\":{\"balance\":\"0x193e5939a08ce9dbd480000000\"}},\"baseFeePerGas\":\"0x5d21dba00\",\"coinbase\":\"0x0000000000000000000000000000000000000000\",\"config\":{\"aiMiningConfig\":{\"blockTimestamp\":0},\"arrowGlacierBlock\":0,\"berlinBlock\":0,\"blobSchedule\":{\"cancun\":{\"baseFeeUpdateFraction\":3338477,\"max\":6,\"target\":3}},\"byzantiumBlock\":0,\"cancunTime\":0,\"chainId\":36962,\"constantinopleBlock\":0,\"eip150Block\":0,\"eip155Block\":0,\"eip158Block\":0,\"evmTimestamp\":0,\"feeConfig\":{\"baseFeeChangeDenominator\":36,\"blockGasCostStep\":200000,\"gasLimit\":1000000000,\"maxBlockGasCost\":1000000,\"minBaseFee\":25000000000,\"minBlockGasCost\":0,\"targetBlockRate\":2,\"targetGas\":500000000},\"grayGlacierBlock\":0,\"homesteadBlock\":0,\"istanbulBlock\":0,\"londonBlock\":0,\"mergeNetsplitBlock\":0,\"muirGlacierBlock\":0,\"petersburgBlock\":0,\"shanghaiTime\":0,\"terminalTotalDifficulty\":0,\"warpConfig\":{\"blockTimestamp\":0,\"quorumNumerator\":67,\"requirePrimaryNetworkSigners\":false}},\"difficulty\":\"0x0\",\"extraData\":\"0x\",\"gasLimit\":\"0x3b9aca00\",\"gasUsed\":\"0x0\",\"mixHash\":\"0x0000000000000000000000000000000000000000000000000000000000000000\",\"nonce\":\"0x0\",\"number\":\"0x0\",\"parentHash\":\"0x0000000000000000000000000000000000000000000000000000000000000000\",\"timestamp\":\"0x0\"}"
}
+9
View File
@@ -0,0 +1,9 @@
{{- $network := index .Values.networks .Values.network }}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ .Values.genesis.configMapName }}
namespace: {{ $network.namespace }}
data:
genesis.json: |
{{- .Files.Get (printf "genesis/%s.json" .Values.network) | nindent 4 }}
+8
View File
@@ -0,0 +1,8 @@
{{- $network := index .Values.networks .Values.network }}
apiVersion: v1
kind: Namespace
metadata:
name: {{ $network.namespace }}
labels:
app: lux
network: {{ .Values.network }}
+26
View File
@@ -0,0 +1,26 @@
{{- $network := index .Values.networks .Values.network }}
{{- $namespace := $network.namespace }}
# Role to allow luxd pods to read their own LoadBalancer service for public IP discovery
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: luxd-service-reader
namespace: {{ $namespace }}
rules:
- apiGroups: [""]
resources: ["services"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: luxd-service-reader
namespace: {{ $namespace }}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: luxd-service-reader
subjects:
- kind: ServiceAccount
name: default
namespace: {{ $namespace }}
+85
View File
@@ -0,0 +1,85 @@
{{- $network := index .Values.networks .Values.network }}
{{- $namespace := $network.namespace }}
{{- $stakingPort := $network.stakingPort }}
{{- $httpPort := $network.httpPort }}
# Headless service for StatefulSet DNS resolution
apiVersion: v1
kind: Service
metadata:
name: luxd-headless
namespace: {{ $namespace }}
labels:
app: luxd
lux-network: {{ .Values.network }}
spec:
clusterIP: None
publishNotReadyAddresses: true
selector:
app: luxd
lux-network: {{ .Values.network }}
ports:
- name: http
port: {{ $httpPort }}
- name: staking
port: {{ $stakingPort }}
{{- if .Values.nodeServices.enabled }}
---
# Main LoadBalancer service (round-robin across all pods)
apiVersion: v1
kind: Service
metadata:
name: luxd
namespace: {{ $namespace }}
labels:
app: luxd
lux-network: {{ .Values.network }}
annotations:
service.beta.kubernetes.io/do-loadbalancer-name: "k8s-luxd-{{ .Values.network }}"
{{- with .Values.nodeServices.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.nodeServices.type }}
selector:
app: luxd
lux-network: {{ .Values.network }}
ports:
- name: http
port: {{ $httpPort }}
targetPort: {{ $httpPort }}
- name: staking
port: {{ $stakingPort }}
targetPort: {{ $stakingPort }}
# Individual LoadBalancer services per pod for stable external IPs
# CRITICAL for P2P: each validator needs its own public IP
{{- range $i := until (int .Values.replicas) }}
---
apiVersion: v1
kind: Service
metadata:
name: luxd-{{ $i }}
namespace: {{ $namespace }}
labels:
app: luxd
lux-network: {{ $.Values.network }}
pod-index: "{{ $i }}"
annotations:
service.beta.kubernetes.io/do-loadbalancer-name: "k8s-luxd-{{ $.Values.network }}-{{ $i }}"
{{- with $.Values.nodeServices.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ $.Values.nodeServices.type }}
selector:
app: luxd
lux-network: {{ $.Values.network }}
statefulset.kubernetes.io/pod-name: luxd-{{ $i }}
ports:
- name: http
port: {{ $httpPort }}
targetPort: {{ $httpPort }}
- name: staking
port: {{ $stakingPort }}
targetPort: {{ $stakingPort }}
{{- end }}
{{- end }}
@@ -0,0 +1,290 @@
{{- $network := index .Values.networks .Values.network }}
{{- $namespace := $network.namespace }}
{{- $stakingPort := $network.stakingPort }}
{{- $httpPort := $network.httpPort }}
{{- $replicas := int .Values.replicas }}
apiVersion: v1
kind: ConfigMap
metadata:
name: luxd-startup
namespace: {{ $namespace }}
data:
startup.sh: |
#!/bin/sh
set -e
POD_INDEX=${HOSTNAME##*-}
echo "=== luxd startup for $HOSTNAME ({{ .Values.network }}, network-id={{ $network.networkID }}) ==="
# Staking keys are set up by init container
echo "Staking keys already in place for node $POD_INDEX"
# Build bootstrap node list excluding self
# Uses --bootstrap-nodes (endpoint-only, NodeID discovered from TLS cert)
{{- if .Values.bootstrap.useHostnames }}
# Using internal K8s DNS for bootstrap (recommended)
DNS_PREFIX="luxd"
DNS_SUFFIX="luxd-headless.{{ $namespace }}.svc.cluster.local"
BOOTSTRAP_NODES=""
{{- range $i := until $replicas }}
if [ "$POD_INDEX" != "{{ $i }}" ]; then
CUR_HOST="${DNS_PREFIX}-{{ $i }}.${DNS_SUFFIX}:{{ $stakingPort }}"
if [ -n "$BOOTSTRAP_NODES" ]; then
BOOTSTRAP_NODES="${BOOTSTRAP_NODES},${CUR_HOST}"
else
BOOTSTRAP_NODES="${CUR_HOST}"
fi
fi
{{- end }}
{{- else }}
# Using external IPs for bootstrap
BOOTSTRAP_NODES=""
{{- range $i := until $replicas }}
{{- $externalIP := "" }}
{{- if and $.Values.bootstrap.externalIPs (gt (len $.Values.bootstrap.externalIPs) $i) }}
{{- $externalIP = index $.Values.bootstrap.externalIPs $i }}
{{- end }}
if [ "$POD_INDEX" != "{{ $i }}" ] && [ -n "{{ $externalIP }}" ]; then
if [ -n "$BOOTSTRAP_NODES" ]; then
BOOTSTRAP_NODES="${BOOTSTRAP_NODES},{{ $externalIP }}:{{ $stakingPort }}"
else
BOOTSTRAP_NODES="{{ $externalIP }}:{{ $stakingPort }}"
fi
fi
{{- end }}
{{- end }}
# Get public IP
# Priority: 1) static externalIPs from values, 2) K8s LB service discovery, 3) pod IP fallback
PUBLIC_IP=""
{{- if .Values.bootstrap.externalIPs }}
case "$POD_INDEX" in
{{- range $i := until $replicas }}
{{- if and $.Values.bootstrap.externalIPs (gt (len $.Values.bootstrap.externalIPs) $i) }}
{{ $i }}) PUBLIC_IP="{{ index $.Values.bootstrap.externalIPs $i }}" ;;
{{- end }}
{{- end }}
esac
{{- end }}
# If no static IP, discover from per-pod LoadBalancer service via K8s API
if [ -z "$PUBLIC_IP" ]; then
SA_TOKEN_FILE="/var/run/secrets/kubernetes.io/serviceaccount/token"
CA_CERT="/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
K8S_API="https://kubernetes.default.svc"
SVC_NAME="luxd-${POD_INDEX}"
NAMESPACE="{{ $namespace }}"
if [ -f "$SA_TOKEN_FILE" ]; then
TOKEN=$(cat "$SA_TOKEN_FILE")
echo "[PUBLIC-IP] Discovering external IP from service ${SVC_NAME} in ${NAMESPACE}..."
ATTEMPT=0
MAX_ATTEMPTS=12
while [ "$ATTEMPT" -lt "$MAX_ATTEMPTS" ]; do
SVC_JSON=$(curl -sf -m 5 \
-H "Authorization: Bearer ${TOKEN}" \
--cacert "$CA_CERT" \
"${K8S_API}/api/v1/namespaces/${NAMESPACE}/services/${SVC_NAME}" 2>/dev/null || true)
if [ -n "$SVC_JSON" ]; then
# Extract loadBalancer ingress IP (no jq in alpine, JSON spans multiple lines)
# Look for "ip" field that appears after "ingress" in the status block
LB_IP=$(echo "$SVC_JSON" | tr -d ' \n' | sed -n 's/.*"loadBalancer":{"ingress":\[{"ip":"\([^"]*\)".*/\1/p')
if [ -n "$LB_IP" ]; then
PUBLIC_IP="$LB_IP"
echo "[PUBLIC-IP] Discovered external IP: $PUBLIC_IP (attempt $((ATTEMPT+1)))"
break
fi
fi
ATTEMPT=$((ATTEMPT + 1))
if [ "$ATTEMPT" -lt "$MAX_ATTEMPTS" ]; then
echo "[PUBLIC-IP] LB IP not yet assigned, retrying in 5s... (attempt $((ATTEMPT))/${MAX_ATTEMPTS})"
sleep 5
fi
done
else
echo "[PUBLIC-IP] No service account token found, skipping LB discovery"
fi
fi
# Final fallback to pod IP
if [ -z "$PUBLIC_IP" ]; then
PUBLIC_IP=$(hostname -i)
echo "[PUBLIC-IP] WARNING: Using pod IP $PUBLIC_IP (no external IP found)"
fi
{{- if .Values.chainConfigs.enabled }}
# Write EVM chain configs for C-chain and deployed subnet chains
CHAIN_CFG='{{ .Values.chainConfigs.evmConfig }}'
for chain in C{{ range .Values.chainConfigs.blockchainIDs }} {{ . }}{{ end }}; do
mkdir -p /data/configs/chains/$chain
echo "$CHAIN_CFG" > /data/configs/chains/$chain/config.json
done
echo "Chain configs written for C{{ range .Values.chainConfigs.blockchainIDs }} + {{ . | trunc 8 }}{{ end }}"
{{- end }}
echo " public-ip: $PUBLIC_IP"
echo " bootstrap-nodes: $BOOTSTRAP_NODES"
# Build luxd arguments
ARGS="--network-id={{ $network.networkID }}"
ARGS="$ARGS --http-host=0.0.0.0"
ARGS="$ARGS --http-port={{ $httpPort }}"
ARGS="$ARGS --http-allowed-hosts={{ .Values.api.httpAllowedHosts }}"
ARGS="$ARGS --staking-port={{ $stakingPort }}"
ARGS="$ARGS --data-dir=/data"
ARGS="$ARGS --genesis-file=/genesis/genesis.json"
ARGS="$ARGS --db-type={{ .Values.dbType }}"
{{- if .Values.chainConfigs.enabled }}
ARGS="$ARGS --chain-config-dir=/data/configs/chains"
{{- end }}
ARGS="$ARGS --index-enabled={{ .Values.api.indexEnabled }}"
ARGS="$ARGS --api-admin-enabled={{ .Values.api.adminEnabled }}"
ARGS="$ARGS --api-metrics-enabled={{ .Values.api.metricsEnabled }}"
ARGS="$ARGS --api-keystore-enabled=true"
ARGS="$ARGS --log-level={{ .Values.logLevel }}"
ARGS="$ARGS --plugin-dir=/data/plugins"
ARGS="$ARGS --staking-tls-cert-file=/data/staking/staker.crt"
ARGS="$ARGS --staking-tls-key-file=/data/staking/staker.key"
# Signer key is optional
if [ -f "/data/staking/signer.key" ]; then
ARGS="$ARGS --staking-signer-key-file=/data/staking/signer.key"
fi
ARGS="$ARGS --consensus-sample-size={{ .Values.consensus.sampleSize }}"
ARGS="$ARGS --consensus-quorum-size={{ .Values.consensus.quorumSize }}"
ARGS="$ARGS --sybil-protection-enabled={{ .Values.consensus.sybilProtectionEnabled }}"
ARGS="$ARGS --network-require-validator-to-connect={{ .Values.consensus.requireValidatorToConnect }}"
ARGS="$ARGS --network-allow-private-ips={{ .Values.consensus.allowPrivateIPs }}"
if [ -n "$PUBLIC_IP" ]; then
ARGS="$ARGS --public-ip=$PUBLIC_IP"
fi
if [ -n "$BOOTSTRAP_NODES" ]; then
ARGS="$ARGS --bootstrap-nodes=$BOOTSTRAP_NODES"
fi
# Chain tracking
{{- if .Values.chainTracking.trackAllChains }}
ARGS="$ARGS --track-all-chains"
{{- else if .Values.chainTracking.trackedChains }}
ARGS="$ARGS --track-chains={{ join "," .Values.chainTracking.trackedChains }}"
{{- end }}
# Upgrade config override (all upgrades from genesis for custom networks)
{{- if .Values.upgradeConfig.enabled }}
UPGRADE_JSON='{{ .Values.upgradeConfig.content | replace "\n" "" | trim }}'
UPGRADE_B64=$(echo "$UPGRADE_JSON" | base64 | tr -d '\n')
ARGS="$ARGS --upgrade-file-content=$UPGRADE_B64"
{{- end }}
{{- if .Values.bootstrap.rlpImport.enabled }}
# Clear C-chain data on restart to avoid trie corruption
# (path database prunes genesis state, causing "triedb parent layer missing" on restart)
# P-chain and X-chain data are preserved (subnet deployments, etc.)
C_CHAIN_DIR="/data/chainData/network-{{ $network.networkID }}/{{ $network.cChainID }}"
IMPORT_MARKER="/data/.bootstrap-{{ .Values.network }}-import.done"
if [ -d "$C_CHAIN_DIR" ]; then
echo "[STARTUP] Clearing C-chain data to prevent trie corruption on restart..."
rm -rf "$C_CHAIN_DIR"
rm -f "$IMPORT_MARKER"
echo "[STARTUP] C-chain data cleared, will re-import RLP"
fi
{{- end }}
# Start luxd in background (allows post-start bootstrap logic)
/luxd/build/luxd $ARGS &
LUXD_PID=$!
# Disable set -e for bootstrap section
set +e
# Wait for node to be healthy
echo "[BOOTSTRAP] Waiting for node health..."
i=0
while [ "$i" -lt 180 ]; do
if curl -sf -m 2 http://127.0.0.1:{{ $httpPort }}/ext/health >/dev/null 2>&1; then
echo "[BOOTSTRAP] Node healthy after ${i}s"
break
fi
i=$((i + 1))
sleep 1
done
# Set up chain aliases
{{- if .Values.chainTracking.aliases }}
{{- range .Values.chainTracking.aliases }}
curl -s -m 5 -X POST -H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","id":1,"method":"admin.aliasChain","params":{"chain":"C","alias":"{{ . }}"}}' \
http://127.0.0.1:{{ $httpPort }}/ext/admin >/dev/null 2>&1 || true
{{- end }}
{{- end }}
{{- if .Values.bootstrap.rlpImport.enabled }}
# C-Chain RLP import
MARKER="/data/.bootstrap-{{ .Values.network }}-import.done"
BOOTSTRAP_DIR="/data/bootstrap"
if [ ! -f "$MARKER" ]; then
height_hex=$(curl -s -m 10 -X POST -H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}' \
http://127.0.0.1:{{ $httpPort }}/ext/bc/C/rpc | sed -n 's/.*"result":"\(0x[0-9A-Fa-f]*\)".*/\1/p' | head -n1)
if [ -z "$height_hex" ]; then
height_hex="0x0"
fi
height_dec=$(printf '%d' "$height_hex" 2>/dev/null || echo 0)
echo "[BOOTSTRAP] C-Chain height: $height_dec (hex: $height_hex)"
if [ "$height_dec" -lt "{{ int .Values.bootstrap.rlpImport.minHeight }}" ]; then
echo "[BOOTSTRAP] C-Chain below target ({{ int .Values.bootstrap.rlpImport.minHeight }}), starting RLP import..."
mkdir -p "$BOOTSTRAP_DIR"
RLP_FILE="${BOOTSTRAP_DIR}/{{ .Values.bootstrap.rlpImport.rlpFilename }}"
{{- if .Values.bootstrap.rlpImport.multiPart }}
# Download multi-part RLP
if [ ! -s "$RLP_FILE" ]; then
echo "[BOOTSTRAP] Downloading RLP parts..."
{{- range .Values.bootstrap.rlpImport.parts }}
PART_FILE="${BOOTSTRAP_DIR}/{{ $.Values.bootstrap.rlpImport.rlpFilename }}.part.{{ . }}"
if [ ! -s "$PART_FILE" ]; then
echo "[BOOTSTRAP] Downloading part {{ . }}..."
curl -fL --retry 5 --retry-delay 2 --retry-connrefused -o "$PART_FILE" \
"{{ $.Values.bootstrap.rlpImport.baseUrl }}/{{ $.Values.bootstrap.rlpImport.rlpFilename | replace ".rlp" "" }}.part.{{ . }}" || true
fi
{{- end }}
echo "[BOOTSTRAP] Assembling RLP file..."
cat "${BOOTSTRAP_DIR}"/{{ .Values.bootstrap.rlpImport.rlpFilename }}.part.* > "$RLP_FILE" 2>/dev/null || true
fi
{{- else }}
# Download single RLP file
if [ ! -s "$RLP_FILE" ]; then
echo "[BOOTSTRAP] Downloading RLP..."
curl -fL --retry 5 --retry-delay 2 --retry-connrefused -o "$RLP_FILE" \
"{{ .Values.bootstrap.rlpImport.baseUrl }}/{{ .Values.bootstrap.rlpImport.rlpFilename }}" || true
fi
{{- end }}
if [ -s "$RLP_FILE" ]; then
echo "[BOOTSTRAP] Starting import of $RLP_FILE..."
import_payload=$(printf '{"jsonrpc":"2.0","id":1,"method":"admin_importChain","params":["%s"]}' "$RLP_FILE")
import_result=$(curl -s -m {{ int .Values.bootstrap.rlpImport.timeout }} -X POST -H 'Content-Type: application/json' --data "$import_payload" http://127.0.0.1:{{ $httpPort }}/ext/bc/C/rpc || true)
echo "[BOOTSTRAP] Import result: $import_result"
echo "$import_result" | grep -q '"success":true' && touch "$MARKER" && echo "[BOOTSTRAP] Import complete" || echo "[BOOTSTRAP] Import did not return success"
else
echo "[BOOTSTRAP] RLP file missing or empty, skipping"
fi
else
echo "[BOOTSTRAP] C-Chain already at height $height_dec, skipping import"
touch "$MARKER"
fi
else
echo "[BOOTSTRAP] Import marker exists, skipping bootstrap"
fi
{{- end }}
wait "$LUXD_PID"
+226
View File
@@ -0,0 +1,226 @@
{{- $network := index .Values.networks .Values.network }}
{{- $namespace := $network.namespace }}
{{- $stakingPort := $network.stakingPort }}
{{- $httpPort := $network.httpPort }}
{{- $replicas := int .Values.replicas }}
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: luxd
namespace: {{ $namespace }}
annotations:
lux.network/protect-pvc: "true"
spec:
serviceName: luxd-headless
replicas: {{ .Values.replicas }}
podManagementPolicy: Parallel
updateStrategy:
type: {{ .Values.upgradeStrategy.type | default "OnDelete" }}
persistentVolumeClaimRetentionPolicy:
whenDeleted: Retain
whenScaled: Retain
selector:
matchLabels:
app: luxd
lux-network: {{ .Values.network }}
template:
metadata:
labels:
app: luxd
lux-network: {{ .Values.network }}
spec:
securityContext:
fsGroup: 1000
runAsUser: 1000
runAsGroup: 1000
terminationGracePeriodSeconds: 30
{{- if .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.imagePullSecrets | nindent 8 }}
{{- end }}
serviceAccountName: luxd
initContainers:
- name: init-plugins
image: {{ .Values.image.repository }}:{{ .Values.image.tag }}
imagePullPolicy: IfNotPresent
securityContext:
runAsUser: 0
runAsGroup: 0
command: ["/bin/sh", "-c"]
args:
- |
set -e
IDX=${HOSTNAME##*-}
echo "=== Init for luxd-${IDX} ({{ .Values.network }}) ==="
{{- if .Values.initMode.clearData }}
echo "=== Clearing data for re-genesis (preserving RLP) ==="
rm -rf /data/chainData /data/db /data/genesis.bytes /data/.bootstrap-*-import.done /data/.seeded
echo "Data cleared"
{{- else }}
echo "=== Preserving existing data ==="
{{- end }}
{{- if .Values.seedRestore.enabled }}
# ─── Seed Restore (idempotent via marker file) ───
SEED_MARKER="{{ .Values.seedRestore.markerPath | default "/data/.seeded" }}"
if [ ! -f "$SEED_MARKER" ]; then
echo "[SEED] Starting seed restore..."
{{- if eq .Values.seedRestore.sourceType "ObjectStore" }}
echo "[SEED] Downloading snapshot from {{ .Values.seedRestore.objectStoreUrl }}..."
curl -fL --retry 5 --retry-delay 5 -o /tmp/snapshot.tar.zst "{{ .Values.seedRestore.objectStoreUrl }}" && \
echo "[SEED] Extracting snapshot..." && \
(zstd -d /tmp/snapshot.tar.zst -o /tmp/snapshot.tar 2>/dev/null || cp /tmp/snapshot.tar.zst /tmp/snapshot.tar) && \
tar xf /tmp/snapshot.tar -C /data && \
rm -f /tmp/snapshot.tar /tmp/snapshot.tar.zst && \
touch "$SEED_MARKER" && \
echo "[SEED] Restore complete" || echo "[SEED] Restore failed"
{{- else if or (eq .Values.seedRestore.sourceType "PVCClone") (eq .Values.seedRestore.sourceType "VolumeSnapshot") }}
# PVC was created from snapshot/clone
if [ -d "/data/db" ] || [ -d "/data/chainData" ]; then
echo "[SEED] Data found from PVC clone/snapshot"
touch "$SEED_MARKER"
else
echo "[SEED] WARNING: PVC clone/snapshot has no data"
fi
{{- else }}
echo "[SEED] No seed source configured, skipping"
{{- end }}
else
echo "[SEED] Marker $SEED_MARKER exists, skipping restore"
fi
{{- end }}
# Set up plugins
echo "=== Setting up plugins ==="
mkdir -p /data/plugins
# C-Chain EVM plugin
cp /luxd/build/plugins/mgj786NP7uDwBCcq6YwThhaN8FLyybkCa4zBWTQbNgmK6k9A6 /data/plugins/
# Subnet EVM plugin (same binary, different VM ID)
cp /luxd/build/plugins/mgj786NP7uDwBCcq6YwThhaN8FLyybkCa4zBWTQbNgmK6k9A6 /data/plugins/ag3GReYPNuSR17rUP8acMdZipQBikdXNRKDyFszAysmy3vDXE
# Set up staking keys
echo "=== Setting up staking keys ==="
mkdir -p /data/staking
cp /staking-keys/staker-${IDX}.crt /data/staking/staker.crt
cp /staking-keys/staker-${IDX}.key /data/staking/staker.key
if [ -f "/staking-keys/signer-${IDX}.key" ]; then
cp /staking-keys/signer-${IDX}.key /data/staking/signer.key
fi
chown -R 1000:1000 /data/staking
chmod 600 /data/staking/*
echo "=== Init complete ==="
volumeMounts:
- name: data
mountPath: /data
- name: staking-keys
mountPath: /staking-keys
readOnly: true
{{- if .Values.startupGate.enabled }}
- name: startup-gate
image: {{ .Values.image.repository }}:{{ .Values.image.tag }}
imagePullPolicy: IfNotPresent
command: ["/bin/sh", "-c"]
args:
- |
set -e
POD_INDEX=${HOSTNAME##*-}
MIN_PEERS={{ .Values.startupGate.minPeers | default 2 }}
TIMEOUT={{ .Values.startupGate.timeoutSeconds | default 300 }}
INTERVAL={{ .Values.startupGate.checkIntervalSeconds | default 5 }}
DNS_PREFIX="luxd"
DNS_SUFFIX="luxd-headless.{{ $namespace }}.svc.cluster.local"
START=$(date +%s)
echo "[GATE] Waiting for $MIN_PEERS peers on staking port {{ $stakingPort }} (timeout ${TIMEOUT}s)..."
while true; do
REACHABLE=0
{{- range $i := until $replicas }}
if [ "$POD_INDEX" != "{{ $i }}" ]; then
if nc -z -w 2 "${DNS_PREFIX}-{{ $i }}.${DNS_SUFFIX}" {{ $stakingPort }} 2>/dev/null; then
REACHABLE=$((REACHABLE + 1))
fi
fi
{{- end }}
if [ "$REACHABLE" -ge "$MIN_PEERS" ]; then
echo "[GATE] $REACHABLE peers reachable, proceeding"
break
fi
ELAPSED=$(( $(date +%s) - START ))
if [ "$ELAPSED" -ge "$TIMEOUT" ]; then
echo "[GATE] WARNING: Timeout after ${ELAPSED}s with only $REACHABLE peers. Starting anyway."
break
fi
echo "[GATE] $REACHABLE/$MIN_PEERS peers reachable (${ELAPSED}/${TIMEOUT}s), waiting ${INTERVAL}s..."
sleep "$INTERVAL"
done
{{- end }}
containers:
- name: luxd
image: {{ .Values.image.repository }}:{{ .Values.image.tag }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
command: ["/bin/sh", "/scripts/startup.sh"]
ports:
- name: http
containerPort: {{ $httpPort }}
- name: staking
containerPort: {{ $stakingPort }}
livenessProbe:
httpGet:
path: /ext/health
port: {{ $httpPort }}
initialDelaySeconds: 60
periodSeconds: 15
failureThreshold: 5
timeoutSeconds: 5
readinessProbe:
httpGet:
path: /ext/health
port: {{ $httpPort }}
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 3
timeoutSeconds: 5
startupProbe:
tcpSocket:
port: {{ $httpPort }}
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 60
resources:
{{- toYaml .Values.resources | nindent 10 }}
volumeMounts:
- name: data
mountPath: /data
- name: genesis
mountPath: /genesis
readOnly: true
- name: startup-script
mountPath: /scripts
- name: staking-keys
mountPath: /staking-keys
readOnly: true
volumes:
- name: genesis
configMap:
name: {{ .Values.genesis.configMapName }}
- name: startup-script
configMap:
name: luxd-startup
defaultMode: 0755
- name: staking-keys
secret:
secretName: {{ .Values.staking.secretName }}
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: {{ .Values.storage.storageClass }}
resources:
requests:
storage: {{ .Values.storage.size }}
+23
View File
@@ -0,0 +1,23 @@
# Hanzo devnet overrides — sovereign L1, networkID == chainID == 36964
network: devnet
replicas: 5
bootstrap:
useHostnames: true
nodeIDs: []
# Devnet — sybil protection off for development
consensus:
sybilProtectionEnabled: false
allowPrivateIPs: true
chainTracking:
trackAllChains: true
trackedChains: []
aliases:
- hanzo
chainConfigs:
enabled: false
blockchainIDs: []
+29
View File
@@ -0,0 +1,29 @@
# Hanzo mainnet overrides — sovereign L1, networkID == chainID == 36963
# Fresh genesis (no historical RLP import; this is Hanzo's own primary network,
# NOT a subnet on Lux and NOT the Lux C-Chain).
network: mainnet
replicas: 5
# Validator NodeIDs are derived from Hanzo's staking keys (KMS) at provisioning —
# populated by the operator, never committed. See PROVISIONING.md.
bootstrap:
useHostnames: true
nodeIDs: []
# Mainnet requires sybil protection
consensus:
sybilProtectionEnabled: true
# Hanzo tracks its own chains
chainTracking:
trackAllChains: true
trackedChains: []
aliases:
- hanzo
# C-Chain EVM config is carried in genesis/mainnet.json (chainId 36963 + aiMiningConfig).
# The C-Chain blockchain ID is derived at network creation (PROVISIONING.md).
chainConfigs:
enabled: false
blockchainIDs: []
+22
View File
@@ -0,0 +1,22 @@
# Hanzo testnet overrides — sovereign L1, networkID == chainID == 36962
network: testnet
replicas: 5
bootstrap:
useHostnames: true
nodeIDs: []
# Testnet requires sybil protection
consensus:
sybilProtectionEnabled: true
chainTracking:
trackAllChains: true
trackedChains: []
aliases:
- hanzo
chainConfigs:
enabled: false
blockchainIDs: []
+184
View File
@@ -0,0 +1,184 @@
# Hanzo sovereign-L1 node Helm chart values
# Base config (used as defaults, override per network with values-{network}.yaml)
#
# SOVEREIGN-L1 MODEL: Hanzo's primary networkID EQUALS its EVM chainID (one ID per
# env), unlike Lux (networkID 1/2/3, chainID 96369/68/70). Hanzo does NOT run on the
# Lux C-Chain (96369) — that is Lux-network only.
network: devnet
# Network-specific settings
# networkID MUST match genesis.json networkID (node validates at startup)
# Hanzo: networkID == chainID (mainnet 36963 / testnet 36962 / devnet 36964)
# Because these are CUSTOM network IDs (not the well-known 1/2/3), upgradeConfig
# below MUST be enabled — the node has no built-in upgrade schedule for them.
networks:
mainnet:
networkID: 36963
chainID: 36963
# cChainID is DERIVED at network creation (blake-hash of the C-Chain genesis +
# createChainTx) — populated by the operator at provisioning (see PROVISIONING.md).
cChainID: ""
httpPort: 9630
stakingPort: 9631
namespace: hanzo-mainnet
testnet:
networkID: 36962
chainID: 36962
cChainID: ""
httpPort: 9640
stakingPort: 9641
namespace: hanzo-testnet
devnet:
networkID: 36964
chainID: 36964
cChainID: ""
httpPort: 9650
stakingPort: 9651
namespace: hanzo-devnet
# 5 replicas for production consensus
replicas: 5
image:
# luxd node image (Hanzo runs the same binary; the network is defined by genesis).
# Upstream OSS node is acceptable here per the registry rules.
repository: registry.digitalocean.com/hanzo/bootnode
tag: luxd-v1.23.14
pullPolicy: Always
imagePullSecrets:
- name: registry-hanzo
# Bootstrap configuration
# nodeIDs are DERIVED from Hanzo's validator staking keys (KMS) at provisioning —
# they are NOT committed here. The operator populates them from the hanzo-staking
# secret it generates (see PROVISIONING.md). Empty = first-genesis bring-up.
bootstrap:
nodeIDs: []
useHostnames: true
externalIPs: []
rlpImport:
enabled: false
baseUrl: ""
rlpFilename: ""
multiPart: false
parts: []
minHeight: 1
timeout: 7200
# Consensus settings (5-node network)
consensus:
sampleSize: 5
quorumSize: 4
sybilProtectionEnabled: false
requireValidatorToConnect: false
allowPrivateIPs: true
# Chain tracking — Hanzo's own sovereign network
chainTracking:
trackAllChains: true
trackedChains: []
aliases:
- hanzo
# Deployed chain configs (blockchain IDs for EVM chain config directories)
chainConfigs:
enabled: false
blockchainIDs: []
evmConfig: '{"admin-api-enabled":true,"eth-apis":["eth","eth-filter","net","web3","internal-eth","internal-blockchain","internal-transaction","internal-tx-pool","internal-account","internal-personal","debug","internal-debug","admin"],"rpc-gas-cap":50000000,"rpc-tx-fee-cap":100,"pruning-enabled":false,"allow-unfinalized-queries":true,"allow-unprotected-txs":true,"log-level":"info","state-sync-enabled":false,"allow-missing-tries":true}'
# Init container mode
initMode:
clearData: false
# Startup gating: wait for peers before starting the node
startupGate:
enabled: true
minPeers: 2
timeoutSeconds: 300
checkIntervalSeconds: 5
# Seed/snapshot restore configuration
seedRestore:
enabled: false
sourceType: None
objectStoreUrl: ""
markerPath: "/data/.seeded"
# Upgrade strategy
upgradeStrategy:
type: OnDelete
maxUnavailable: 1
# Health policy
healthPolicy:
requireInboundValidators: false
minInbound: 1
gracePeriodSeconds: 300
maxHeightSkew: 10
# Staking keys — generated by the operator from KMS (NEVER committed).
staking:
secretName: hanzo-staking
# Resources
resources:
requests:
memory: "1Gi"
cpu: "500m"
limits:
memory: "4Gi"
cpu: "2"
# Storage (DOKS hanzo-k8s)
storage:
size: 100Gi
storageClass: do-block-storage
# Node exposure
nodeServices:
enabled: true
type: LoadBalancer
annotations: {}
# Logging
logLevel: info
# Database
dbType: badgerdb
# API settings
api:
adminEnabled: true
metricsEnabled: true
indexEnabled: true
httpAllowedHosts: "*"
# Upgrade config override — REQUIRED for Hanzo (custom network IDs 36963/62/64 have
# no built-in upgrade schedule). All phases activated at genesis (past timestamps).
upgradeConfig:
enabled: true
content: |
{
"apricotPhase1Time": "2020-12-05T05:00:00Z",
"apricotPhase2Time": "2020-12-05T05:00:00Z",
"apricotPhase3Time": "2020-12-05T05:00:00Z",
"apricotPhase4Time": "2020-12-05T05:00:00Z",
"apricotPhase4MinPChainHeight": 0,
"apricotPhase5Time": "2020-12-05T05:00:00Z",
"apricotPhasePre6Time": "2020-12-05T05:00:00Z",
"apricotPhase6Time": "2020-12-05T05:00:00Z",
"apricotPhasePost6Time": "2020-12-05T05:00:00Z",
"banffTime": "2020-12-05T05:00:00Z",
"cortinaTime": "2020-12-05T05:00:00Z",
"cortinaXChainStopVertexID": "11111111111111111111111111111111LpoYY",
"durangoTime": "2020-12-05T05:00:00Z",
"etnaTime": "2020-12-05T05:00:00Z",
"fortunaTime": "2020-12-05T05:00:00Z",
"graniteTime": "9999-12-01T00:00:00Z"
}
# Genesis
genesis:
configMapName: hanzo-genesis
+1 -1
View File
@@ -1,5 +1,5 @@
module github.com/hanzoai/bootnode
go 1.26.2
go 1.26.5
require github.com/go-chi/chi/v5 v5.1.0