dchain: the genesis is chain-creation data, not a property of the binary

The D-Chain derived its height-0 block from compiled-in defaults and threw away
the genesis bytes the node handed it. Two consequences, both live:

  * dex v1.4.2..v1.13.0 and v1.14.0+ derive DIFFERENT genesis blocks. v1.14.0
    (e58e2a3) put the custody ledger into ComposeRoot, taking the keccak preimage
    from 136 to 168 bytes, so the empty-state root moved. lux-mainnet and
    lux-testnet run MuetnVSbs1UnPUDTH5q4kgbKcfRx97DjmzZXVxcCdQ52H9sES; lux-devnet
    runs mpxP6xF3ahpjdwP6YgjtagSWQCZSR6pziEqDoDv573k2V3WJA. The fleets are split
    and nothing said so.
  * every network shared one genesis, because none of them reached the VM.

A wiped validator therefore adopted whatever its binary believed, looked healthy
alone, and was partitioned for good. Wiping chainData is the ordinary repair for
a stuck chain, so the ordinary repair was the thing that stranded the node.

Genesis now comes from block.Init.Genesis — the dchain.json recorded in the
P-Chain CreateChainTx, carried across the plugin boundary as
InitializeRequest.GenesisBytes and, until now, dropped on the floor. The document
is opaque: its digest occupies genesis's parent-root slot, which nothing used, so
the chain begins from its configuration and every later root descends from it. No
schema of the document enters consensus.

meta:genesis records the height-0 image in the batch that commits it. Every start
reconciles three views — the creation record, this binary, the database — and a
disagreement refuses to start naming all three. A chain born without the record
recovers it at height 0 from its own head block, which is durable data, not a
guess; past height 0 there is nothing honest to recover and it refuses.

Removed with it: loadHeadBlock's rebuild-genesis-from-code path, and readRoot's
silent zero for a missing root, which would have rebased the chain just as quietly.

