Files
zeekay e9b58784de cmd/bootstrap-chain: decomplect chain.name from brand identity
The CreateChainTx `name` argument is a P-chain-unique identifier — caller's
choice. The brand alias ("hanzo", "zoo", "spc", "pars") is user-facing and
belongs at the chain-aliases ConfigMap layer. Conflating the two forced
the {brand}fix-{env}/ symlink workaround when re-bootstrapping over stale
P-chain chain rows from prior CreateChainTx attempts.

Default chain.name is now content-addressed: "<brand>-<8hex>" where
8hex = hex(SHA256("<brand>|<env>|"||genesisBytes))[:4]. Properties:

  - Idempotent: same canonical genesis → same chain.name → re-runs detect
    the existing chain via platform.getBlockchains and skip CreateChainTx.
  - Collision-free: different genesis bytes (any change to chainId, alloc,
    config) → different chain.name → CreateChainTx succeeds against
    P-chain instead of colliding with the previous stale row.
  - Brand-rooted: "hanzo-3f4a1c8b" stays humanly legible without conflating
    the chain.name with the user-facing brand alias.

Spec struct renamed: Name → Brand (user-facing) + ChainTxName (P-chain ID).
Result JSON gains "brand" and "chainTxName" fields so downstream
consumers (chain-aliases CM, explorer-chains CM, gateway routes) can pin
on the brand value without parsing the chain.name suffix.

Override path: --chain-name-override "<brand>=<name>,..." pins explicit
names for legacy reattachment scenarios.

4 new tests pin the contract:
  - Deterministic (same input → same output)
  - DifferentGenesisDifferentName (bytes change → name changes)
  - DifferentBrandDifferentName (brand-scoped)
  - DifferentEnvDifferentName (env-scoped)

All 14 tests pass.
2026-06-02 20:46:09 -07:00

269 lines
9.9 KiB
Go

