mirror of
https://github.com/luxfi/genesis.git
synced 2026-10-30 16:25:45 +00:00
1083 lines
42 KiB
Go
1083 lines
42 KiB
Go
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// Command bootstrap-chain creates one or more chains on a luxd network using the
|
|
// canonical BIP44 m/44'/9000'/0'/0/<idx> derivation. For each chain it:
|
|
//
|
|
// 1. IssueCreateNetworkTx — chain-owner network with threshold=1
|
|
// 2. IssueCreateChainTx — vmID=<--vm-id>, genesis read from the configs dir
|
|
// 3. IssueAddChainValidatorTx — adds every primary network validator
|
|
// 4. Probes eth_blockNumber and info.isBootstrapped — fails if either is bad
|
|
//
|
|
// Creates lux-native chains on a luxd network. The chain list is data-driven
|
|
// (--track-chain-ids). Brand names (hanzo/zoo/pars/spc/osage) are REFUSED:
|
|
// per the brand_sovereign_node_one_way law luxd runs the LUX primary network
|
|
// ONLY, and each brand runs its own sovereign node (zood/hanzod/parsd/…). The
|
|
// historical "bootstrap the four brand L2s into luxd" purpose is retired
|
|
// alongside cmd/createchaintx.
|
|
//
|
|
// Idempotency: before any P-chain spend, the tool queries
|
|
// platform.getBlockchains and skips any chain whose alias already exists.
|
|
// Re-running the tool is safe — already-bootstrapped chains are detected and
|
|
// only probed for liveness, never re-created.
|
|
//
|
|
// Vocabulary: this tool speaks "chain" — the polymorphic primitive produced
|
|
// by CreateChainTx. Three IDs, three roles, never aliased:
|
|
//
|
|
// - `networkID` — identifies a validator network. Comes in two scopes:
|
|
// - primary networkID (uint32: 1=mainnet, 2=testnet, 3=local, 1337=dev)
|
|
// - per-chain networkID (ids.ID 32 bytes) — the CreateNetworkTx ID
|
|
// that owns one or more chains.
|
|
// - `chainID` — the blockchain's own globally unique ID (ids.ID 32 bytes).
|
|
// - `evmChainID` — EIP-155 chain ID (uint64). EVM JSON-RPC only.
|
|
//
|
|
// Usage:
|
|
//
|
|
// LUX_MNEMONIC="..." bootstrap-chain \
|
|
// --uri=http://luxd-0.lux-devnet.svc.cluster.local:9650 \
|
|
// --hrp=dev \
|
|
// --bip44-idx=5 \
|
|
// --network-label=devnet \
|
|
// --configs-dir=/path/to/genesis/configs \
|
|
// --track-chain-ids=<lux-native-chain-ids> \
|
|
// --output=/dev/stdout
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"math/big"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
luxbip32 "github.com/luxfi/go-bip32"
|
|
luxbip39 "github.com/luxfi/go-bip39"
|
|
|
|
"github.com/luxfi/crypto/secp256k1"
|
|
gethcommon "github.com/luxfi/geth/common"
|
|
gethtypes "github.com/luxfi/geth/core/types"
|
|
"github.com/luxfi/ids"
|
|
"github.com/luxfi/math/set"
|
|
"github.com/luxfi/node/utils/formatting/address"
|
|
"github.com/luxfi/node/vms/platformvm/txs"
|
|
"github.com/luxfi/node/wallet/network/primary"
|
|
"github.com/luxfi/sdk/info"
|
|
"github.com/luxfi/sdk/platformvm"
|
|
"github.com/luxfi/utxo/secp256k1fx"
|
|
)
|
|
|
|
// defaultEVMID is the canonical EVM VM ID present in the luxd node image's
|
|
// plugin dir. Devnet, testnet, mainnet all expose this plugin natively
|
|
// (verified via `ls /data/plugins`). The brand-namespaced alias
|
|
// nyGCobireNhxFB7iM5bxV74hAY6j9nQX6wizxfWomnMMtztkr referenced in the
|
|
// 2026-05-27 devnet chain-aliases CM required a runtime symlink that didn't
|
|
// survive PVC remount; using the native VM ID removes that failure mode.
|
|
const defaultEVMID = "mgj786NP7uDwBCcq6YwThhaN8FLyybkCa4zBWTQbNgmK6k9A6"
|
|
|
|
// resultChain is the per-chain output written to --output.
|
|
//
|
|
// Brand is the user-facing brand identifier ("hanzo", "zoo", "spc",
|
|
// "pars") that downstream consumers (chain-aliases CM, explorer-chains
|
|
// CM, gateway routes) map onto. ChainTxName is the P-chain-unique
|
|
// identifier passed to IssueCreateChainTx (content-hashed by default).
|
|
// NetworkID is the CreateNetworkTx ID — the per-chain validator network
|
|
// that owns the blockchain. ChainID is the blockchain's own globally
|
|
// unique ID. EVMChainID is the EIP-155 chain ID for EVM JSON-RPC.
|
|
// Five distinct concepts; never alias.
|
|
type resultChain struct {
|
|
Brand string `json:"brand"`
|
|
ChainTxName string `json:"chainTxName"`
|
|
NetworkID string `json:"networkId"`
|
|
ChainID string `json:"chainId"`
|
|
EVMChainID uint64 `json:"evmChainId"`
|
|
FirstBlockHex string `json:"firstBlockHex"`
|
|
BootstrappedAt string `json:"bootstrappedAt"`
|
|
Reused bool `json:"reused"` // true when the chain pre-existed and was only probed
|
|
}
|
|
|
|
type result struct {
|
|
Network string `json:"network"`
|
|
URI string `json:"uri"`
|
|
ControlKey string `json:"controlKey"`
|
|
BIP44Index uint32 `json:"bip44Index"`
|
|
VMID string `json:"vmId"`
|
|
GeneratedAt string `json:"generatedAt"`
|
|
Chains []resultChain `json:"chains"`
|
|
}
|
|
|
|
func main() {
|
|
uri := flag.String("uri", "", "luxd API URI (e.g. http://luxd-0.lux-devnet.svc.cluster.local:9650)")
|
|
networkLabel := flag.String("network-label", "devnet", "human label for logs (testnet|devnet)")
|
|
hrp := flag.String("hrp", "dev", "P-chain bech32 HRP: test|dev")
|
|
bipIdx := flag.Uint("bip44-idx", 5, "BIP44 derivation index at m/44'/9000'/0'/0/<idx>")
|
|
configsDir := flag.String("configs-dir", "", "directory containing <chain>-<network>/genesis.json files (required)")
|
|
// --track-chain-ids is the declared list of non-brand chains to create on
|
|
// the target luxd network. The tool reads it, queries the P-chain, and
|
|
// idempotently creates the missing ones.
|
|
//
|
|
// DEFAULT IS EMPTY by design. The historical default "hanzo,zoo,pars,spc"
|
|
// created brand L2 EVMs INSIDE luxd — the dead WAY-1 retired by the
|
|
// brand_sovereign_node_one_way law: luxd runs the LUX primary network
|
|
// ONLY, and each brand (hanzo/zoo/pars/spc/osage) runs its OWN sovereign
|
|
// node (zood/hanzod/parsd/…). Brand names are now hard-refused below; this
|
|
// flag exists only for legitimate lux-native chain creation.
|
|
trackChainIDs := flag.String("track-chain-ids", "", "comma-separated chain ids to create on the target luxd network. Brand names (hanzo/zoo/pars/spc/osage) are REFUSED — brands run their own sovereign node, never inside luxd.")
|
|
vmIDStr := flag.String("vm-id", defaultEVMID, "EVM VM ID present in luxd's --plugin-dir")
|
|
// --chain-name-override pins an explicit CreateChainTx name for a single
|
|
// brand (format: <brand>=<name>, repeatable). Bypasses the content-hash
|
|
// default. Use only for legacy reattachment scenarios; the canonical
|
|
// path is the content-hash default which keeps re-runs idempotent and
|
|
// avoids collisions on P-chain.
|
|
chainNameOverride := flag.String("chain-name-override", "", "comma-separated <brand>=<chainName> overrides (e.g. 'hanzo=hanzo-v2,zoo=zoo-final'). Empty = use content-hash default.")
|
|
output := flag.String("output", "/dev/stdout", "result JSON output path")
|
|
skipValidators := flag.Bool("skip-validators", false, "skip adding primary validators as chain validators")
|
|
probeTimeout := flag.Duration("probe-timeout", 90*time.Second, "max wait per chain for eth_blockNumber>0 and isBootstrapped")
|
|
probeInterval := flag.Duration("probe-interval", 3*time.Second, "polling interval inside probe-timeout")
|
|
chainSettleDelay := flag.Duration("chain-settle-delay", 10*time.Second, "delay after IssueCreateNetworkTx before re-syncing wallet")
|
|
printAddrOnly := flag.Bool("print-addr-only", false, "derive the BIP44 key from LUX_MNEMONIC, print the P-chain address, exit")
|
|
evmHeartbeatKeyHex := flag.String("evm-heartbeat-key", "", "hex-encoded secp256k1 key funded on every EVM chain (LUX_PRIVATE_KEY). If set, the tool sends a 0-value self-tx after CreateChainTx to roll block 1 before probing eth_blockNumber>0")
|
|
probeBootstrapOnly := flag.Bool("probe-bootstrap-only", false, "accept the chain as healthy if info.isBootstrapped=true regardless of eth_blockNumber; use when --evm-heartbeat-key is unavailable and the operator will trigger heartbeats out-of-band")
|
|
// --skip-bootstrap-wait is the create-only path: issue CreateNetworkTx +
|
|
// CreateChainTx + AddChainValidatorTx and exit immediately, without
|
|
// waiting for the chain to bootstrap. This is the right tool when the
|
|
// running luxd's --track-chains flag does not yet include the new chain
|
|
// ID — the only way to make luxd track it is to update the ConfigMap and
|
|
// roll the StatefulSet, which must happen AFTER we know the new chain ID.
|
|
skipBootstrapWait := flag.Bool("skip-bootstrap-wait", false, "skip the post-CreateChainTx isBootstrapped+eth_blockNumber probe; use when the running luxd does not yet --track-chains the new chain")
|
|
// --force-new-chains bypasses the platform.getBlockchains existing-chain
|
|
// detection. Use when re-bootstrapping a brand whose name already exists
|
|
// on P-chain but whose previous CreateChainTx used a wrong genesis blob.
|
|
// The new chain will have a NEW blockchain ID; the old chain stays
|
|
// orphaned on P-chain (no way to delete, no harm if untracked by luxd).
|
|
forceNewChains := flag.Bool("force-new-chains", false, "ignore pre-existing chains with the same name on P-chain and always issue a fresh CreateChainTx")
|
|
// To override the fee-payment asset (e.g. on lux-mainnet where staking
|
|
// asset pmSJ7BfZ... differs from the legacy LUX asset HrJCm4yvN... held
|
|
// on P-chain UTXOs), set the env var LUX_WALLET_UTXO_ASSET_ID_OVERRIDE
|
|
// in the process environment before running. The override is read by
|
|
// luxfi/node/wallet/chain/p.NewContextFromClients.
|
|
// --key-file is an alternate entry to LUX_MNEMONIC+BIP44 for callers that
|
|
// already hold the deployer's raw 32-byte secp256k1 key on disk (e.g. the
|
|
// node{1..5}/ec/private.key files used to fund test+dev primary genesis).
|
|
// When set, LUX_MNEMONIC and --bip44-idx are ignored.
|
|
keyFile := flag.String("key-file", "", "path to a 64-char hex secp256k1 private key (alternative to LUX_MNEMONIC+BIP44 derivation)")
|
|
flag.Parse()
|
|
|
|
loadKey := func() (*secp256k1.PrivateKey, error) {
|
|
if *keyFile != "" {
|
|
raw, err := os.ReadFile(*keyFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read key-file %s: %w", *keyFile, err)
|
|
}
|
|
hexStr := strings.TrimSpace(strings.TrimPrefix(string(raw), "0x"))
|
|
keyBytes, err := hex.DecodeString(hexStr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse key-file %s as hex: %w", *keyFile, err)
|
|
}
|
|
return secp256k1.ToPrivateKey(keyBytes)
|
|
}
|
|
mn := strings.TrimSpace(os.Getenv("LUX_MNEMONIC"))
|
|
if mn == "" {
|
|
return nil, fmt.Errorf("LUX_MNEMONIC env var or --key-file required")
|
|
}
|
|
return deriveLuxKey(mn, uint32(*bipIdx))
|
|
}
|
|
|
|
if *printAddrOnly {
|
|
key, err := loadKey()
|
|
if err != nil {
|
|
log.Fatalf("load key: %v", err)
|
|
}
|
|
fmt.Println(formatPAddr(*hrp, key.PublicKey().Address()))
|
|
return
|
|
}
|
|
|
|
if *uri == "" || *configsDir == "" {
|
|
log.Fatal("--uri and --configs-dir are required")
|
|
}
|
|
|
|
// brand_sovereign_node_one_way law: luxd runs the LUX primary network
|
|
// ONLY. Brand L2 EVMs are NEVER created inside luxd — each brand runs its
|
|
// own sovereign node (zood/hanzod/parsd/…) and the unified DEX is the
|
|
// shared 0x9999 V4 precompile compiled into every brand EVM, not a chain
|
|
// hosted on luxd. Refuse brand names so this tool can never re-create the
|
|
// phantom brand chains that decayed into dangling chain-aliases.
|
|
for _, name := range strings.Split(*trackChainIDs, ",") {
|
|
switch strings.ToLower(strings.TrimSpace(name)) {
|
|
case "hanzo", "zoo", "pars", "spc", "osage":
|
|
log.Fatalf("refusing to bootstrap brand chain %q inside luxd: brands run their own sovereign node (zood/hanzod/parsd/…), not luxd — see brand_sovereign_node_one_way", strings.TrimSpace(name))
|
|
}
|
|
}
|
|
|
|
vmID, err := ids.FromString(*vmIDStr)
|
|
if err != nil {
|
|
log.Fatalf("invalid --vm-id: %v", err)
|
|
}
|
|
|
|
key, err := loadKey()
|
|
if err != nil {
|
|
log.Fatalf("load key: %v", err)
|
|
}
|
|
addr := key.PublicKey().Address()
|
|
controlKey := formatPAddr(*hrp, addr)
|
|
log.Printf("[%s] derived key m/44'/9000'/0'/0/%d -> %s", *networkLabel, *bipIdx, controlKey)
|
|
|
|
chains := strings.Split(*trackChainIDs, ",")
|
|
for i := range chains {
|
|
chains[i] = strings.TrimSpace(chains[i])
|
|
}
|
|
|
|
// Parse --chain-name-override (CSV of <brand>=<name>).
|
|
overrideByBrand := map[string]string{}
|
|
if strings.TrimSpace(*chainNameOverride) != "" {
|
|
for _, kv := range strings.Split(*chainNameOverride, ",") {
|
|
kv = strings.TrimSpace(kv)
|
|
if kv == "" {
|
|
continue
|
|
}
|
|
eq := strings.IndexByte(kv, '=')
|
|
if eq <= 0 || eq == len(kv)-1 {
|
|
log.Fatalf("--chain-name-override entry %q: expected <brand>=<name>", kv)
|
|
}
|
|
overrideByBrand[strings.TrimSpace(kv[:eq])] = strings.TrimSpace(kv[eq+1:])
|
|
}
|
|
}
|
|
|
|
// Pre-flight: load every genesis up front. A bad path is fatal before
|
|
// we burn any P-chain LUX on a CreateNetwork that we can't follow with
|
|
// a CreateChain. The loader validates 0x-prefixing on alloc keys
|
|
// (auto-fixes in memory, never rewrites the file on disk).
|
|
//
|
|
// Two-name decomposition (decomplected from brand identity):
|
|
// - Brand — user-facing brand identifier ("hanzo", "zoo",
|
|
// "spc", "pars"). Lives at the chain-aliases CM
|
|
// layer (blockchainID → ["hanzo"]). NOT used as
|
|
// the CreateChainTx name.
|
|
// - ChainTxName — P-chain-unique identifier passed to
|
|
// IssueCreateChainTx. Default is content-
|
|
// addressed: "<brand>-<8hex>" where 8hex is
|
|
// hex(SHA256("<brand>|<env>|"||genesisBytes))
|
|
// [:4]. Same canonical genesis → same name (idem-
|
|
// potent re-runs). Different genesis → different
|
|
// name → no collision.
|
|
// - GenesisPath — configs/<brand>-<env>/genesis.json (one
|
|
// canonical path per (brand,env); no "fix"
|
|
// suffix variants).
|
|
type chainSpec struct {
|
|
Brand string // user-facing brand identifier
|
|
ChainTxName string // P-chain unique name (content-hash or override)
|
|
GenesisRaw []byte
|
|
EVMChainID uint64
|
|
}
|
|
specs := make([]chainSpec, 0, len(chains))
|
|
for _, brand := range chains {
|
|
path := filepath.Join(*configsDir, fmt.Sprintf("%s-%s", brand, *networkLabel), "genesis.json")
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
log.Fatalf("read genesis %s: %v", path, err)
|
|
}
|
|
var doc map[string]any
|
|
if err := json.Unmarshal(raw, &doc); err != nil {
|
|
log.Fatalf("invalid genesis json %s: %v", path, err)
|
|
}
|
|
cfg, _ := doc["config"].(map[string]any)
|
|
if cfg == nil {
|
|
log.Fatalf("genesis %s has no .config", path)
|
|
}
|
|
var evmChainID uint64
|
|
switch v := cfg["chainId"].(type) {
|
|
case float64:
|
|
evmChainID = uint64(v)
|
|
case int:
|
|
evmChainID = uint64(v)
|
|
case int64:
|
|
evmChainID = uint64(v)
|
|
default:
|
|
log.Fatalf("genesis %s: .config.chainId not a number (got %T)", path, v)
|
|
}
|
|
// Track D regression gate: brand L2 genesis blobs MUST NOT carry a
|
|
// pre-computed `stateRoot` or `genesisHash`. These two fields in
|
|
// luxfi/geth/core/genesis.go::Genesis.Commit override the result of
|
|
// flushAlloc — meaning a stale value silently displaces the
|
|
// canonical alloc trie. The only legitimate use was the historical
|
|
// Lux primary C-Chain header workaround, now resolved via the
|
|
// empirical block-0 hash in cchain.json. Any brand L2 carrying
|
|
// these fields is a regression — fail loudly before burning LUX on
|
|
// a CreateChainTx with poisoned GenesisData.
|
|
if v, ok := doc["stateRoot"]; ok && v != nil && v != "" {
|
|
log.Fatalf(
|
|
"[%s] %s: genesis %s carries pre-computed stateRoot=%v — brand L2 JSON must let flushAlloc compute the root (see Track D root cause + geth/core/genesis.go:592-595)",
|
|
*networkLabel, brand, path, v,
|
|
)
|
|
}
|
|
if v, ok := doc["genesisHash"]; ok && v != nil && v != "" {
|
|
log.Fatalf(
|
|
"[%s] %s: genesis %s carries pre-computed genesisHash=%v — brand L2 JSON must let toBlockWithRoot compute the hash (see geth/core/genesis.go:602-605)",
|
|
*networkLabel, brand, path, v,
|
|
)
|
|
}
|
|
// Defensive 0x prefix + hex shape validation on alloc keys.
|
|
// The EVM genesis loader rejects unprefixed keys. We normalize
|
|
// and log for missing prefixes (recoverable), and fail loudly for
|
|
// malformed keys (non-hex chars, wrong length) — never silently
|
|
// mutate something we can't prove is just a missing prefix.
|
|
//
|
|
// Acceptance shape after this block:
|
|
// - every alloc key matches /^0x[0-9a-fA-F]{40}$/
|
|
// - if any key fails that AND can't be fixed by prepending
|
|
// `0x`, the whole bootstrap aborts before any CreateChainTx
|
|
// burns LUX
|
|
alloc, _ := doc["alloc"].(map[string]any)
|
|
if alloc != nil {
|
|
fixed := 0
|
|
ok := 0
|
|
bad := make([]string, 0)
|
|
repaired := make(map[string]any, len(alloc))
|
|
for k, v := range alloc {
|
|
canonical, repair, valid := normalizeAllocKey(k)
|
|
if !valid {
|
|
bad = append(bad, k)
|
|
continue
|
|
}
|
|
repaired[canonical] = v
|
|
if repair {
|
|
fixed++
|
|
} else {
|
|
ok++
|
|
}
|
|
}
|
|
if len(bad) > 0 {
|
|
// Fail loud + give the operator everything they need to
|
|
// fix the genesis at source. Show up to 5 bad keys so
|
|
// the log line stays bounded but useful.
|
|
preview := bad
|
|
if len(preview) > 5 {
|
|
preview = preview[:5]
|
|
}
|
|
log.Fatalf(
|
|
"[%s] %s: %d malformed alloc keys (require canonical /^0x[0-9a-fA-F]{40}$/); sample: %v",
|
|
*networkLabel, brand, len(bad), preview,
|
|
)
|
|
}
|
|
doc["alloc"] = repaired
|
|
if patched, perr := json.Marshal(doc); perr == nil {
|
|
raw = patched
|
|
log.Printf(
|
|
"[%s] %s: alloc keys ok=%d 0x-repaired=%d (in-memory only, file unchanged)",
|
|
*networkLabel, brand, ok, fixed,
|
|
)
|
|
}
|
|
}
|
|
// Decomplect chain.name from brand identity. Default is content-
|
|
// addressed: same canonical genesis → same chain.name → idempotent
|
|
// across re-runs. Different bytes → different chain.name → no
|
|
// collision against stale "hanzo"/"zoo"/"spc"/"pars" chain rows
|
|
// from prior CreateChainTx attempts on P-chain.
|
|
chainTxName := overrideByBrand[brand]
|
|
if chainTxName == "" {
|
|
chainTxName = contentHashChainName(brand, *networkLabel, raw)
|
|
}
|
|
specs = append(specs, chainSpec{
|
|
Brand: brand,
|
|
ChainTxName: chainTxName,
|
|
GenesisRaw: raw,
|
|
EVMChainID: evmChainID,
|
|
})
|
|
log.Printf("[%s] loaded %s genesis (chainTxName=%s, evm chainId=%d, %d bytes)",
|
|
*networkLabel, brand, chainTxName, evmChainID, len(raw))
|
|
}
|
|
|
|
ctx := context.Background()
|
|
kc := primary.NewKeychainAdapter(secp256k1fx.NewKeychain(key))
|
|
|
|
infoClient := info.NewClient(*uri)
|
|
if myNodeID, _, err := infoClient.GetNodeID(ctx); err == nil {
|
|
log.Printf("[%s] connected to node %s", *networkLabel, myNodeID)
|
|
}
|
|
|
|
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 control key: %+v", *networkLabel, balResp)
|
|
}
|
|
|
|
// Single source of truth for "already created" — query the live
|
|
// P-chain for the current blockchain set, then look up by the
|
|
// content-hashed ChainTxName. This removes the operator burden of
|
|
// maintaining a --existing-X flag in lockstep with cluster reality
|
|
// AND makes the idempotency check exact: a pre-existing chain
|
|
// matches IFF it shares the same canonical genesis bytes (because
|
|
// chain.name encodes the content hash). A previous "hanzo" chain
|
|
// from a different genesis is NOT matched and falls through to a
|
|
// fresh CreateChainTx with a new, distinct chain.name.
|
|
existingByChainTxName := map[string]struct {
|
|
NetworkID ids.ID // parent validator-network (CreateNetworkTx ID)
|
|
ChainID ids.ID // the blockchain's own ID
|
|
}{}
|
|
if *forceNewChains {
|
|
log.Printf("[%s] FORCE-NEW-CHAINS: skipping platform.getBlockchains existing-chain detection; every spec will issue a fresh CreateNetworkTx + CreateChainTx", *networkLabel)
|
|
} else {
|
|
blockchains, perr := platformGetBlockchains(ctx, *uri)
|
|
if perr != nil {
|
|
log.Printf("WARN: platform.getBlockchains failed: %v (assuming no pre-existing chains)", perr)
|
|
} else {
|
|
byName := map[string]platformBlockchain{}
|
|
for _, b := range blockchains {
|
|
byName[strings.ToLower(b.Name)] = b
|
|
}
|
|
for _, spec := range specs {
|
|
if b, ok := byName[strings.ToLower(spec.ChainTxName)]; ok {
|
|
existingByChainTxName[spec.ChainTxName] = struct {
|
|
NetworkID ids.ID
|
|
ChainID ids.ID
|
|
}{NetworkID: b.NetworkID, ChainID: b.ID}
|
|
log.Printf("[%s/%s] pre-existing chain found via platform.getBlockchains: chainTxName=%s chainID=%s networkID=%s",
|
|
*networkLabel, spec.Brand, spec.ChainTxName, b.ID, b.NetworkID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
var nodeIDs []ids.NodeID
|
|
var minPrimaryEnd uint64
|
|
if !*skipValidators {
|
|
validators, err := pClient.GetCurrentValidators(ctx, ids.Empty, nil)
|
|
if err != nil {
|
|
log.Printf("WARN: GetCurrentValidators failed: %v (skipping validators)", err)
|
|
*skipValidators = true
|
|
} else {
|
|
for _, v := range validators {
|
|
nodeIDs = append(nodeIDs, v.NodeID)
|
|
if minPrimaryEnd == 0 || v.EndTime < minPrimaryEnd {
|
|
minPrimaryEnd = v.EndTime
|
|
}
|
|
}
|
|
log.Printf("[%s] discovered %d primary validators (min end = %d)", *networkLabel, len(nodeIDs), minPrimaryEnd)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
pBal, err := w.P().Builder().GetBalance()
|
|
if err != nil {
|
|
log.Fatalf("P balance: %v", err)
|
|
}
|
|
log.Printf("[%s] initial wallet P-chain LUX = %d nLUX (assetID=%s)", *networkLabel, pBal[luxAssetID], luxAssetID)
|
|
|
|
// Refuse work we cannot fund. We only charge for the chains that don't
|
|
// exist yet — pre-existing chains are skipped entirely.
|
|
pendingCount := 0
|
|
for _, s := range specs {
|
|
if _, ok := existingByChainTxName[s.ChainTxName]; !ok {
|
|
pendingCount++
|
|
}
|
|
}
|
|
// Empirical: ~1 LUX for createNetwork + ~0.5 LUX for createChain per
|
|
// pending chain, plus ~0.005 LUX per addValidator. Floor at 1.5 LUX *
|
|
// pendingCount.
|
|
minRequired := uint64(1_500_000_000) * uint64(pendingCount)
|
|
if pBal[luxAssetID] < minRequired {
|
|
log.Fatalf("insufficient P-chain balance for %d pending chains, need >= %d nLUX, have %d nLUX",
|
|
pendingCount, minRequired, pBal[luxAssetID])
|
|
}
|
|
|
|
owner := &secp256k1fx.OutputOwners{
|
|
Threshold: 1,
|
|
Addrs: []ids.ShortID{addr},
|
|
}
|
|
|
|
out := result{
|
|
Network: *networkLabel,
|
|
URI: *uri,
|
|
ControlKey: controlKey,
|
|
BIP44Index: uint32(*bipIdx),
|
|
VMID: vmID.String(),
|
|
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
|
Chains: make([]resultChain, 0, len(specs)),
|
|
}
|
|
|
|
for _, spec := range specs {
|
|
log.Printf("[%s] === bootstrapping %s (chainTxName=%s) ===", *networkLabel, spec.Brand, spec.ChainTxName)
|
|
|
|
var networkID ids.ID // chain-owner validator network (CreateNetworkTx ID)
|
|
var chainID ids.ID // the blockchain's own ID
|
|
reused := false
|
|
|
|
if ec, ok := existingByChainTxName[spec.ChainTxName]; ok {
|
|
networkID = ec.NetworkID
|
|
chainID = ec.ChainID
|
|
reused = true
|
|
log.Printf("[%s/%s] REUSE: networkID=%s chainID=%s (skipping both CreateNetworkTx and CreateChainTx)",
|
|
*networkLabel, spec.Brand, networkID, chainID)
|
|
} else {
|
|
log.Printf("[%s/%s] IssueCreateNetworkTx", *networkLabel, spec.Brand)
|
|
createNetTx, err := w.P().IssueCreateNetworkTx(owner)
|
|
if err != nil {
|
|
log.Fatalf("[%s] create network: %v", spec.Brand, err)
|
|
}
|
|
networkID = createNetTx.ID()
|
|
log.Printf("[%s/%s] network ID = %s", *networkLabel, spec.Brand, networkID)
|
|
time.Sleep(*chainSettleDelay)
|
|
|
|
// Re-sync wallet with the new chain-owner tx fetched, so the
|
|
// builder can authorize the CreateChain spend.
|
|
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] wallet re-sync attempt %d: %v", spec.Brand, i+1, err)
|
|
time.Sleep(5 * time.Second)
|
|
}
|
|
if err != nil {
|
|
log.Fatalf("[%s] wallet re-sync failed: %v", spec.Brand, err)
|
|
}
|
|
|
|
log.Printf("[%s/%s] IssueCreateChainTx (vmID=%s, chainTxName=%s)",
|
|
*networkLabel, spec.Brand, vmID, spec.ChainTxName)
|
|
createChainTx, err := w2.P().IssueCreateChainTx(networkID, spec.GenesisRaw, vmID, nil, spec.ChainTxName)
|
|
if err != nil {
|
|
log.Fatalf("[%s] create chain: %v", spec.Brand, err)
|
|
}
|
|
chainID = createChainTx.ID()
|
|
log.Printf("[%s/%s] blockchain ID = %s", *networkLabel, spec.Brand, chainID)
|
|
}
|
|
|
|
if !*skipValidators && len(nodeIDs) > 0 && !reused {
|
|
time.Sleep(3 * time.Second)
|
|
w3, err := primary.MakeWallet(ctx, &primary.WalletConfig{
|
|
URI: *uri,
|
|
LUXKeychain: kc,
|
|
EVMKeychain: kc,
|
|
PChainTxsToFetch: set.Of(networkID),
|
|
})
|
|
if err != nil {
|
|
log.Printf("[%s] WARN validator wallet sync failed: %v", spec.Brand, err)
|
|
} else {
|
|
startTime := time.Now().Add(60 * time.Second)
|
|
endTime := startTime.Add(300 * 24 * time.Hour)
|
|
if minPrimaryEnd > 0 {
|
|
primaryEnd := time.Unix(int64(minPrimaryEnd), 0)
|
|
safe := primaryEnd.Add(-1 * time.Hour)
|
|
if safe.Before(endTime) {
|
|
endTime = safe
|
|
}
|
|
}
|
|
for _, nid := range nodeIDs {
|
|
_, err := w3.P().IssueAddChainValidatorTx(&txs.ChainValidator{
|
|
Validator: txs.Validator{
|
|
NodeID: nid,
|
|
Start: uint64(startTime.Unix()),
|
|
End: uint64(endTime.Unix()),
|
|
Wght: 20,
|
|
},
|
|
Chain: networkID,
|
|
})
|
|
if err != nil {
|
|
log.Printf("[%s] WARN add validator %s: %v", spec.Brand, nid, err)
|
|
} else {
|
|
log.Printf("[%s] added chain validator: %s", spec.Brand, nid)
|
|
}
|
|
time.Sleep(1 * time.Second)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Use w (not w2) for the next chain: w still has the parent wallet
|
|
// state for issuing a fresh CreateNetwork. Re-sync w so it picks up
|
|
// the spent UTXOs from this iteration. Skip when we didn't touch
|
|
// the P-chain (reused chain).
|
|
if !reused {
|
|
w, err = primary.MakeWallet(ctx, &primary.WalletConfig{
|
|
URI: *uri,
|
|
LUXKeychain: kc,
|
|
EVMKeychain: kc,
|
|
})
|
|
if err != nil {
|
|
log.Fatalf("[%s] post-chain wallet re-sync: %v", spec.Brand, err)
|
|
}
|
|
}
|
|
|
|
// Wait for bootstrap, send heartbeat if configured, then probe.
|
|
// Bootstrap-only probe is required before sending a tx — otherwise
|
|
// eth_sendRawTransaction returns "chain not bootstrapped".
|
|
if *skipBootstrapWait {
|
|
log.Printf("[%s/%s] SKIP-BOOTSTRAP-WAIT: chain registered on P-chain at chainID=%s; operator must update --track-chains and roll luxd to activate", *networkLabel, spec.Brand, chainID)
|
|
} else {
|
|
if err := waitBootstrap(ctx, *uri, chainID.String(), *probeTimeout, *probeInterval); err != nil {
|
|
log.Fatalf("[%s] wait-bootstrap failed: %v", spec.Brand, err)
|
|
}
|
|
log.Printf("[%s/%s] isBootstrapped=true", *networkLabel, spec.Brand)
|
|
}
|
|
|
|
if *evmHeartbeatKeyHex != "" && !*skipBootstrapWait {
|
|
log.Printf("[%s/%s] sending EVM heartbeat tx to roll block 1", *networkLabel, spec.Brand)
|
|
if err := evmHeartbeat(ctx, *uri, chainID.String(), spec.EVMChainID, *evmHeartbeatKeyHex); err != nil {
|
|
// Heartbeat failure on a reused chain is non-fatal: the
|
|
// chain may already have blocks. Log and continue.
|
|
if reused {
|
|
log.Printf("[%s/%s] WARN heartbeat on reused chain failed (non-fatal): %v", *networkLabel, spec.Brand, err)
|
|
} else {
|
|
log.Fatalf("[%s] heartbeat failed: %v", spec.Brand, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
firstBlock := "0x0"
|
|
if *skipBootstrapWait {
|
|
log.Printf("[%s/%s] SKIP-BOOTSTRAP-WAIT: firstBlockHex=0x0 (chain not yet tracked by luxd)", *networkLabel, spec.Brand)
|
|
} else if !*probeBootstrapOnly {
|
|
firstBlock, err = probeChain(ctx, *uri, chainID.String(), *probeTimeout, *probeInterval)
|
|
if err != nil {
|
|
log.Fatalf("[%s] probe failed: %v", spec.Brand, err)
|
|
}
|
|
log.Printf("[%s/%s] PROBE OK: first eth_blockNumber=%s", *networkLabel, spec.Brand, firstBlock)
|
|
} else {
|
|
// In bootstrap-only mode we still capture whatever
|
|
// eth_blockNumber currently reports; the caller knows it may
|
|
// be 0x0.
|
|
httpc := &http.Client{Timeout: 5 * time.Second}
|
|
if blk, berr := ethBlockNumber(ctx, httpc, *uri, chainID.String()); berr == nil {
|
|
firstBlock = blk
|
|
}
|
|
log.Printf("[%s/%s] BOOTSTRAP-ONLY mode: eth_blockNumber=%s (heartbeat will roll block 1 out-of-band)", *networkLabel, spec.Brand, firstBlock)
|
|
}
|
|
|
|
out.Chains = append(out.Chains, resultChain{
|
|
Brand: spec.Brand,
|
|
ChainTxName: spec.ChainTxName,
|
|
NetworkID: networkID.String(),
|
|
ChainID: chainID.String(),
|
|
EVMChainID: spec.EVMChainID,
|
|
FirstBlockHex: firstBlock,
|
|
BootstrappedAt: time.Now().UTC().Format(time.RFC3339),
|
|
Reused: reused,
|
|
})
|
|
}
|
|
|
|
// Atomic write — temp + rename so a partial file never leaks.
|
|
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)
|
|
}
|
|
}
|
|
|
|
// platformBlockchain is the subset of platform.getBlockchains we care about.
|
|
//
|
|
// `NetworkID` is the validator-network (CreateNetworkTx ID) that owns the
|
|
// blockchain. `ID` is the blockchain's own globally unique chain ID. Both
|
|
// are 32-byte hashes; they identify different things and never alias.
|
|
//
|
|
// Wire field name `networkID` matches the canonical upstream lux/node
|
|
// shape (see lux/node/vms/platformvm/service.go::APIBlockchain).
|
|
type platformBlockchain struct {
|
|
ID ids.ID `json:"id"`
|
|
Name string `json:"name"`
|
|
NetworkID ids.ID `json:"networkID"`
|
|
VMID ids.ID `json:"vmID"`
|
|
}
|
|
|
|
// platformGetBlockchains queries the P-chain for the current set of
|
|
// blockchains. Returns the parsed list. This is the canonical
|
|
// already-exists check for idempotency.
|
|
func platformGetBlockchains(ctx context.Context, uri string) ([]platformBlockchain, error) {
|
|
body := strings.NewReader(
|
|
`{"jsonrpc":"2.0","id":1,"method":"platform.getBlockchains","params":{}}`)
|
|
req, _ := http.NewRequestWithContext(ctx, "POST", uri+"/v1/P", body)
|
|
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()
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
var parsed struct {
|
|
Result struct {
|
|
Blockchains []platformBlockchain `json:"blockchains"`
|
|
} `json:"result"`
|
|
Error *struct {
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
|
return nil, fmt.Errorf("decode platform.getBlockchains: %w (body=%s)", err, string(raw))
|
|
}
|
|
if parsed.Error != nil {
|
|
return nil, fmt.Errorf("rpc error: %s", parsed.Error.Message)
|
|
}
|
|
return parsed.Result.Blockchains, nil
|
|
}
|
|
|
|
// contentHashChainName returns the P-chain CreateChainTx name for a
|
|
// brand-env pair, content-addressed against the canonical genesis bytes.
|
|
//
|
|
// Format: "<brand>-<8hex>" where 8hex is the first 8 hex chars (4 bytes)
|
|
// of SHA256("<brand>|<env>|"||genesisBytes).
|
|
//
|
|
// Properties:
|
|
// - Deterministic. Same canonical genesis → same chain.name → idempotent
|
|
// re-runs of bootstrap-chain see the chain already exists and skip.
|
|
// - Collision-free against legacy "hanzo"/"zoo"/"spc"/"pars" rows from
|
|
// prior CreateChainTx attempts. Old chains stay orphaned on P-chain;
|
|
// the new chain.name is distinct so CreateChainTx succeeds.
|
|
// - Brand-rooted. The "<brand>-" prefix keeps the value humanly legible
|
|
// ("hanzo-3f4a1c8b") without conflating it with the user-facing
|
|
// brand alias (which lives at the chain-aliases CM layer).
|
|
//
|
|
// 4 bytes (8 hex chars) gives a 2^32 collision space — overkill for the
|
|
// 4-chain set we bootstrap. Operators who need explicit names use the
|
|
// --chain-name-override flag.
|
|
func contentHashChainName(brand, env string, genesisBytes []byte) string {
|
|
h := sha256.New()
|
|
h.Write([]byte(brand))
|
|
h.Write([]byte("|"))
|
|
h.Write([]byte(env))
|
|
h.Write([]byte("|"))
|
|
h.Write(genesisBytes)
|
|
digest := h.Sum(nil)
|
|
return fmt.Sprintf("%s-%s", brand, hex.EncodeToString(digest[:4]))
|
|
}
|
|
|
|
// normalizeAllocKey returns the canonical 0x-prefixed form of an EVM
|
|
// genesis alloc key, a flag indicating whether the input needed repair,
|
|
// and a validity flag.
|
|
//
|
|
// Acceptance shape: /^0x[0-9a-fA-F]{40}$/ after normalization.
|
|
//
|
|
// Returns:
|
|
// - canonical: the 0x-prefixed lowercase-or-mixed-case form (we don't
|
|
// lowercase the hex itself — Ethereum addresses preserve case as a
|
|
// checksum signal; if the caller mixed cases they want to keep it).
|
|
// - repaired: true if the input was missing a 0x prefix.
|
|
// - valid: false if the key fails the hex+length shape AFTER any prefix
|
|
// repair attempt. Callers must fail loudly on !valid.
|
|
//
|
|
// Single source of truth for "is this a usable alloc key?" — both the
|
|
// in-memory repair in main and any future validation tooling should
|
|
// route through this function.
|
|
func normalizeAllocKey(k string) (canonical string, repaired bool, valid bool) {
|
|
switch {
|
|
case strings.HasPrefix(k, "0x"):
|
|
canonical = k
|
|
case strings.HasPrefix(k, "0X"):
|
|
// Normalize the prefix to lowercase even when the body cases are
|
|
// preserved — `0X` is non-standard for EVM addresses.
|
|
canonical = "0x" + k[2:]
|
|
repaired = true
|
|
default:
|
|
canonical = "0x" + k
|
|
repaired = true
|
|
}
|
|
body := canonical[2:]
|
|
if len(body) != 40 {
|
|
return canonical, repaired, false
|
|
}
|
|
for _, c := range body {
|
|
switch {
|
|
case c >= '0' && c <= '9':
|
|
case c >= 'a' && c <= 'f':
|
|
case c >= 'A' && c <= 'F':
|
|
default:
|
|
return canonical, repaired, false
|
|
}
|
|
}
|
|
valid = true
|
|
return
|
|
}
|
|
|
|
// deriveLuxKey derives a secp256k1 private key from a BIP39 mnemonic at the
|
|
// canonical Lux web-wallet hardened path m/44'/9000'/0'/0/<idx>.
|
|
func deriveLuxKey(mnemonic string, idx uint32) (*secp256k1.PrivateKey, error) {
|
|
mnemonic = strings.TrimSpace(mnemonic)
|
|
if !luxbip39.IsMnemonicValid(mnemonic) {
|
|
return nil, fmt.Errorf("invalid BIP39 mnemonic")
|
|
}
|
|
seed := luxbip39.NewSeed(mnemonic, "")
|
|
master, err := luxbip32.NewMasterKey(seed)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
purpose, err := master.NewChildKey(luxbip32.FirstHardenedChild + 44)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
coinType, err := purpose.NewChildKey(luxbip32.FirstHardenedChild + 9000)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
account, err := coinType.NewChildKey(luxbip32.FirstHardenedChild + 0)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
change, err := account.NewChildKey(0)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
child, err := change.NewChildKey(idx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return secp256k1.ToPrivateKey(child.Key)
|
|
}
|
|
|
|
func formatPAddr(hrp string, a ids.ShortID) string {
|
|
b32, err := address.FormatBech32(hrp, a[:])
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return "P-" + b32
|
|
}
|
|
|
|
// waitBootstrap polls /v1/info.isBootstrapped(chain=<id>) until true or timeout.
|
|
func waitBootstrap(ctx context.Context, uri, chainID string, timeout, interval time.Duration) error {
|
|
deadline := time.Now().Add(timeout)
|
|
httpc := &http.Client{Timeout: 10 * time.Second}
|
|
for {
|
|
ok, err := infoIsBootstrapped(ctx, httpc, uri, chainID)
|
|
if ok {
|
|
return nil
|
|
}
|
|
if time.Now().After(deadline) {
|
|
return fmt.Errorf("not bootstrapped within %s: %v", timeout, err)
|
|
}
|
|
time.Sleep(interval)
|
|
}
|
|
}
|
|
|
|
// evmHeartbeat sends a 0-value self-transfer using the supplied secp256k1 key.
|
|
// Signs with the EVM chainID's EIP-155 v. Returns nil iff eth_sendRawTransaction
|
|
// returns a tx hash. The first tx on a fresh EVM chain produces block 1.
|
|
func evmHeartbeat(ctx context.Context, uri, chainID string, evmChainID uint64, keyHex string) error {
|
|
keyHex = strings.TrimSpace(strings.TrimPrefix(keyHex, "0x"))
|
|
keyBytes, err := hex.DecodeString(keyHex)
|
|
if err != nil {
|
|
return fmt.Errorf("parse evm key hex: %w", err)
|
|
}
|
|
priv, err := secp256k1.ToPrivateKey(keyBytes)
|
|
if err != nil {
|
|
return fmt.Errorf("parse evm key: %w", err)
|
|
}
|
|
ecdsaPriv := priv.ToECDSA()
|
|
// EVM address derivation: keccak256(pubKey[1:65])[-20:]
|
|
from := evmAddrFromPriv(priv)
|
|
httpc := &http.Client{Timeout: 10 * time.Second}
|
|
|
|
// Fetch nonce + suggested gas + chain baseFee
|
|
rpc := fmt.Sprintf("%s/v1/chain/%s/rpc", uri, chainID)
|
|
nonce, err := ethGetTransactionCount(ctx, httpc, rpc, from)
|
|
if err != nil {
|
|
return fmt.Errorf("nonce: %w", err)
|
|
}
|
|
// Use the genesis baseFeePerGas (25 gwei per devnet configs) bumped 100%
|
|
// so the tx beats the min on the very first block.
|
|
gasPrice := new(big.Int).SetUint64(50_000_000_000)
|
|
|
|
tx := gethtypes.NewTransaction(nonce, from, big.NewInt(0), 21000, gasPrice, nil)
|
|
signer := gethtypes.NewEIP155Signer(new(big.Int).SetUint64(evmChainID))
|
|
signed, err := gethtypes.SignTx(tx, signer, ecdsaPriv)
|
|
if err != nil {
|
|
return fmt.Errorf("sign tx: %w", err)
|
|
}
|
|
raw, err := signed.MarshalBinary()
|
|
if err != nil {
|
|
return fmt.Errorf("marshal tx: %w", err)
|
|
}
|
|
rawHex := "0x" + hex.EncodeToString(raw)
|
|
|
|
body := strings.NewReader(fmt.Sprintf(
|
|
`{"jsonrpc":"2.0","id":1,"method":"eth_sendRawTransaction","params":["%s"]}`, rawHex))
|
|
req, _ := http.NewRequestWithContext(ctx, "POST", rpc, body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := httpc.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("post: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
var parsed struct {
|
|
Result string `json:"result"`
|
|
Error *struct {
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
|
return fmt.Errorf("decode: %w (body=%s)", err, string(respBody))
|
|
}
|
|
if parsed.Error != nil {
|
|
return fmt.Errorf("rpc error: %s", parsed.Error.Message)
|
|
}
|
|
log.Printf(" heartbeat tx hash=%s (from=%s nonce=%d)", parsed.Result, from.Hex(), nonce)
|
|
return nil
|
|
}
|
|
|
|
// evmAddrFromPriv computes the EVM address as keccak256(pubKey)[-20:].
|
|
func evmAddrFromPriv(priv *secp256k1.PrivateKey) gethcommon.Address {
|
|
ecdsa := priv.ToECDSA()
|
|
luxAddr := secp256k1.PubkeyToAddress(ecdsa.PublicKey)
|
|
return gethcommon.BytesToAddress(luxAddr[:])
|
|
}
|
|
|
|
func ethGetTransactionCount(ctx context.Context, c *http.Client, rpc string, addr gethcommon.Address) (uint64, error) {
|
|
body := strings.NewReader(fmt.Sprintf(
|
|
`{"jsonrpc":"2.0","id":1,"method":"eth_getTransactionCount","params":["0x%x","pending"]}`, addr))
|
|
req, _ := http.NewRequestWithContext(ctx, "POST", rpc, body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := c.Do(req)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
var parsed struct {
|
|
Result string `json:"result"`
|
|
Error *struct {
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
|
return 0, fmt.Errorf("decode: %w (body=%s)", err, string(raw))
|
|
}
|
|
if parsed.Error != nil {
|
|
return 0, fmt.Errorf("rpc: %s", parsed.Error.Message)
|
|
}
|
|
hexStr := strings.TrimPrefix(parsed.Result, "0x")
|
|
if hexStr == "" {
|
|
return 0, nil
|
|
}
|
|
n, ok := new(big.Int).SetString(hexStr, 16)
|
|
if !ok {
|
|
return 0, fmt.Errorf("bad hex nonce: %s", parsed.Result)
|
|
}
|
|
return n.Uint64(), nil
|
|
}
|
|
|
|
// probeChain polls /v1/info isBootstrapped + /v1/chain/<id>/rpc eth_blockNumber
|
|
// until both return success or timeout elapses. Returns the first eth_blockNumber
|
|
// observed (hex). Fails with a descriptive error if the deadline is missed.
|
|
func probeChain(ctx context.Context, uri, chainID string, timeout, interval time.Duration) (string, error) {
|
|
deadline := time.Now().Add(timeout)
|
|
httpc := &http.Client{Timeout: 10 * time.Second}
|
|
|
|
for {
|
|
bootstrapped, bootErr := infoIsBootstrapped(ctx, httpc, uri, chainID)
|
|
var blockHex string
|
|
var blockErr error
|
|
if bootstrapped {
|
|
blockHex, blockErr = ethBlockNumber(ctx, httpc, uri, chainID)
|
|
}
|
|
|
|
if bootstrapped && blockErr == nil && isPositiveHex(blockHex) {
|
|
return blockHex, nil
|
|
}
|
|
|
|
if time.Now().After(deadline) {
|
|
return "", fmt.Errorf("probe deadline exceeded: bootstrapped=%v(%v) blockNumber=%q(%v)",
|
|
bootstrapped, bootErr, blockHex, blockErr)
|
|
}
|
|
time.Sleep(interval)
|
|
}
|
|
}
|
|
|
|
func infoIsBootstrapped(ctx context.Context, c *http.Client, uri, chainID string) (bool, error) {
|
|
body := strings.NewReader(fmt.Sprintf(
|
|
`{"jsonrpc":"2.0","id":1,"method":"info.isBootstrapped","params":{"chain":%q}}`, chainID))
|
|
req, _ := http.NewRequestWithContext(ctx, "POST", uri+"/v1/info", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := c.Do(req)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
var parsed struct {
|
|
Result struct {
|
|
IsBootstrapped bool `json:"isBootstrapped"`
|
|
} `json:"result"`
|
|
Error *struct {
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
|
return false, fmt.Errorf("decode info.isBootstrapped: %w (body=%s)", err, string(raw))
|
|
}
|
|
if parsed.Error != nil {
|
|
return false, fmt.Errorf("rpc error: %s", parsed.Error.Message)
|
|
}
|
|
return parsed.Result.IsBootstrapped, nil
|
|
}
|
|
|
|
func ethBlockNumber(ctx context.Context, c *http.Client, uri, chainID string) (string, error) {
|
|
body := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}`)
|
|
url := fmt.Sprintf("%s/v1/chain/%s/rpc", uri, chainID)
|
|
req, _ := http.NewRequestWithContext(ctx, "POST", url, body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := c.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
var parsed struct {
|
|
Result string `json:"result"`
|
|
Error *struct {
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
|
return "", fmt.Errorf("decode eth_blockNumber: %w (body=%s)", err, string(raw))
|
|
}
|
|
if parsed.Error != nil {
|
|
return "", fmt.Errorf("rpc error: %s", parsed.Error.Message)
|
|
}
|
|
return parsed.Result, nil
|
|
}
|
|
|
|
func isPositiveHex(h string) bool {
|
|
if !strings.HasPrefix(h, "0x") {
|
|
return false
|
|
}
|
|
// Accept "0x0" as bootstrapped-without-blocks-yet only if the RPC itself
|
|
// succeeded — our acceptance criteria say >0 is required.
|
|
if h == "0x0" {
|
|
return false
|
|
}
|
|
return true
|
|
}
|