genesis_test.go pins the images byte for byte, including both live fleet genesis
blocks read from luxd, so this cannot drift again unnoticed.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-07 16:21:48 -07:00
co-authored by Hanzo Dev
parent aae6cd0857
commit 47928ea08d
5 changed files with 697 additions and 93 deletions
+147
View File
@@ -0,0 +1,147 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dchain
import (
"fmt"
"time"
"github.com/luxfi/crypto/hash"
)
// genesis.go is the single home of the D-Chain's height-0 block.
//
// A chain's genesis is creation data. It is NOT a property of whichever binary
// happens to be running, and the moment it becomes one the fleet can split without
// a single error: a node whose chainData is wiped re-derives genesis from its own
// code, agrees with nobody, and looks perfectly healthy in isolation. Wiping
// chainData is the ordinary repair for a stuck chain, so the ordinary repair is
// what strands the validator. That is what happened here — dex v1.13 and v1.14
// derive different height-0 blocks, because the custody ledger entered the root
// composition (ComposeRoot, state.go) and the block image changed with it.
//
// Three views of the genesis exist, and Initialize refuses to start unless they
// agree:
//
// expected — from the chain-creation document, block.Init.Genesis. For the
// D-Chain that is the dchain.json recorded in the P-Chain
// CreateChainTx, delivered verbatim across the plugin boundary as
// InitializeRequest.GenesisBytes. Immutable: a wiped node reads the
// same document its chain was born from and rebuilds the same block.
// derived — what this binary computes with no document at all. The old and only
// view. TestGenesisGolden pins it byte for byte, so a change to the
// block image or the root composition fails in CI rather than on a
// fleet.
// stored — meta:genesis, written once in the batch that commits the height-0
// head and never rewritten. This node's record of which chain it is on.
//
// A node that cannot agree with its own chain about height 0 has nothing useful to
// do; starting anyway is exactly what makes the split silent.
// genesisOrigin binds the chain-creation document into the height-0 block.
//
// The document is opaque here. Committing to its digest — rather than parsing it —
// keeps its schema out of consensus entirely, so the D-Chain's fee table, market
// list and chain id are bound to the chain without the VM knowing what any of them
// mean. The digest occupies genesis's parent-root slot, which is otherwise unused
// (nothing precedes genesis): the chain begins from this configuration. Every later
// execution root descends from it, so two chains created from different documents
// cannot agree on a root at any height. With no document the slot stays zero.
func genesisOrigin(document []byte) (origin [Size]byte) {
if len(document) == 0 {
return origin
}
return hash.ComputeKeccak256Array(document)
}
// canonicalGenesis builds the height-0 block for a chain created from document:
// no parent, height 0, timestamp 0, no transactions, and the execution root over
// empty state rooted at the document's origin. Pure — every validator and every
// restart computes the same bytes from the same document.
func (vm *VM) canonicalGenesis(document []byte) *Block {
var emptyLedger [Size]byte // genesis has no custody ledger yet
root, _, _, _ := ExecutionRoot(genesisOrigin(document), nil, nil, nil, emptyLedger, 0)
return newBlock(vm, genesisParent, 0, time.Unix(0, 0).UTC(), root, nil)
}
// recoverGenesis restores the genesis record of a chain born before the record
// existed. It is possible only at height 0, where the persisted head block IS the
// genesis: the bytes come from the chain's own data and the running binary has no
// say in them. Past height 0 the genesis is gone and the node cannot prove which
// chain it is on, which is a refusal — reconstructing it from the binary instead is
// the defect this file exists to remove. Must be called under vm.mu.
func (vm *VM) recoverGenesis() ([]byte, error) {
raw, err := readHeadBlock(vm.db)
if err != nil {
return nil, fmt.Errorf("dchain: refusing to start: this database holds a chain with neither a "+
"genesis record nor a head block, so its genesis is unknowable: %w", err)
}
head, err := parseBlock(vm, raw)
if err != nil {
return nil, fmt.Errorf("dchain: head block is unreadable: %w", err)
}
if head.height != 0 {
return nil, fmt.Errorf(
"dchain: refusing to start: this chain is at height %d and has no genesis record, so the node "+
"cannot prove which chain it is on (it was born under a build that kept none). Re-sync from a "+
"peer that has one. Do NOT wipe chainData: a wipe makes this node adopt its own binary's genesis "+
"%s and partition permanently",
head.height, describeGenesis(vm.canonicalGenesis(nil)))
}
if err := writeGenesis(vm.db, raw); err != nil {
return nil, fmt.Errorf("dchain: record recovered genesis: %w", err)
}
return raw, nil
}
// genesis resolves the height-0 block this node must build on, and refuses to
// return one its own chain contradicts.
//
// document is block.Init.Genesis (the chain-creation record). stored is
// meta:genesis, empty only when this database holds no chain at all.
func (vm *VM) genesis(document, stored []byte) (*Block, error) {
expected := vm.canonicalGenesis(document)
if len(stored) == 0 {
return expected, nil // chain is born here; Initialize records it
}
have, err := parseBlock(vm, stored)
if err != nil {
return nil, fmt.Errorf("dchain: stored genesis record is unreadable: %w (image %x)", err, stored)
}
if have.id != expected.id {
return nil, genesisDisagreement(vm, document, expected, have)
}
return have, nil
}
// genesisDisagreement names all three views so an operator can see which one is the
// odd one out — the creation document, the binary, or the data on disk — without
// having to reproduce the derivation by hand.
func genesisDisagreement(vm *VM, document []byte, expected, stored *Block) error {
origin := "no chain-creation document was supplied, so this is the binary's default"
if len(document) > 0 {
origin = fmt.Sprintf("%d-byte chain-creation document, digest %x", len(document), genesisOrigin(document))
}
return fmt.Errorf(
"dchain: refusing to start: this node disagrees with its own chain about height 0.\n"+
" expected (chain-creation record): %s\n"+
" source: %s\n"+
" derived (this VM binary, no document): %s\n"+
" stored (this node's database): %s\n"+
"This node would build on a genesis its own chain does not have, which no peer would\n"+
"accept and no error would report. Run the dex build whose genesis matches the stored\n"+
"one, or re-sync from a peer. Do NOT wipe chainData: a wipe removes the stored genesis\n"+
"and lets this binary's genesis win silently",
describeGenesis(expected), origin,
describeGenesis(vm.canonicalGenesis(nil)),
describeGenesis(stored))
}
// describeGenesis renders a height-0 block as its id, execution root and full
// canonical image. The image is 84 bytes, so printing it costs nothing and lets an
// operator diff two nodes directly.
func describeGenesis(b *Block) string {
return fmt.Sprintf("%s (execRoot %x, image %x)", b.id, b.execRoot, b.bytes)
}
+472
View File
@@ -0,0 +1,472 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dchain
import (
"context"
"encoding/hex"
"strings"
"testing"
"time"
"github.com/luxfi/consensus/engine/chain/block"
"github.com/luxfi/database/memdb"
"github.com/luxfi/log"
)
// genesis_test.go is the golden vector for the D-Chain's height-0 block.
//
// These constants are not derived at test time. They are pinned images, and any
// change to the block encoding, the execution-root composition, or the way the
// chain-creation document binds into genesis breaks them here — in CI, on a laptop,
// before a release exists. That is the whole point: without a pinned vector the
// genesis is whatever the binary computes today, and the first time it changes the
// only symptom is a validator that wipes its chainData for an unrelated reason and
// never rejoins.
//
// THIS ALREADY HAPPENED. dex v1.4.2 through v1.13.0 and dex v1.14.0 onward derive
// different genesis blocks, because commit 2a164da put the custody ledger into
// ComposeRoot (state.go) and the 136-byte keccak preimage became 168 bytes. Both
// ids are pinned below so the split is a fact in the test suite rather than a story.
const (
// genesisImageHex is the canonical 84-byte height-0 block for a chain created
// with no creation document: 32-byte empty parent, height 0, timestamp 0,
// 32-byte execution root, zero transactions.
genesisImageHex = "0000000000000000000000000000000000000000000000000000000000000000" + // parent
"0000000000000000" + // height
"0000000000000000" + // timestamp
"92ae9d5c5dca023b00d94750403b9cd9cd450c84eb46a9908c80de7a1c3cf698" + // execRoot
"00000000" // tx count
// genesisID is ids.Checksum256(genesisImage) — the block id every validator on
// a document-less D-Chain must report for height 0.
genesisID = "mpxP6xF3ahpjdwP6YgjtagSWQCZSR6pziEqDoDv573k2V3WJA"
// genesisIDPreLedger is the height-0 id every dex build from v1.4.2 through
// v1.13.0 produced, before the custody ledger entered the root composition. A
// node running one of those builds derives THIS and can never agree with a
// fleet on the current one. Pinned so the divergence is regression-tested, not
// rediscovered.
genesisIDPreLedger = "MuetnVSbs1UnPUDTH5q4kgbKcfRx97DjmzZXVxcCdQ52H9sES"
// preLedgerImageHex is that block, byte for byte — the image lux-mainnet and
// lux-testnet are actually running, read from luxd on 2026-08-07 via
// /v1/bc/D/dex/clob_get_markets -> {"height":0,"lastAccepted":"MuetnVSb…",
// "root":"f8e013a4…"}
// on all 15 pods. It differs from genesisImageHex in the execution root alone.
preLedgerImageHex = "0000000000000000000000000000000000000000000000000000000000000000" +
"0000000000000000" +
"0000000000000000" +
"f8e013a48fbc8e0ae0523a4174e333f1687da718a0f3e3bec31b6e8f7cff6815" +
"00000000"
)
// mainnetCreationDocument is the D-Chain's chain-creation record on Lux mainnet,
// byte for byte: the dchain.json the node bakes into the P-Chain CreateChainTx and
// hands the VM as block.Init.Genesis. 375 bytes, no trailing newline.
//
// It is copied here rather than imported because a golden vector is a constant. If
// the operator's config ever changes these bytes the mainnet D-Chain's genesis
// changes with them — which is a chain-splitting act, and this test is where it
// must be seen and consciously re-pinned.
const mainnetCreationDocument = `{
"description": "Decentralized Exchange \u2014 native CLOB + AMM + perpetuals",
"feeConfig": {
"makerFeeBps": 2,
"takerFeeBps": 5
},
"liquidityPools": [],
"message": "Lux D-Chain Genesis",
"name": "D-Chain",
"networkId": 1,
"perpetualMarkets": [],
"timestamp": 1730446786,
"tradingPairs": [],
"version": 1,
"vm": "DexVM",
"chainId": 96469
}`
// mainnetGenesisID is the height-0 block id of a D-Chain created from
// mainnetCreationDocument.
const mainnetGenesisID = "2cdEmT9KrxfGzaRiCLJXAkSmkuPzzVSDR6iMzeGSVYkpLknRTm"
// TestGenesisGolden pins the document-less genesis image byte for byte. It fails on
// any change to the block encoding or the execution-root composition.
func TestGenesisGolden(t *testing.T) {
want, err := hex.DecodeString(genesisImageHex)
if err != nil {
t.Fatal(err)
}
if len(want) != blockHeaderSize+4 {
t.Fatalf("golden image is %d bytes, want %d", len(want), blockHeaderSize+4)
}
gen := (&VM{}).canonicalGenesis(nil)
if hex.EncodeToString(gen.bytes) != genesisImageHex {
t.Fatalf("genesis image changed.\n got %x\nwant %s\n"+
"A D-Chain built by this binary cannot join a fleet built by any other.",
gen.bytes, genesisImageHex)
}
if gen.id.String() != genesisID {
t.Fatalf("genesis id = %s, want %s", gen.id, genesisID)
}
if gen.id.String() == genesisIDPreLedger {
t.Fatalf("genesis reverted to the pre-ledger composition (%s)", genesisIDPreLedger)
}
// Parsing the image back must reproduce the same block: the id is a hash of
// these bytes, so a decoder that disagrees with the encoder is the same split
// by another route.
back, err := parseBlock(&VM{}, want)
if err != nil {
t.Fatalf("parse golden image: %v", err)
}
if back.id != gen.id {
t.Fatalf("round-trip id = %s, want %s", back.id, gen.id)
}
}
// TestGenesisBindsCreationDocument pins the mainnet D-Chain's genesis and proves
// the creation document actually reaches it — a document-bound genesis that equals
// the document-less one would mean init.Genesis is being ignored again.
func TestGenesisBindsCreationDocument(t *testing.T) {
doc := []byte(mainnetCreationDocument)
if len(doc) != 375 {
t.Fatalf("mainnet creation document is %d bytes, want 375", len(doc))
}
// Pinned separately from the genesis id so a bad transcription of the document
// is distinguishable from a change in the derivation.
const wantDigest = "0e19a1b63f4ee93cc436d8bbefe4a2694a947d1bbd0a47e11a874c8476c3d1ee"
if got := genesisOrigin(doc); hex.EncodeToString(got[:]) != wantDigest {
t.Fatalf("creation document digest = %x, want %s", got, wantDigest)
}
vm := &VM{}
bound := vm.canonicalGenesis(doc)
if bound.id.String() != mainnetGenesisID {
t.Fatalf("mainnet genesis id = %s, want %s\n"+
"Either the creation document or the genesis derivation changed; both split the chain.",
bound.id, mainnetGenesisID)
}
if bound.id == vm.canonicalGenesis(nil).id {
t.Fatal("a chain created from a document has the same genesis as one created from none; " +
"the creation document is not reaching height 0")
}
// A one-byte edit to the document must move the genesis. Nothing in the
// document may be cosmetic as far as chain identity is concerned.
edited := append([]byte{}, doc...)
edited[len(edited)-2] = '8' // chainId 96469 -> 96468
if vm.canonicalGenesis(edited).id == bound.id {
t.Fatal("editing the creation document left the genesis unchanged")
}
}
// TestGenesisHonoursSuppliedDocument drives the real Initialize path: a chain born
// from a creation document must adopt the document-bound genesis, not the binary's
// default, and must record it.
func TestGenesisHonoursSuppliedDocument(t *testing.T) {
db := memdb.New()
doc := []byte(mainnetCreationDocument)
vm := &VM{}
if err := vm.Initialize(context.Background(), block.Init{
DB: db,
Log: log.NewNoOpLogger(),
ToEngine: make(chan block.Message, 16),
Genesis: doc,
Config: authConfig(t),
}); err != nil {
t.Fatalf("Initialize: %v", err)
}
if vm.lastAcceptedID.String() != mainnetGenesisID {
t.Fatalf("height-0 head = %s, want the document-bound genesis %s",
vm.lastAcceptedID, mainnetGenesisID)
}
stored, err := readGenesis(db)
if err != nil {
t.Fatalf("the chain was born without recording its genesis: %v", err)
}
if hex.EncodeToString(stored) != hex.EncodeToString(vm.canonicalGenesis(doc).bytes) {
t.Fatalf("recorded genesis %x is not the document-bound one", stored)
}
}
// TestGenesisSurvivesWipe is the reported failure, run forwards. Wiping chainData
// is the ordinary repair for a stuck chain; a wiped validator must come back on the
// SAME chain it left. It does, because it rebuilds genesis from the immutable
// creation record rather than from whatever its binary believes.
func TestGenesisSurvivesWipe(t *testing.T) {
doc := []byte(mainnetCreationDocument)
init := func(db *memdb.Database) *VM {
t.Helper()
vm := &VM{}
if err := vm.Initialize(context.Background(), block.Init{
DB: db,
Log: log.NewNoOpLogger(),
ToEngine: make(chan block.Message, 16),
Genesis: doc,
Config: authConfig(t),
}); err != nil {
t.Fatalf("Initialize: %v", err)
}
return vm
}
before := init(memdb.New()).lastAcceptedID
after := init(memdb.New()).lastAcceptedID // the wipe: a brand-new, empty chainData
if before != after {
t.Fatalf("a wiped node adopted a different genesis: %s -> %s", before, after)
}
}
// TestGenesisRefusesDisagreement covers the mechanism that used to be silent: a
// node whose chain says one thing about height 0 and whose inputs say another must
// refuse to start, and must name all three views so an operator can tell which one
// moved.
func TestGenesisRefusesDisagreement(t *testing.T) {
db := memdb.New()
born := []byte(mainnetCreationDocument)
vm := &VM{}
if err := vm.Initialize(context.Background(), block.Init{
DB: db,
Log: log.NewNoOpLogger(),
ToEngine: make(chan block.Message, 16),
Genesis: born,
Config: authConfig(t),
}); err != nil {
t.Fatalf("Initialize: %v", err)
}
// Restart the same chainData against a different creation document.
other := append([]byte{}, born...)
other[len(other)-2] = '8'
err := (&VM{}).Initialize(context.Background(), block.Init{
DB: db,
Log: log.NewNoOpLogger(),
ToEngine: make(chan block.Message, 16),
Genesis: other,
Config: authConfig(t),
})
if err == nil {
t.Fatal("the VM started on a chain whose genesis it disagrees with")
}
msg := err.Error()
for _, want := range []string{
"expected", "derived", "stored", // all three views named
mainnetGenesisID, // the stored one
(&VM{}).canonicalGenesis(other).id.String(), // the one it would have used
(&VM{}).canonicalGenesis(nil).id.String(), // the binary's default
} {
if !strings.Contains(msg, want) {
t.Fatalf("refusal does not name %q:\n%s", want, msg)
}
}
}
// TestGenesisLiveFleets pins what is actually running, not what ought to be.
//
// Read from luxd on 2026-08-07, all 15 pods agreeing, every chain at height 0:
//
// lux-mainnet D=29Rnd1kbr9ZRyPF1AobiZvvF8LWMP9bmJhRC6979VBJkiXbRoD MuetnVSb…
// lux-testnet D=2wWhbR6rBZmfNHcxrBX1Rm1y7uH4rWnK5xzed6GqstcdyPyPyM MuetnVSb…
// lux-devnet D=23wLjqidbQ6wHA2hfGvkM8WMgbwpxgY8ryyZJsjoWZr9uG2pp1 mpxP6xF3…
//
// The fleets are on two different genesis blocks. This test asserts the current
// binary can produce the devnet one and CANNOT produce the mainnet/testnet one —
// that is the live split stated as a fact, so nobody has to rediscover it.
func TestGenesisLiveFleets(t *testing.T) {
live, err := hex.DecodeString(preLedgerImageHex)
if err != nil {
t.Fatal(err)
}
mainnetLive, err := parseBlock(&VM{}, live)
if err != nil {
t.Fatalf("parse the live mainnet genesis image: %v", err)
}
if mainnetLive.id.String() != genesisIDPreLedger {
t.Fatalf("live mainnet image hashes to %s, not the id luxd reports (%s)",
mainnetLive.id, genesisIDPreLedger)
}
// No document produces the devnet chain; that is the one this binary can serve.
if (&VM{}).canonicalGenesis(nil).id.String() != genesisID {
t.Fatal("the document-less genesis no longer matches lux-devnet")
}
// Nothing this binary can derive reproduces mainnet/testnet. Their genesis was
// built by a root composition this code no longer contains, so a dexvm plugin
// built from here cannot join those chains — it must refuse, not fork at
// block 1.
for name, g := range map[string]*Block{
"no document": (&VM{}).canonicalGenesis(nil),
"mainnet doc": (&VM{}).canonicalGenesis([]byte(mainnetCreationDocument)),
"arbitrary bytes": (&VM{}).canonicalGenesis([]byte("anything at all")),
} {
if g.id == mainnetLive.id {
t.Fatalf("%s reproduced the pre-ledger mainnet genesis; the pin is wrong", name)
}
}
}
// TestGenesisRecoversHeight0ChainThenRefuses is the live mainnet upgrade, run in a
// test. A D-Chain born under a pre-v1.14 build carries no genesis record; at height
// 0 the record is recoverable from its own head block, and the new binary then
// discovers it cannot serve that chain and says so with all three values. The
// alternative — starting anyway — forks the fleet at block 1 in silence.
func TestGenesisRecoversHeight0ChainThenRefuses(t *testing.T) {
db := memdb.New()
live, err := hex.DecodeString(preLedgerImageHex)
if err != nil {
t.Fatal(err)
}
head, err := parseBlock(&VM{}, live)
if err != nil {
t.Fatal(err)
}
// Exactly what a v1.13-born D-Chain at height 0 has on disk: head meta and head
// block, no meta:genesis.
batch := db.NewBatch()
for _, w := range []func() error{
func() error { return writeLastAccepted(batch, head.id) },
func() error { return writeHeight(batch, 0) },
func() error { return writeRoot(batch, head.execRoot) },
func() error { return writeHeadBlock(batch, live) },
} {
if err := w(); err != nil {
t.Fatal(err)
}
}
if err := batch.Write(); err != nil {
t.Fatal(err)
}
err = (&VM{}).Initialize(context.Background(), block.Init{
DB: db,
Log: log.NewNoOpLogger(),
ToEngine: make(chan block.Message, 16),
Genesis: []byte(mainnetCreationDocument),
Config: authConfig(t),
})
if err == nil {
t.Fatal("a current build started on a pre-ledger chain; it would fork at block 1")
}
if !strings.Contains(err.Error(), genesisIDPreLedger) {
t.Fatalf("refusal does not name the chain's own genesis:\n%v", err)
}
// The record was recovered from the chain's own data before the refusal, so the
// next start reports the same three values instead of a different complaint.
recovered, rerr := readGenesis(db)
if rerr != nil {
t.Fatalf("genesis was not recovered from the height-0 head: %v", rerr)
}
if hex.EncodeToString(recovered) != preLedgerImageHex {
t.Fatalf("recovered genesis %x is not the chain's own head block", recovered)
}
}
// TestGenesisRecoversAndContinues is the benign half of the same recovery: a
// document-less chain born under an older build carries no genesis record, its
// height-0 head IS its genesis, and the new binary agrees with it — so it records
// what the chain already says and keeps running. This is the standalone venue and
// lux-devnet's D-Chain.
func TestGenesisRecoversAndContinues(t *testing.T) {
db := memdb.New()
gen := (&VM{}).canonicalGenesis(nil)
batch := db.NewBatch()
for _, w := range []func() error{
func() error { return writeLastAccepted(batch, gen.id) },
func() error { return writeHeight(batch, 0) },
func() error { return writeRoot(batch, gen.execRoot) },
func() error { return writeHeadBlock(batch, gen.bytes) },
} {
if err := w(); err != nil {
t.Fatal(err)
}
}
if err := batch.Write(); err != nil {
t.Fatal(err)
}
vm := &VM{}
if err := vm.Initialize(context.Background(), block.Init{
DB: db,
Log: log.NewNoOpLogger(),
ToEngine: make(chan block.Message, 16),
Config: authConfig(t),
}); err != nil {
t.Fatalf("a chain whose genesis this binary agrees with was refused: %v", err)
}
if vm.lastAcceptedID != gen.id {
t.Fatalf("head = %s, want %s", vm.lastAcceptedID, gen.id)
}
recovered, err := readGenesis(db)
if err != nil {
t.Fatalf("the genesis record was not written on recovery: %v", err)
}
if hex.EncodeToString(recovered) != genesisImageHex {
t.Fatalf("recorded genesis %x is not the chain's height-0 block", recovered)
}
}
// TestGenesisRefusesRecordlessChainAboveHeight0 covers the case with no honest
// recovery: past height 0 the head block is not the genesis, and there is nothing
// on disk that says which chain this is.
func TestGenesisRefusesRecordlessChainAboveHeight0(t *testing.T) {
db := memdb.New()
vm := &VM{}
head := newBlock(vm, vm.canonicalGenesis(nil).id, 1, time.Unix(1, 0).UTC(), [Size]byte{}, nil)
batch := db.NewBatch()
for _, w := range []func() error{
func() error { return writeLastAccepted(batch, head.id) },
func() error { return writeHeight(batch, 1) },
func() error { return writeRoot(batch, head.execRoot) },
func() error { return writeHeadBlock(batch, head.bytes) },
} {
if err := w(); err != nil {
t.Fatal(err)
}
}
if err := batch.Write(); err != nil {
t.Fatal(err)
}
err := (&VM{}).Initialize(context.Background(), block.Init{
DB: db,
Log: log.NewNoOpLogger(),
ToEngine: make(chan block.Message, 16),
Config: authConfig(t),
})
if err == nil || !strings.Contains(err.Error(), "height 1") {
t.Fatalf("got %v, want a refusal naming the height it cannot recover from", err)
}
}
// TestGenesisRefusesUnreadableRecord covers a corrupt genesis record. Anything the
// VM cannot read as a height-0 block leaves it unable to say which chain it is on,
// which is a refusal, not a warning.
func TestGenesisRefusesUnreadableRecord(t *testing.T) {
db := memdb.New()
if err := writeGenesis(db, []byte("not a block")); err != nil {
t.Fatal(err)
}
err := (&VM{}).Initialize(context.Background(), block.Init{
DB: db,
Log: log.NewNoOpLogger(),
ToEngine: make(chan block.Message, 16),
Config: authConfig(t),
})
if err == nil || !strings.Contains(err.Error(), "unreadable") {
t.Fatalf("corrupt genesis record: got %v, want a refusal naming it unreadable", err)
}
}
+18 -3
View File
@@ -88,6 +88,7 @@ const (
metaHeight = prefixMeta + "height"
metaRoot = prefixMeta + "root"
metaHeadBlock = prefixMeta + "headBlock" // canonical bytes of the accepted head block
metaGenesis = prefixMeta + "genesis" // canonical bytes of the height-0 block
)
// seenKey builds seen:<txID:32>. A tx id committed here has been applied by an
@@ -752,12 +753,13 @@ func writeHeight(db database.KeyValueWriter, height uint64) error {
return db.Put([]byte(metaHeight), b[:])
}
// readRoot returns the accepted head's execution root. It is written with the head
// pointer in one batch, so a head with no root behind it is corruption — and
// substituting a zero root would silently rebase the chain, which is the same class
// of defect as reconstructing genesis from the binary. It errors instead.
func readRoot(db database.KeyValueReader) ([32]byte, error) {
var r [32]byte
v, err := db.Get([]byte(metaRoot))
if err == database.ErrNotFound {
return r, nil // genesis parent root is zero
}
if err != nil {
return r, err
}
@@ -789,6 +791,19 @@ func writeHeadBlock(db database.KeyValueWriter, blockBytes []byte) error {
return db.Put([]byte(metaHeadBlock), blockBytes)
}
// readGenesis returns the canonical bytes of the chain's height-0 block, or
// database.ErrNotFound when this database holds no chain yet. It is the node's
// record of WHICH chain it is on: written once, in the same batch that commits
// the height-0 head, and never rewritten. See genesis.go.
func readGenesis(db database.KeyValueReader) ([]byte, error) {
return db.Get([]byte(metaGenesis))
}
// writeGenesis records the chain's height-0 block image at birth.
func writeGenesis(db database.KeyValueWriter, blockBytes []byte) error {
return db.Put([]byte(metaGenesis), blockBytes)
}
// =========================================================================
// State root — REUSES the audited xvm execution_root construction:
// keccak256 tagged binary Merkle (RFC-6962, github.com/luxfi/crypto/merkle) for
+60 -52
View File
@@ -58,7 +58,7 @@ type VM struct {
// (single-chain unit tests with no seam) — the seam then refuses (no mint).
runtime *runtime.Runtime
// cChainID is the C-Chain id resolved from the runtime: the partition a C->D
// intent object is read under (import) and a D->C settlement object is written
// order object is read under (import) and a D->C settlement object is written
// under (export). ids.Empty when no runtime / no C-Chain — the seam stays closed.
cChainID ids.ID
@@ -119,7 +119,7 @@ type VM struct {
// autoDriveSeam enables the PROPOSER-SIDE DRIVE (drive.go): when set, BuildBlock
// autonomously enumerates the committed cross-chain atomic state and emits the
// TxImport / TxExport txs that import C->D intents and export D->C settlements —
// TxImport / TxExport txs that import C->D orders and export D->C settlements —
// the keeper-less native settlement seam. It is set true at Initialize exactly when
// the cross-chain seam is wired (a runtime with shared memory + a resolved C-Chain
// id); a single-chain runtime (no seam) leaves it false, and the seam unit tests
@@ -183,20 +183,46 @@ func (vm *VM) Initialize(ctx context.Context, init block.Init) error {
vm.processingBlocks = map[ids.ID]*Block{}
vm.heightIndex = map[uint64]ids.ID{}
// Load the head, or bootstrap genesis.
if _, err := readLastAccepted(vm.db); err == database.ErrNotFound {
if err := vm.bootstrapGenesis(); err != nil {
// Resolve height 0 before anything else touches the chain. The genesis comes
// from the chain-creation document the node supplies (init.Genesis) and must
// match this node's own record of it; a disagreement refuses to start rather
// than fork silently. See genesis.go.
stored, err := readGenesis(vm.db)
if err != nil && err != database.ErrNotFound {
return fmt.Errorf("dchain: read stored genesis: %w", err)
}
_, err = readLastAccepted(vm.db)
if err != nil && err != database.ErrNotFound {
return fmt.Errorf("dchain: read last accepted: %w", err)
}
born := err == nil // this database already holds a chain
if born && len(stored) == 0 {
// Born under a build that kept no genesis record. Recoverable at height 0
// from the chain's own head block, and nowhere else.
if stored, err = vm.recoverGenesis(); err != nil {
return err
}
} else if err != nil {
return fmt.Errorf("dchain: read last accepted: %w", err)
} else {
}
gen, err := vm.genesis(init.Genesis, stored)
if err != nil {
return err
}
if born {
if err := vm.loadHead(); err != nil {
return err
}
if vm.lastAcceptedHeight == 0 && vm.lastAcceptedID != gen.id {
return fmt.Errorf("dchain: refusing to start: the head at height 0 is %s but this chain's genesis is %s",
vm.lastAcceptedID, describeGenesis(gen))
}
if err := vm.rebuildAllBooks(); err != nil {
return err
}
} else if err := vm.bootstrapGenesis(gen); err != nil {
return err
}
vm.preferred = vm.lastAcceptedID
@@ -225,7 +251,11 @@ func (vm *VM) Initialize(ctx context.Context, init block.Init) error {
}
vm.initialized = true
// genesis is logged on every start so which chain a node is on is answerable
// from its logs alone. Two nodes that cannot agree here agree about nothing.
vm.log.Info("dchain VM initialized",
"genesis", gen.id,
"createdFrom", fmt.Sprintf("%x", genesisOrigin(init.Genesis)),
"height", vm.lastAcceptedHeight,
"lastAccepted", vm.lastAcceptedID,
"markets", len(vm.books),
@@ -234,31 +264,23 @@ func (vm *VM) Initialize(ctx context.Context, init block.Init) error {
return nil
}
// genesisBlock deterministically reconstructs the genesis block (height 0, empty,
// zero parent root). It is a pure function of the VM binding — every validator and
// every restart computes the identical id — so it is the single source of the
// genesis block for both bootstrapGenesis (fresh DB) and loadHead's legacy-DB
// recovery (a DB written before head blocks were persisted).
func (vm *VM) genesisBlock() (*Block, [Size]byte) {
var parentRoot [Size]byte // zero
var emptyLedger [Size]byte // genesis has no custody ledger yet
root, _, _, _ := ExecutionRoot(parentRoot, nil, nil, nil, emptyLedger, 0)
return newBlock(vm, genesisParent, 0, time.Unix(0, 0).UTC(), root, nil), root
}
// bootstrapGenesis writes the genesis block (height 0, empty, zero parent root)
// and sets it as the accepted head. Must be called under vm.mu on a fresh DB.
func (vm *VM) bootstrapGenesis() error {
gen, root := vm.genesisBlock()
// bootstrapGenesis commits gen as the chain's height-0 head and, in the same
// batch, records it as the chain's genesis — the node's permanent answer to "which
// chain am I on". Every later start reads that record back and asserts against it,
// so this is the one and only moment the answer is decided. Must be called under
// vm.mu on a database that holds no chain.
func (vm *VM) bootstrapGenesis(gen *Block) error {
batch := vm.db.NewBatch()
if err := writeGenesis(batch, gen.bytes); err != nil {
return err
}
if err := writeLastAccepted(batch, gen.id); err != nil {
return err
}
if err := writeHeight(batch, 0); err != nil {
return err
}
if err := writeRoot(batch, root); err != nil {
if err := writeRoot(batch, gen.execRoot); err != nil {
return err
}
if err := writeHeadBlock(batch, gen.bytes); err != nil {
@@ -271,7 +293,7 @@ func (vm *VM) bootstrapGenesis() error {
gen.status = statusAccepted
vm.lastAcceptedID = gen.id
vm.lastAcceptedHeight = 0
vm.lastRoot = root
vm.lastRoot = gen.execRoot
vm.acceptedBlocks[gen.id] = gen
vm.heightIndex[0] = gen.id
return nil
@@ -310,30 +332,16 @@ func (vm *VM) loadHead() error {
return nil
}
// loadHeadBlock reconstructs the accepted head block named by (id, height). It
// reads the persisted head-block bytes and parses them, asserting the reconstructed
// id matches the head pointer. For a DB written before head blocks were persisted
// (legacy), the bytes are absent: a height-0 head is recovered from the
// deterministic genesis block (verified to match the stored id); a height>0 head
// with no persisted block is unrecoverable corruption and errors loudly rather
// than silently resetting the chain. Must be called under vm.mu.
// loadHeadBlock reconstructs the accepted head block named by (id, height) from
// its persisted bytes, asserting the reconstructed id and height match the head
// pointer. A head pointer with no block behind it is unrecoverable corruption and
// errors loudly rather than silently resetting the chain — reconstructing height 0
// from the binary instead is exactly the silent fork this package refuses to
// commit. Must be called under vm.mu.
func (vm *VM) loadHeadBlock(id ids.ID, height uint64) (*Block, error) {
raw, err := readHeadBlock(vm.db)
if err == database.ErrNotFound {
// Legacy DB (head block never persisted). Only genesis is reconstructible
// without the bytes.
if height != 0 {
return nil, fmt.Errorf("dchain: head block missing at height %d (corrupt or pre-persistence state); cannot recover", height)
}
gen, _ := vm.genesisBlock()
if gen.id != id {
return nil, fmt.Errorf("dchain: genesis id mismatch on legacy DB: stored %s computed %s", id, gen.id)
}
gen.status = statusAccepted
return gen, nil
}
if err != nil {
return nil, fmt.Errorf("dchain: read head block: %w", err)
return nil, fmt.Errorf("dchain: read head block at height %d: %w", height, err)
}
head, err := parseBlock(vm, raw)
@@ -452,11 +460,11 @@ func (vm *VM) BuildBlock(ctx context.Context) (block.Block, error) {
// PROPOSER-SIDE DRIVE (drive.go): before draining the mempool, enumerate the committed
// cross-chain atomic state and autonomously generate the seam txs — TxImport for every
// un-imported C->D intent, TxExport for every open intent whose owner now holds realized
// un-imported C->D order, TxExport for every open order whose owner now holds realized
// proceeds. Both are pure, deterministic functions of committed state (shared memory +
// the seamintent: escrow + the ledger), so every validator re-derives the identical
// effects in execute and a divergent proposer is rejected on the tx root. This is the
// keeper-less native settlement seam — the VM imports C->D intents and exports D->C
// keeper-less native settlement seam — the VM imports C->D orders and exports D->C
// settlements during normal block production. A no-op when the seam is unwired.
imports, err := vm.driveSeamImports(maxSeamDrivePerBlock)
if err != nil {
@@ -477,7 +485,7 @@ func (vm *VM) BuildBlock(ctx context.Context) (block.Block, error) {
// Assemble [imports || exports || mempool]. Imports come FIRST so a same-block taker
// order draws from the just-imported funds; exports come before the mempool so they
// settle committed proceeds before any new mempool order can touch the same account.
// The seam txs and the mempool txs are disjoint by construction (an intent is imported,
// The seam txs and the mempool txs are disjoint by construction (an order is imported,
// then traded, then exported across distinct blocks).
txs := make([]*Tx, 0, len(imports)+len(orders)+len(exports)+len(mtxs))
txs = append(txs, imports...)
@@ -672,7 +680,7 @@ func (vm *VM) WaitForEvent(ctx context.Context) (block.Message, error) {
ticker := time.NewTicker(5 * time.Millisecond)
defer ticker.Stop()
// The seam drive (drive.go) can have work even with an empty mempool — a pending
// C->D intent to import, or an open intent with realized proceeds to export. Those
// C->D order to import, or an open order with realized proceeds to export. Those
// appear at the source chain's block cadence (seconds), so they are polled on a
// slower, separate tick to keep the hot mempool path cheap.
seamTicker := time.NewTicker(time.Second)
-38
View File
@@ -509,44 +509,6 @@ func TestVMRestartGetBlockHead(t *testing.T) {
}
}
// TestVMRestartLegacyGenesisDB proves loadHead recovers a DB written before head
// blocks were persisted (no metaHeadBlock) when the head is genesis: it
// reconstructs the deterministic genesis block and serves it over GetBlock. A
// non-genesis legacy head (no recoverable bytes) must error loudly instead of
// silently resetting the chain.
func TestVMRestartLegacyGenesisDB(t *testing.T) {
ctx := context.Background()
db := memdb.New()
// Simulate a legacy genesis DB: write the head META (lastAccepted/height/root)
// for genesis but NOT the head-block bytes, exactly as the pre-persistence code
// left a fresh chain.
gen, root := (&VM{}).genesisBlock()
batch := db.NewBatch()
if err := writeLastAccepted(batch, gen.id); err != nil {
t.Fatal(err)
}
if err := writeHeight(batch, 0); err != nil {
t.Fatal(err)
}
if err := writeRoot(batch, root); err != nil {
t.Fatal(err)
}
if err := batch.Write(); err != nil {
t.Fatal(err)
}
// Initialize over that legacy DB: loadHead's genesis recovery must rebuild the
// head block so GetBlock(genesis) works.
vm2, _ := newTestVM(t, db)
if vm2.lastAcceptedID != gen.id {
t.Fatalf("legacy-DB head = %s, want genesis %s", vm2.lastAcceptedID, gen.id)
}
if _, err := vm2.GetBlock(ctx, gen.id); err != nil {
t.Fatalf("GetBlock(genesis) on legacy DB: %v", err)
}
}
// TestVMAcceptViaGetBlock reproduces the production consensus stall: the luxd
// plugin transport (vm/rpc) resolves Block.Accept by ID via VM.GetBlock — unlike
// Verify, which carries the full block bytes — and then calls Accept on whatever