mirror of
https://github.com/luxfi/dex.git
synced 2026-09-24 00:26:31 +00:00
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:
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user