// Unit tests for bootstrap-chain's defensive alloc key normalization.
//
// The whole point of normalizeAllocKey is to keep the load-and-validate
// path single-sourced: every consumer that touches an alloc key (the
// in-memory repair in main, future validation tooling, future on-disk
// rewriters) must route through this function. These tests pin the
// contract — a regression here would let a malformed key slip past the
// pre-flight check and burn LUX on a CreateChainTx that the node will
// later reject at genesis-load time.
package main
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestNormalizeAllocKey_AlreadyCanonical(t *testing.T) {
canonical, repaired, valid := normalizeAllocKey("0x1111111111111111111111111111111111111111")
if !valid {
t.Fatalf("canonical 0x... key must validate; got !valid")
}
if repaired {
t.Error("canonical key must not flag repaired=true")
}
if canonical != "0x1111111111111111111111111111111111111111" {
t.Errorf("canonical roundtrip mismatch: %q", canonical)
}
}
func TestNormalizeAllocKey_AddsMissingPrefix(t *testing.T) {
canonical, repaired, valid := normalizeAllocKey("1111111111111111111111111111111111111111")
if !valid {
t.Fatal("missing-prefix key must validate after repair")
}
if !repaired {
t.Error("missing-prefix repair must flag repaired=true")
}
if canonical != "0x1111111111111111111111111111111111111111" {
t.Errorf("expected 0x-prefixed form, got %q", canonical)
}
}
func TestNormalizeAllocKey_NormalizesCapitalPrefix(t *testing.T) {
// `0X` is non-standard for EVM address strings. We lowercase the
// PREFIX while preserving body case (in case the caller is using
// EIP-55 checksum casing on the address itself).
canonical, repaired, valid := normalizeAllocKey("0XaBcDeF0123456789aBcDeF0123456789aBcDeF01")
if !valid {
t.Fatal("0X-prefixed key with valid body must validate")
}
if !repaired {
t.Error("0X repair must flag repaired=true")
}
if canonical != "0xaBcDeF0123456789aBcDeF0123456789aBcDeF01" {
t.Errorf("prefix should normalize to 0x while preserving body case; got %q", canonical)
}
}
func TestNormalizeAllocKey_PreservesBodyCase(t *testing.T) {
// EIP-55 checksum addresses encode the checksum in upper/lower case
// of the hex digits. Stripping the case would destroy the checksum,
// so we must preserve body case verbatim.
canonical, _, valid := normalizeAllocKey("0xaBcDeF0123456789AbCdEf0123456789AbCdEf01")
if !valid {
t.Fatal("mixed-case body must validate")
}
if canonical != "0xaBcDeF0123456789AbCdEf0123456789AbCdEf01" {
t.Errorf("body case must be preserved verbatim; got %q", canonical)
}
}
func TestNormalizeAllocKey_RejectsShortBody(t *testing.T) {
// 39 hex chars after 0x — one short of a valid address. Must fail.
cases := []string{
"0x111111111111111111111111111111111111111", // 39
"0x11111111111111111111111111111111111111", // 38
"0x", // empty body
}
for _, k := range cases {
_, _, valid := normalizeAllocKey(k)
if valid {
t.Errorf("short-body key %q must fail validation", k)
}
}
}
func TestNormalizeAllocKey_RejectsLongBody(t *testing.T) {
// 41 hex chars — one over. Must fail.
_, _, valid := normalizeAllocKey("0x11111111111111111111111111111111111111111")
if valid {
t.Error("long-body key must fail validation")
}
}
func TestNormalizeAllocKey_RejectsNonHex(t *testing.T) {
cases := []string{
// `g` is not a hex char
"0x111111111111111111111111111111111111111g",
// Underscore
"0x_aBcDeF0123456789aBcDeF0123456789aBcDeF0",
// Spaces
"0x 111111111111111111111111111111111111111",
}
for _, k := range cases {
_, _, valid := normalizeAllocKey(k)
if valid {
t.Errorf("non-hex key %q must fail validation", k)
}
}
}
func TestNormalizeAllocKey_EmptyString(t *testing.T) {
_, _, valid := normalizeAllocKey("")
if valid {
t.Error("empty string must fail validation")
}
}
func TestNormalizeAllocKey_IsIdempotent(t *testing.T) {
// Calling normalize twice in a row must produce the same result —
// every consumer should be safe to defensive-call.
first, _, valid := normalizeAllocKey("aBcDeF0123456789aBcDeF0123456789aBcDeF01")
if !valid {
t.Fatal("first call must validate")
}
second, repairedSecond, validSecond := normalizeAllocKey(first)
if !validSecond {
t.Error("second call must also validate (idempotent)")
}
if repairedSecond {
t.Error("second call must report repaired=false; the input was already canonical")
}
if second != first {
t.Errorf("second-call output differs from first: %q vs %q", second, first)
}
}
// TestContentHashChainName_Deterministic pins the canonical
// chain.name format and its idempotency contract. Re-running
// bootstrap-chain with the same canonical genesis bytes MUST produce
// the same chain.name, otherwise idempotent re-runs would burn LUX
// creating duplicate chains for the same content.
func TestContentHashChainName_Deterministic(t *testing.T) {
g := []byte(`{"config":{"chainId":36963},"alloc":{}}`)
a := contentHashChainName("hanzo", "testnet", g)
b := contentHashChainName("hanzo", "testnet", g)
if a != b {
t.Fatalf("content hash must be deterministic: %q vs %q", a, b)
}
if !strings.HasPrefix(a, "hanzo-") {
t.Errorf("name must be brand-rooted; got %q", a)
}
suffix := strings.TrimPrefix(a, "hanzo-")
if len(suffix) != 8 {
t.Errorf("suffix must be 8 hex chars; got %q (len=%d)", suffix, len(suffix))
}
for _, c := range suffix {
switch {
case c >= '0' && c <= '9', c >= 'a' && c <= 'f':
default:
t.Errorf("suffix must be lowercase hex; got %q (bad char %q)", suffix, c)
}
}
}
// TestContentHashChainName_DifferentGenesisDifferentName pins the
// no-collision contract. A change to the genesis bytes (e.g. EVM chain
// ID, alloc, anything) MUST produce a different chain.name, so
// CreateChainTx succeeds against P-chain instead of colliding with the
// previous (stale) chain row.
func TestContentHashChainName_DifferentGenesisDifferentName(t *testing.T) {
a := contentHashChainName("hanzo", "testnet", []byte(`{"config":{"chainId":36963}}`))
b := contentHashChainName("hanzo", "testnet", []byte(`{"config":{"chainId":36964}}`))
if a == b {
t.Fatalf("different genesis bytes must produce different chain.name; both = %q", a)
}
}
// TestContentHashChainName_DifferentBrandDifferentName pins the
// brand-scoping contract. Two brands with identical (improbable but
// possible) genesis bytes still get distinct chain.name values because
// the brand is mixed into the hash input.
func TestContentHashChainName_DifferentBrandDifferentName(t *testing.T) {
g := []byte(`{"config":{"chainId":36963},"alloc":{}}`)
a := contentHashChainName("hanzo", "testnet", g)
b := contentHashChainName("zoo", "testnet", g)
if a == b {
t.Fatalf("different brands must produce different chain.name; both = %q", a)
}
}
// TestContentHashChainName_DifferentEnvDifferentName pins env-scoping.
// Even with identical brand+genesis, testnet and devnet produce
// distinct chain.name values.
func TestContentHashChainName_DifferentEnvDifferentName(t *testing.T) {
g := []byte(`{"config":{"chainId":36963},"alloc":{}}`)
a := contentHashChainName("hanzo", "testnet", g)
b := contentHashChainName("hanzo", "devnet", g)
if a == b {
t.Fatalf("different envs must produce different chain.name; both = %q", a)
}
}
// TestBrandL2GenesisHasNoStateRootOrGenesisHash pins the Track D
// regression: every <brand>-<env>/genesis.json under configs/ MUST NOT
// embed a `stateRoot` or `genesisHash` field. These two fields override
// the result of luxfi/geth/core/genesis.go::Genesis.Commit's flushAlloc
// — meaning any value present in the JSON silently displaces the
// canonical alloc trie. The historical workaround that needed these
// fields (Lux primary C-Chain header mismatch) is dead; the empirical
// block-0 hash now lives in cchain.json. Brand L2 JSONs MUST stay
// clean. If a generator (or a hand-edit) reintroduces these fields,
// CreateChainTx will burn LUX and produce a chain whose block 0 has
// a stateRoot whose trie node was never persisted to disk — the exact
// failure mode that produced the missing-trie-node errors on testnet
// hanzo / zoo / spc / pars between 2026-05-27 and 2026-06-02.
func TestBrandL2GenesisHasNoStateRootOrGenesisHash(t *testing.T) {
_, thisFile, _, _ := runtime.Caller(0)
configsDir := filepath.Join(filepath.Dir(thisFile), "..", "..", "configs")
entries, err := os.ReadDir(configsDir)
if err != nil {
t.Fatalf("read configs dir %s: %v", configsDir, err)
}
checked := 0
for _, e := range entries {
if !e.IsDir() {
continue
}
name := e.Name()
// Brand L2 dirs look like `<brand>-<env>` where env is
// {mainnet,testnet,devnet}; skip primary-network legacy dirs
// (testnet/, devnet/, mainnet/, local/, localnet/, dev/,
// chain-configs/) — those have the embedded cChainGenesis
// blob which is a separate concern handled by luxd's primary
// genesis loader, not bootstrap-chain.
if !(strings.HasSuffix(name, "-mainnet") || strings.HasSuffix(name, "-testnet") || strings.HasSuffix(name, "-devnet")) {
continue
}
path := filepath.Join(configsDir, name, "genesis.json")
raw, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
continue // brand dir without a genesis.json (e.g. metadata only)
}
t.Fatalf("read %s: %v", path, err)
}
var doc map[string]any
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatalf("invalid json %s: %v", path, err)
}
if v, ok := doc["stateRoot"]; ok && v != nil && v != "" {
t.Errorf("%s: carries stateRoot=%v — must be absent (Track D regression)", path, v)
}
if v, ok := doc["genesisHash"]; ok && v != nil && v != "" {
t.Errorf("%s: carries genesisHash=%v — must be absent (Track D regression)", path, v)
}
checked++
}
if checked == 0 {
t.Fatal("no brand L2 genesis.json files found under configs/ — test self-check failed")
}
t.Logf("Track D regression gate: checked %d brand L2 genesis files, all clean", checked)
}