mirror of
https://github.com/luxfi/genesis.git
synced 2026-07-27 04:11:41 +00:00
chore: update
This commit is contained in:
@@ -0,0 +1,705 @@
|
||||
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// Command createchaintx is the focused, immediate-unblock tool for registering
|
||||
// the 5 brand L2 EVMs (hanzo, zoo, pars, spc, osage) on a Quasar-era P-Chain.
|
||||
//
|
||||
// Decoupled from luxfi/cli: this tool does not call info.getBlockchainID(alias=X)
|
||||
// anywhere — it goes through luxfi/node/wallet/network/primary.MakeWallet which
|
||||
// fail-softs to a P-only XCTX (BlockchainID = ids.Empty) when the network has no
|
||||
// X-Chain registered. That's the actual unblock — every other path you might
|
||||
// hit in luxfi/cli's PublicDeployer.loadWallet currently crashes because the
|
||||
// upstream sdk/wallet/primary still tries to look up X first.
|
||||
//
|
||||
// What this tool does, end-to-end:
|
||||
//
|
||||
// 1. Loads a BIP-39 mnemonic from one of three sources, in order of priority:
|
||||
// a. --mnemonic-file=<path> (raw file, mnemonic is the only content)
|
||||
// b. LUX_MNEMONIC env var
|
||||
// c. KMS via env vars KMS_ENDPOINT + KMS_TOKEN + KMS_WORKSPACE_ID, fetching
|
||||
// secret at path lux/<env>/staking with key `mnemonic` (Infisical REST
|
||||
// v3 raw API: GET /api/v3/secrets/raw?workspaceId=&secretPath=&environment=)
|
||||
//
|
||||
// 2. Derives a single secp256k1 funding key on the canonical Lux BIP-44 UTXO
|
||||
// path m/44'/9000'/0'/0/<index> (purpose=44', coin_type=9000', account=0',
|
||||
// change=0, leaf=<index>, default index=5 → first non-staking-locked
|
||||
// allocation slot per the mainnet genesis layout). 9000 is the Lux/Avalanche
|
||||
// BIP-44 coin type — do not confuse with 96369, which is the Lux mainnet
|
||||
// EVM chainID and never appears in a derivation path. Override with --index
|
||||
// to use a different funded allocation slot.
|
||||
//
|
||||
// Distinct paths exist for other key purposes — they are NOT this funding key:
|
||||
// * device Lux key (Lux-internal hardened): m/44'/9000'/<nid>'/1'/<i>'
|
||||
// * device PQ key (ML-DSA-65 staking): m/44'/9000'/<nid>'/0'/<i>'
|
||||
// where <nid> is the primary-network ID (1/2/3/1337), NOT an EVM chainID.
|
||||
//
|
||||
// 3. For each of the 5 brand specs, loads the canonical brand genesis bytes
|
||||
// from disk (paths are not relative to --configs-dir; the tool ships a
|
||||
// compiled-in canonical mapping per brand-genesis-homes.md), then:
|
||||
// a. IssueCreateNetworkTx(owner) → per-chain validator network
|
||||
// b. IssueCreateChainTx(networkID, genesis, vmID, fxIDs=[], chainName=brand)
|
||||
// Skips both steps if a chain with chainName=brand already exists (the
|
||||
// idempotency contract — re-running this tool is safe).
|
||||
//
|
||||
// 4. Emits JSON of produced blockchain IDs to stdout (or --output=<path>).
|
||||
//
|
||||
// 5. With --apply-aliases, writes a kubectl-ready Patch JSON that updates the
|
||||
// `luxd-chain-aliases` (or `chain-aliases`) ConfigMap in namespace
|
||||
// lux-<env> with the new {blockchainID: [brand]} mapping.
|
||||
//
|
||||
// The tool deliberately omits AddChainValidatorTx, bootstrap-wait, EVM
|
||||
// heartbeat, and eth_blockNumber probing — those are the bootstrap-chain
|
||||
// concerns, not the immediate-unblock concerns. Once the chains are
|
||||
// registered on P-chain, the running luxd will pick them up on the next
|
||||
// roll (after the chain-aliases CM is updated and StatefulSet is rolled).
|
||||
//
|
||||
// Build:
|
||||
//
|
||||
// cd ~/work/lux/genesis/cmd && GOWORK=off go build -o /tmp/createchaintx ./createchaintx/
|
||||
//
|
||||
// Example invocations:
|
||||
//
|
||||
// # Mainnet, mnemonic in a local file, write output to /tmp/result.json
|
||||
// createchaintx \
|
||||
// --uri=https://api.lux.network \
|
||||
// --env=mainnet \
|
||||
// --mnemonic-file=/tmp/lux-master.mnemonic \
|
||||
// --output=/tmp/result.json
|
||||
//
|
||||
// # Devnet, mnemonic from KMS, also emit aliases patch for kubectl apply
|
||||
// export KMS_ENDPOINT=https://kms.hanzo.ai
|
||||
// export KMS_TOKEN=...
|
||||
// export KMS_WORKSPACE_ID=...
|
||||
// createchaintx \
|
||||
// --uri=http://luxd-0.lux-devnet.svc.cluster.local:9650 \
|
||||
// --env=devnet \
|
||||
// --output=/tmp/result.json \
|
||||
// --apply-aliases=/tmp/aliases-patch.json
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
luxbip32 "github.com/luxfi/go-bip32"
|
||||
luxbip39 "github.com/luxfi/go-bip39"
|
||||
|
||||
"github.com/luxfi/crypto/secp256k1"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/math/set"
|
||||
"github.com/luxfi/node/utils/formatting/address"
|
||||
"github.com/luxfi/node/wallet/network/primary"
|
||||
"github.com/luxfi/sdk/info"
|
||||
"github.com/luxfi/sdk/platformvm"
|
||||
"github.com/luxfi/utxo/secp256k1fx"
|
||||
)
|
||||
|
||||
// defaultVMID is the canonical EVM plugin VM ID baked into every luxd image
|
||||
// (verified via `ls /data/plugins`). Same value the 2026-06-06 inventory
|
||||
// records, same value across mainnet/testnet/devnet. Override with --vm-id
|
||||
// only if a non-default plugin is provisioned.
|
||||
const defaultVMID = "mgj786NP7uDwBCcq6YwThhaN8FLyybkCa4zBWTQbNgmK6k9A6"
|
||||
|
||||
// stakingPath is the canonical Avalanche/Lux BIP-44 derivation path for
|
||||
// the P-Chain UTXO key: m/44'/9000'/0'/0/<index>.
|
||||
//
|
||||
// purpose=44 (BIP-44), coinType=9000 (Avalanche/Lux platform), account=0,
|
||||
// change=0, index=<funded slot>. C-Chain EVM keys use the same shape with
|
||||
// coinType=60 (ETH) — out of scope here, CreateChainTx is a P-Chain op.
|
||||
//
|
||||
// The funded mainnet genesis allocations match this derivation:
|
||||
// index 0..4 -> initialStakedFunds (bonded as initial validator stake,
|
||||
// 500M LUX each, locked until validator end-time)
|
||||
// index 5..99 -> 100 unlock-schedule allocations, ~35M LUX unlocked
|
||||
// per address at network start. CreateChainTx fee is
|
||||
// trivial vs that — any non-staking slot is funded.
|
||||
//
|
||||
// Default --index = 5 (first free-spendable slot). Override with --index
|
||||
// for any allocations[<N>].utxoAddr address you control.
|
||||
type stakingPath struct {
|
||||
purpose, coinType, account, change, index uint32
|
||||
}
|
||||
|
||||
var defaultStakingPath = stakingPath{
|
||||
purpose: 44, // hardened — BIP-44
|
||||
coinType: 9000, // hardened — Avalanche/Lux platform
|
||||
account: 0, // hardened
|
||||
change: 0, // unhardened
|
||||
index: 5, // unhardened — first non-staking-locked allocation
|
||||
}
|
||||
|
||||
// brandSpec maps a brand identifier to its canonical genesis location.
|
||||
// Single source of truth, lives inline so the binary is self-contained
|
||||
// (no --configs-dir flag, no /tmp/bootstrap-configs shuffling).
|
||||
//
|
||||
// The pathTemplate is filled with the env name via fmt.Sprintf. Each
|
||||
// brand owns its own genesis (per ~/work/lux/genesis/configs/inventory/
|
||||
// brand-genesis-homes.md), so the home directories differ.
|
||||
type brandSpec struct {
|
||||
brand string
|
||||
pathTemplate string // %s = env name (mainnet|testnet|devnet)
|
||||
}
|
||||
|
||||
// brandSpecs are the 5 brand L2 EVMs registered on Lux primary network.
|
||||
// Order matters: CreateChainTx ordering produces deterministic chain IDs
|
||||
// per spec list, which keeps re-runs aligned to the inventory file.
|
||||
var brandSpecs = []brandSpec{
|
||||
{brand: "hanzo", pathTemplate: "%s/work/hanzo/universe/configs/genesis/%s/genesis.json"},
|
||||
{brand: "zoo", pathTemplate: "%s/work/zoo/universe/configs/genesis/%s/genesis.json"},
|
||||
{brand: "pars", pathTemplate: "%s/work/pars/genesis/networks/%s/genesis.json"},
|
||||
{brand: "spc", pathTemplate: "%s/work/spc/genesis/networks/%s/genesis.json"},
|
||||
{brand: "osage", pathTemplate: "%s/work/osage/genesis/networks/%s/genesis.json"},
|
||||
}
|
||||
|
||||
// result is the JSON shape emitted to stdout/--output. Keyed by env so
|
||||
// downstream consumers (chain-aliases CM templating, gateway routes,
|
||||
// explorer-chains CM) can index into a single document for all envs.
|
||||
type result struct {
|
||||
Env string `json:"env"`
|
||||
URI string `json:"uri"`
|
||||
ControlKey string `json:"controlKey"`
|
||||
VMID string `json:"vmId"`
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
Chains map[string]chainRow `json:"chains"`
|
||||
}
|
||||
|
||||
// chainRow records one brand's outcome. NetworkID is the per-chain
|
||||
// validator network (CreateNetworkTx ID, owner of the blockchain).
|
||||
// ChainID is the blockchain's globally unique ID. Reused=true means
|
||||
// the chain was already present on P-chain and we only recorded it
|
||||
// without burning LUX on a duplicate CreateChainTx.
|
||||
type chainRow struct {
|
||||
NetworkID string `json:"networkId"`
|
||||
ChainID string `json:"chainId"`
|
||||
Reused bool `json:"reused"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
uri := flag.String("uri", "", "luxd API URI (e.g. https://api.lux.network)")
|
||||
env := flag.String("env", "", "network env: mainnet|testnet|devnet (required)")
|
||||
hrp := flag.String("hrp", "", "P-chain bech32 HRP (auto-resolved from --env if empty)")
|
||||
mnemonicFile := flag.String("mnemonic-file", "", "path to a file whose contents are the BIP-39 mnemonic (alternative to LUX_MNEMONIC env or KMS)")
|
||||
vmIDStr := flag.String("vm-id", defaultVMID, "EVM plugin VM ID present in luxd's --plugin-dir")
|
||||
output := flag.String("output", "/dev/stdout", "result JSON output path")
|
||||
applyAliases := flag.String("apply-aliases", "", "if set, write a kubectl-ready JSON patch for the chain-aliases ConfigMap to this path")
|
||||
homeOverride := flag.String("home", "", "override $HOME for the brand-genesis path template (empty = use real $HOME)")
|
||||
forceNew := flag.Bool("force-new", false, "ignore pre-existing chains with the same brand name on P-chain; always issue a fresh CreateChainTx")
|
||||
derivIndex := flag.Uint("index", uint(defaultStakingPath.index), "BIP-44 leaf index at m/44'/9000'/0'/0/<index> (default 5: first allocation slot not bonded to initial validator stake)")
|
||||
flag.Parse()
|
||||
|
||||
if *uri == "" || *env == "" {
|
||||
log.Fatal("--uri and --env are required (try --help)")
|
||||
}
|
||||
resolvedHRP := *hrp
|
||||
if resolvedHRP == "" {
|
||||
var err error
|
||||
resolvedHRP, err = hrpForEnv(*env)
|
||||
if err != nil {
|
||||
log.Fatalf("resolve hrp: %v", err)
|
||||
}
|
||||
}
|
||||
vmID, err := ids.FromString(*vmIDStr)
|
||||
if err != nil {
|
||||
log.Fatalf("invalid --vm-id %q: %v", *vmIDStr, err)
|
||||
}
|
||||
|
||||
mnemonic, mnemonicSource, err := loadMnemonic(*mnemonicFile, *env)
|
||||
if err != nil {
|
||||
log.Fatalf("load mnemonic: %v", err)
|
||||
}
|
||||
log.Printf("[%s] loaded mnemonic from %s (%d words)", *env, mnemonicSource, countWords(mnemonic))
|
||||
|
||||
derivPath := defaultStakingPath
|
||||
derivPath.index = uint32(*derivIndex)
|
||||
key, err := deriveStakingKey(mnemonic, derivPath)
|
||||
if err != nil {
|
||||
log.Fatalf("derive funding key: %v", err)
|
||||
}
|
||||
addr := key.PublicKey().Address()
|
||||
controlKey := formatPAddr(resolvedHRP, addr)
|
||||
log.Printf("[%s] funding key m/44'/9000'/0'/0/%d -> %s", *env, derivPath.index, controlKey)
|
||||
|
||||
home := *homeOverride
|
||||
if home == "" {
|
||||
home, err = os.UserHomeDir()
|
||||
if err != nil {
|
||||
log.Fatalf("user home: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-flight: load all 5 genesis blobs. Fail before any P-chain spend if
|
||||
// any path is missing or malformed — the alternative (start spending LUX
|
||||
// then crash at chain #3) is exactly the failure mode this tool exists
|
||||
// to avoid.
|
||||
type loadedSpec struct {
|
||||
brand string
|
||||
genesis []byte
|
||||
}
|
||||
specs := make([]loadedSpec, 0, len(brandSpecs))
|
||||
for _, bs := range brandSpecs {
|
||||
path := filepath.Clean(fmt.Sprintf(bs.pathTemplate, home, *env))
|
||||
// pathTemplate has a leading "%s/work/..." — the first %s is the
|
||||
// home dir. Strip a stray leading "/" if home already ends in "/".
|
||||
path = strings.ReplaceAll(path, "//", "/")
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
log.Fatalf("[%s] read genesis %s: %v", bs.brand, path, err)
|
||||
}
|
||||
if !json.Valid(raw) {
|
||||
log.Fatalf("[%s] genesis %s: invalid JSON", bs.brand, path)
|
||||
}
|
||||
specs = append(specs, loadedSpec{brand: bs.brand, genesis: raw})
|
||||
log.Printf("[%s] %s genesis loaded (%d bytes) from %s", *env, bs.brand, len(raw), path)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
kc := primary.NewKeychainAdapter(secp256k1fx.NewKeychain(key))
|
||||
|
||||
infoClient := info.NewClient(*uri)
|
||||
if nodeID, _, err := infoClient.GetNodeID(ctx); err == nil {
|
||||
log.Printf("[%s] connected to luxd node %s", *env, nodeID)
|
||||
}
|
||||
pClient := platformvm.NewClient(*uri)
|
||||
if balResp, err := pClient.GetBalance(ctx, []ids.ShortID{addr}); err != nil {
|
||||
log.Fatalf("getBalance: %v", err)
|
||||
} else {
|
||||
log.Printf("[%s] P-chain balance of %s: %+v", *env, controlKey, balResp)
|
||||
}
|
||||
|
||||
// Tighten HTTP timeouts so DNS entries with mixed live/dead IPs don't
|
||||
// stall the wallet for minutes per dead IP. Matches the timeout shape
|
||||
// PublicDeployer.loadWallet sets — keeping behaviour predictable.
|
||||
if t, ok := http.DefaultTransport.(*http.Transport); ok {
|
||||
t.DialContext = (&net.Dialer{Timeout: 5 * time.Second}).DialContext
|
||||
t.TLSHandshakeTimeout = 10 * time.Second
|
||||
t.TLSClientConfig = &tls.Config{InsecureSkipVerify: false}
|
||||
}
|
||||
|
||||
// Idempotency: query the live P-chain for existing blockchains. Any brand
|
||||
// whose chainName already matches a registered chain is skipped (we record
|
||||
// the existing IDs in the result, no new CreateChainTx). --force-new
|
||||
// bypasses this check.
|
||||
existing := map[string]struct {
|
||||
NetworkID ids.ID
|
||||
ChainID ids.ID
|
||||
}{}
|
||||
if *forceNew {
|
||||
log.Printf("[%s] FORCE-NEW: skipping pre-existing-chain detection; every brand will issue a fresh CreateChainTx", *env)
|
||||
} else {
|
||||
rows, err := platformGetBlockchains(ctx, *uri)
|
||||
if err != nil {
|
||||
log.Printf("WARN platform.getBlockchains failed: %v (assuming no pre-existing chains)", err)
|
||||
} else {
|
||||
byName := map[string]platformBlockchain{}
|
||||
for _, b := range rows {
|
||||
byName[strings.ToLower(b.Name)] = b
|
||||
}
|
||||
for _, s := range specs {
|
||||
if b, ok := byName[strings.ToLower(s.brand)]; ok {
|
||||
existing[s.brand] = struct {
|
||||
NetworkID ids.ID
|
||||
ChainID ids.ID
|
||||
}{NetworkID: b.NetworkID, ChainID: b.ID}
|
||||
log.Printf("[%s/%s] pre-existing chain: chainID=%s networkID=%s (skipping CreateChainTx)",
|
||||
*env, s.brand, b.ID, b.NetworkID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initial wallet sync. We re-sync after every chain creation so the
|
||||
// builder sees the new chain-owner UTXOs.
|
||||
w, err := primary.MakeWallet(ctx, &primary.WalletConfig{
|
||||
URI: *uri,
|
||||
LUXKeychain: kc,
|
||||
EVMKeychain: kc,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("wallet sync: %v", err)
|
||||
}
|
||||
luxAssetID := w.X().Builder().Context().UTXOAssetID
|
||||
bal, err := w.P().Builder().GetBalance()
|
||||
if err != nil {
|
||||
log.Fatalf("P balance: %v", err)
|
||||
}
|
||||
log.Printf("[%s] initial P-chain wallet LUX=%d nLUX (assetID=%s)", *env, bal[luxAssetID], luxAssetID)
|
||||
|
||||
out := result{
|
||||
Env: *env,
|
||||
URI: *uri,
|
||||
ControlKey: controlKey,
|
||||
VMID: vmID.String(),
|
||||
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Chains: make(map[string]chainRow, len(specs)),
|
||||
}
|
||||
|
||||
for _, s := range specs {
|
||||
log.Printf("[%s/%s] === processing ===", *env, s.brand)
|
||||
|
||||
var (
|
||||
networkID ids.ID
|
||||
chainID ids.ID
|
||||
reused bool
|
||||
)
|
||||
if ec, ok := existing[s.brand]; ok {
|
||||
networkID, chainID, reused = ec.NetworkID, ec.ChainID, true
|
||||
log.Printf("[%s/%s] REUSE: chainID=%s networkID=%s", *env, s.brand, chainID, networkID)
|
||||
} else {
|
||||
// Each brand gets a Lux-native network with an owner OutputOwners
|
||||
// set to our funding key. Required because IssueCreateChainTx
|
||||
// needs a "subnet owner" UTXO to authorize the spend — the
|
||||
// primary network has no owner tx in P-Chain history, so a
|
||||
// chain on primary cannot be authored that way.
|
||||
//
|
||||
// Owner network created here is tracked by luxd via
|
||||
// chainTracking.trackAllChains=true in the LuxNetwork CR, so
|
||||
// chains on these owner networks serve under their aliases
|
||||
// without needing a per-subnet validator registration.
|
||||
owner := &secp256k1fx.OutputOwners{
|
||||
Threshold: 1,
|
||||
Addrs: []ids.ShortID{addr},
|
||||
}
|
||||
log.Printf("[%s/%s] IssueCreateNetworkTx", *env, s.brand)
|
||||
netTx, err := w.P().IssueCreateNetworkTx(owner)
|
||||
if err != nil {
|
||||
log.Fatalf("[%s/%s] create network: %v", *env, s.brand, err)
|
||||
}
|
||||
networkID = netTx.ID()
|
||||
log.Printf("[%s/%s] networkID=%s", *env, s.brand, networkID)
|
||||
time.Sleep(8 * time.Second) // mempool settle for owner tx
|
||||
|
||||
// Wallet re-sync: pick up the new owner UTXO so the chain
|
||||
// auth can be built.
|
||||
var w2 primary.Wallet
|
||||
for i := 0; i < 5; i++ {
|
||||
w2, err = primary.MakeWallet(ctx, &primary.WalletConfig{
|
||||
URI: *uri,
|
||||
LUXKeychain: kc,
|
||||
EVMKeychain: kc,
|
||||
PChainTxsToFetch: set.Of(networkID),
|
||||
})
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
log.Printf("[%s/%s] wallet re-sync attempt %d: %v", *env, s.brand, i+1, err)
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatalf("[%s/%s] wallet re-sync failed: %v", *env, s.brand, err)
|
||||
}
|
||||
|
||||
log.Printf("[%s/%s] IssueCreateChainTx vmID=%s chainName=%s genesisBytes=%d",
|
||||
*env, s.brand, vmID, s.brand, len(s.genesis))
|
||||
chainTx, err := w2.P().IssueCreateChainTx(networkID, s.genesis, vmID, nil, s.brand)
|
||||
if err != nil {
|
||||
log.Fatalf("[%s/%s] create chain: %v", *env, s.brand, err)
|
||||
}
|
||||
chainID = chainTx.ID()
|
||||
log.Printf("[%s/%s] chainID=%s", *env, s.brand, chainID)
|
||||
|
||||
// Re-sync parent wallet for next brand iteration.
|
||||
w, err = primary.MakeWallet(ctx, &primary.WalletConfig{
|
||||
URI: *uri,
|
||||
LUXKeychain: kc,
|
||||
EVMKeychain: kc,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("[%s/%s] post-chain wallet re-sync: %v", *env, s.brand, err)
|
||||
}
|
||||
}
|
||||
|
||||
out.Chains[s.brand] = chainRow{
|
||||
NetworkID: networkID.String(),
|
||||
ChainID: chainID.String(),
|
||||
Reused: reused,
|
||||
}
|
||||
}
|
||||
|
||||
// Marshal and write the result.
|
||||
body, err := json.MarshalIndent(out, "", " ")
|
||||
if err != nil {
|
||||
log.Fatalf("marshal result: %v", err)
|
||||
}
|
||||
if *output == "/dev/stdout" {
|
||||
fmt.Println(string(body))
|
||||
} else {
|
||||
tmp := *output + ".tmp"
|
||||
if err := os.WriteFile(tmp, body, 0o644); err != nil {
|
||||
log.Fatalf("write output: %v", err)
|
||||
}
|
||||
if err := os.Rename(tmp, *output); err != nil {
|
||||
log.Fatalf("rename output: %v", err)
|
||||
}
|
||||
log.Printf("wrote %s", *output)
|
||||
}
|
||||
|
||||
if *applyAliases != "" {
|
||||
if err := writeAliasesPatch(*applyAliases, *env, out.Chains); err != nil {
|
||||
log.Fatalf("write aliases patch: %v", err)
|
||||
}
|
||||
log.Printf("wrote aliases patch %s", *applyAliases)
|
||||
log.Printf("APPLY: kubectl --context do-sfo3-lux-k8s -n lux-%s patch cm chain-aliases --type merge --patch-file %s",
|
||||
*env, *applyAliases)
|
||||
}
|
||||
}
|
||||
|
||||
// hrpForEnv resolves the bech32 HRP for the given network env. lux primary
|
||||
// uses HRPs `lux` (mainnet, networkID=1), `test` (testnet, networkID=2),
|
||||
// `dev` (devnet/localnet, networkID=3 and 1337). The mapping lives in
|
||||
// luxfi/constants — we mirror it here so the tool is self-contained.
|
||||
func hrpForEnv(env string) (string, error) {
|
||||
switch strings.ToLower(env) {
|
||||
case "mainnet":
|
||||
return "lux", nil
|
||||
case "testnet":
|
||||
return "test", nil
|
||||
case "devnet", "local", "localnet":
|
||||
return "dev", nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown env %q (expected mainnet|testnet|devnet)", env)
|
||||
}
|
||||
}
|
||||
|
||||
// countWords returns the BIP-39 word count of mnemonic, used purely for
|
||||
// the log line — never for validation.
|
||||
func countWords(mnemonic string) int {
|
||||
return len(strings.Fields(mnemonic))
|
||||
}
|
||||
|
||||
// loadMnemonic resolves the mnemonic from one of three sources in priority
|
||||
// order: --mnemonic-file, LUX_MNEMONIC env var, KMS HTTP GET via Infisical's
|
||||
// v3 raw secrets API. Returns the mnemonic and a human-readable source
|
||||
// label for logging. Always validates with bip39 before returning.
|
||||
func loadMnemonic(mnemonicFile, env string) (string, string, error) {
|
||||
if mnemonicFile != "" {
|
||||
raw, err := os.ReadFile(mnemonicFile)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("read %s: %w", mnemonicFile, err)
|
||||
}
|
||||
mn := strings.TrimSpace(string(raw))
|
||||
if !luxbip39.IsMnemonicValid(mn) {
|
||||
return "", "", fmt.Errorf("file %s: invalid BIP-39 mnemonic", mnemonicFile)
|
||||
}
|
||||
return mn, fmt.Sprintf("file %s", mnemonicFile), nil
|
||||
}
|
||||
if mn := strings.TrimSpace(os.Getenv("LUX_MNEMONIC")); mn != "" {
|
||||
if !luxbip39.IsMnemonicValid(mn) {
|
||||
return "", "", errors.New("env LUX_MNEMONIC: invalid BIP-39 mnemonic")
|
||||
}
|
||||
return mn, "env LUX_MNEMONIC", nil
|
||||
}
|
||||
endpoint := strings.TrimRight(strings.TrimSpace(os.Getenv("KMS_ENDPOINT")), "/")
|
||||
token := strings.TrimSpace(os.Getenv("KMS_TOKEN"))
|
||||
wsID := strings.TrimSpace(os.Getenv("KMS_WORKSPACE_ID"))
|
||||
if endpoint == "" || token == "" || wsID == "" {
|
||||
return "", "", errors.New("no mnemonic source: set --mnemonic-file, LUX_MNEMONIC env, or KMS_ENDPOINT+KMS_TOKEN+KMS_WORKSPACE_ID env")
|
||||
}
|
||||
mn, err := fetchKMSMnemonic(endpoint, token, wsID, env)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("kms fetch: %w", err)
|
||||
}
|
||||
if !luxbip39.IsMnemonicValid(mn) {
|
||||
return "", "", errors.New("kms-returned mnemonic: invalid BIP-39 mnemonic")
|
||||
}
|
||||
return mn, fmt.Sprintf("KMS %s lux/%s/staking[mnemonic]", endpoint, env), nil
|
||||
}
|
||||
|
||||
// fetchKMSMnemonic calls the Infisical v3 raw secrets API to retrieve the
|
||||
// `mnemonic` key under secretPath /lux/<env>/staking, environment "prod"
|
||||
// (Infisical environments are independent of luxd network envs; we always
|
||||
// store mnemonics under the prod Infisical env regardless of which luxd
|
||||
// env they target — the secretPath disambiguates).
|
||||
//
|
||||
// Endpoint shape: GET ${KMS_ENDPOINT}/api/v3/secrets/raw?
|
||||
// workspaceId=<ws>&environment=prod&secretPath=/lux/<env>/staking
|
||||
// Auth: Authorization: Bearer ${KMS_TOKEN}
|
||||
// Response: {"secrets": [{"secretKey": "mnemonic", "secretValue": "..."}]}
|
||||
func fetchKMSMnemonic(endpoint, token, wsID, env string) (string, error) {
|
||||
q := url.Values{}
|
||||
q.Set("workspaceId", wsID)
|
||||
q.Set("environment", "prod")
|
||||
q.Set("secretPath", fmt.Sprintf("/lux/%s/staking", env))
|
||||
reqURL := endpoint + "/api/v3/secrets/raw?" + q.Encode()
|
||||
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
httpc := &http.Client{Timeout: 20 * time.Second}
|
||||
resp, err := httpc.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("kms http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
var doc struct {
|
||||
Secrets []struct {
|
||||
SecretKey string `json:"secretKey"`
|
||||
SecretValue string `json:"secretValue"`
|
||||
} `json:"secrets"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
|
||||
return "", fmt.Errorf("decode kms response: %w", err)
|
||||
}
|
||||
for _, s := range doc.Secrets {
|
||||
if s.SecretKey == "mnemonic" {
|
||||
return strings.TrimSpace(s.SecretValue), nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("no `mnemonic` key in kms response")
|
||||
}
|
||||
|
||||
// deriveStakingKey derives a secp256k1 private key from a BIP-39 mnemonic
|
||||
// at the supplied BIP-32 path. Hardened nodes have FirstHardenedChild
|
||||
// (0x80000000) OR'd in by the bip32 library when called via NewChildKey
|
||||
// with the hardened index already pre-set.
|
||||
func deriveStakingKey(mnemonic string, p stakingPath) (*secp256k1.PrivateKey, error) {
|
||||
mnemonic = strings.TrimSpace(mnemonic)
|
||||
if !luxbip39.IsMnemonicValid(mnemonic) {
|
||||
return nil, errors.New("invalid BIP-39 mnemonic")
|
||||
}
|
||||
seed := luxbip39.NewSeed(mnemonic, "")
|
||||
master, err := luxbip32.NewMasterKey(seed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Hardened steps: purpose'/coinType'/account'
|
||||
purpose, err := master.NewChildKey(luxbip32.FirstHardenedChild + p.purpose)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
coinType, err := purpose.NewChildKey(luxbip32.FirstHardenedChild + p.coinType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
account, err := coinType.NewChildKey(luxbip32.FirstHardenedChild + p.account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Unhardened steps: change/index
|
||||
change, err := account.NewChildKey(p.change)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
leaf, err := change.NewChildKey(p.index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return secp256k1.ToPrivateKey(leaf.Key)
|
||||
}
|
||||
|
||||
// formatPAddr returns the bech32-encoded P-chain address string with the
|
||||
// "P-" prefix. Returns "" on error so log lines never carry partial data.
|
||||
func formatPAddr(hrp string, a ids.ShortID) string {
|
||||
b32, err := address.FormatBech32(hrp, a[:])
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return "P-" + b32
|
||||
}
|
||||
|
||||
// platformBlockchain is the subset of platform.getBlockchains we care
|
||||
// about. ID is the blockchain's own globally unique chain ID; NetworkID
|
||||
// is the validator-network (CreateNetworkTx ID) that owns it; Name is
|
||||
// the human-readable chainName that CreateChainTx records.
|
||||
type platformBlockchain struct {
|
||||
ID ids.ID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
NetworkID ids.ID `json:"networkID"`
|
||||
VMID ids.ID `json:"vmID"`
|
||||
}
|
||||
|
||||
// platformGetBlockchains issues a single JSON-RPC POST to
|
||||
// /ext/bc/P with method="platform.getBlockchains" and parses the
|
||||
// response. We hand-roll this RPC because info.getBlockchainID(alias)
|
||||
// would re-introduce the X-Chain coupling we exist to avoid.
|
||||
func platformGetBlockchains(ctx context.Context, uri string) ([]platformBlockchain, error) {
|
||||
body := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"platform.getBlockchains","params":{}}`)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(uri, "/")+"/ext/bc/P", body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
httpc := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := httpc.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
var doc struct {
|
||||
Result struct {
|
||||
Blockchains []platformBlockchain `json:"blockchains"`
|
||||
} `json:"result"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if doc.Error != nil {
|
||||
return nil, errors.New(doc.Error.Message)
|
||||
}
|
||||
return doc.Result.Blockchains, nil
|
||||
}
|
||||
|
||||
// writeAliasesPatch emits a kubectl JSON patch that, when applied with
|
||||
// `kubectl patch cm chain-aliases --type merge --patch-file`, updates
|
||||
// data.aliases.json to map the new blockchainIDs to their brand alias.
|
||||
//
|
||||
// We use a `merge` patch (not strategic) because ConfigMap data is a
|
||||
// freeform map[string]string — strategic merge is undefined for it.
|
||||
// The patch overwrites the whole aliases.json key; if the operator
|
||||
// wants to PRESERVE pre-existing aliases (e.g. legacy chain IDs from
|
||||
// a prior bootstrap), they must hand-merge before applying. This is
|
||||
// the safe default for the post-reset case where the CM should
|
||||
// contain ONLY the 5 fresh L2s.
|
||||
func writeAliasesPatch(outPath, env string, chains map[string]chainRow) error {
|
||||
aliases := map[string][]string{}
|
||||
// Stable brand order matches brandSpecs for deterministic patch bytes.
|
||||
for _, bs := range brandSpecs {
|
||||
row, ok := chains[bs.brand]
|
||||
if !ok || row.ChainID == "" {
|
||||
continue
|
||||
}
|
||||
aliases[row.ChainID] = []string{bs.brand}
|
||||
}
|
||||
aliasesJSON, err := json.MarshalIndent(aliases, " ", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
patch := map[string]any{
|
||||
"data": map[string]any{
|
||||
"aliases.json": string(aliasesJSON) + "\n",
|
||||
},
|
||||
}
|
||||
_ = env // env unused in the patch body; kubectl --namespace carries it
|
||||
buf := &bytes.Buffer{}
|
||||
enc := json.NewEncoder(buf)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(patch); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(outPath, buf.Bytes(), 0o644)
|
||||
}
|
||||
@@ -34,12 +34,8 @@ const (
|
||||
// FamilySPC is the SPC L2 family hosted on the Lux primary network's
|
||||
// validator set (validators==0 per LP-018).
|
||||
FamilySPC NetworkFamily = "spc"
|
||||
// FamilyLiquid is the Liquid network family. Runs as Anchored on a
|
||||
// Lux primary (validators>0 with networkID in {1,2,3,1337}) or as
|
||||
// Independent (own networkID = 8675309/8675310/8675312 per env).
|
||||
FamilyFamily = "liquid"
|
||||
// FamilyPars is the Pars network family. Runs as Anchored on a Lux
|
||||
// primary or as Independent — same dual-mode option as Liquid.
|
||||
// primary or as Independent with its own networkID.
|
||||
FamilyPars NetworkFamily = "pars"
|
||||
// FamilyOsage is the Osage network family (placeholder pending
|
||||
// genesis publication).
|
||||
@@ -100,15 +96,6 @@ func EVMChainID(family NetworkFamily, env uint32) (uint64, bool) {
|
||||
case DevnetID:
|
||||
return 36912, true
|
||||
}
|
||||
case FamilyLiquid:
|
||||
switch env {
|
||||
case MainnetID:
|
||||
return 8675309, true
|
||||
case TestnetID:
|
||||
return 8675310, true
|
||||
case DevnetID:
|
||||
return 8675312, true
|
||||
}
|
||||
case FamilyPars:
|
||||
switch env {
|
||||
case MainnetID:
|
||||
@@ -143,8 +130,6 @@ func NetworkFamilyOf(chainID uint64) (NetworkFamily, bool) {
|
||||
return FamilyZoo, true
|
||||
case 36911, 36910, 36912:
|
||||
return FamilySPC, true
|
||||
case 8675309, 8675310, 8675312:
|
||||
return FamilyLiquid, true
|
||||
case 7070, 494950, 494951:
|
||||
return FamilyPars, true
|
||||
}
|
||||
@@ -165,8 +150,6 @@ func ParseNetworkFamily(s string) (NetworkFamily, bool) {
|
||||
return FamilyZoo, true
|
||||
case FamilySPC:
|
||||
return FamilySPC, true
|
||||
case FamilyLiquid:
|
||||
return FamilyLiquid, true
|
||||
case FamilyPars:
|
||||
return FamilyPars, true
|
||||
case FamilyOsage:
|
||||
|
||||
@@ -28,9 +28,6 @@ func TestEVMChainID_KnownCells(t *testing.T) {
|
||||
{FamilySPC, MainnetID, 36911},
|
||||
{FamilySPC, TestnetID, 36910},
|
||||
{FamilySPC, DevnetID, 36912},
|
||||
{FamilyLiquid, MainnetID, 8675309},
|
||||
{FamilyLiquid, TestnetID, 8675310},
|
||||
{FamilyLiquid, DevnetID, 8675312},
|
||||
{FamilyPars, MainnetID, 7070},
|
||||
{FamilyPars, TestnetID, 494950},
|
||||
{FamilyPars, DevnetID, 494951},
|
||||
@@ -80,7 +77,6 @@ func TestNetworkFamilyOf_RoundTrip(t *testing.T) {
|
||||
{36963, FamilyHanzo},
|
||||
{200200, FamilyZoo},
|
||||
{36911, FamilySPC},
|
||||
{8675309, FamilyLiquid},
|
||||
{7070, FamilyPars},
|
||||
}
|
||||
for _, c := range cases {
|
||||
@@ -150,9 +146,6 @@ func TestEVMChainIDFor_StringInputs(t *testing.T) {
|
||||
{"spc", "mainnet", 36911},
|
||||
{"spc", "testnet", 36910},
|
||||
{"spc", "devnet", 36912},
|
||||
{"liquid", "mainnet", 8675309},
|
||||
{"liquid", "testnet", 8675310},
|
||||
{"liquid", "devnet", 8675312},
|
||||
{"pars", "mainnet", 7070},
|
||||
{"pars", "testnet", 494950},
|
||||
{"pars", "devnet", 494951},
|
||||
|
||||
Reference in New Issue
Block a user