quantumvm: write the genesis block so Q-Chain can start

Bootstrap opens by asking each beacon which block it holds, and an answer
naming no block is not counted as a responder -- it neither backs a tip nor
raises the response floor (tally, node chains/bootstrap_trust.go). The VM
derived its state lazily from the signer config and never wrote a block, so
last-accepted stayed empty, every node answered with nothing, every reply was
dropped, and all five waited on each other for as long as the chain ran:

  waiting for the beacon quorum: 0 of 5 beacons responded, need 3

State you can execute against is not the same as a block you can name.

Initialize now writes height 0 when the chain has none. The block is a
constant -- fixed timestamp, empty parent, no transactions -- so every node
computes the same id alone; wall-clock time would give each node its own
genesis and make this a fork. It commits, or the tip lives in the version
layer only and the next start names nothing again.

Tests pin the id rather than compare two local VMs: the wire carries Unix
seconds, so two VMs seeded in the same second agree even with a wall-clock
timestamp, and that assertion would pass for a build that forks in production.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-17 15:08:21 -07:00
co-authored by Hanzo Dev
parent 9427d78408
commit b0763957cc
2 changed files with 192 additions and 0 deletions
+136
View File
@@ -0,0 +1,136 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package quantumvm
import (
"context"
"testing"
"time"
"github.com/luxfi/database/memdb"
"github.com/luxfi/database/versiondb"
"github.com/luxfi/ids"
"github.com/luxfi/log"
)
// seedVM is the smallest VM seedGenesis needs: a store and a log. Building the
// whole VM would test the constructor, not the property under test.
func seedVM(t *testing.T) (*VM, *memdb.Database) {
t.Helper()
db := memdb.New()
vm := &VM{log: log.NewNoOpLogger(), db: db}
vm.versiondb = versiondb.New(db)
vm.state = vm.versiondb
return vm, db
}
// TestSeedGenesisNamesATip is the property the chain could not start without.
//
// Bootstrap asks each beacon which block it holds, and an answer naming no block
// is not counted as a responder — so if every node names nothing, the response
// floor is unreachable and the chain waits on itself for as long as it runs. The
// assertion is therefore not "a block exists" but "the VM can NAME one".
func TestSeedGenesisNamesATip(t *testing.T) {
vm, _ := seedVM(t)
if got := vm.getLastAcceptedID(); got != ids.Empty {
t.Fatalf("precondition: a fresh VM should name no block, got %s", got)
}
if err := vm.seedGenesis(); err != nil {
t.Fatalf("seedGenesis: %v", err)
}
if got := vm.getLastAcceptedID(); got == ids.Empty {
t.Fatal("VM still names no block after seeding — every beacon reply would " +
"be dropped and the chain could never reach its bootstrap quorum")
}
if got := vm.getHeight(); got != 0 {
t.Fatalf("genesis height = %d, want 0", got)
}
}
// genesisID is the one block every Q-Chain node must name at height 0.
//
// It is pinned rather than recomputed because the property is not "two VMs in
// this test agree" — two VMs seeded in the same second agree even with a
// wall-clock timestamp, since the wire carries Unix SECONDS, so that assertion
// passes for a build that forks in production where nodes start minutes apart.
// A constant is the only assertion that fails for every input a node could
// disagree on. If this value must change, the chain is being re-genesised and
// every node has to move together.
const genesisID = "23YdHYkgL766qjZojgBBey3RuvMfuZgtJkvgJ3yEFFVK5UcKEQ"
// TestSeedGenesisAgreesAcrossNodes is the safety half. Each node seeds alone,
// without talking to any other, so if the block were a function of anything
// local — wall-clock time above all — the nodes would name different genesis
// blocks and the repair would be a fork rather than a fix.
func TestSeedGenesisAgreesAcrossNodes(t *testing.T) {
a, _ := seedVM(t)
b, _ := seedVM(t)
if err := a.seedGenesis(); err != nil {
t.Fatalf("node a: %v", err)
}
if err := b.seedGenesis(); err != nil {
t.Fatalf("node b: %v", err)
}
if a.getLastAcceptedID() != b.getLastAcceptedID() {
t.Fatalf("nodes disagree on genesis: %s vs %s — this would fork the chain",
a.getLastAcceptedID(), b.getLastAcceptedID())
}
if got := a.getLastAcceptedID().String(); got != genesisID {
t.Fatalf("genesis id = %s, want %s — the block is not a constant, so nodes "+
"that start at different times will name different genesis blocks", got, genesisID)
}
}
// TestSeedGenesisLeavesAnExistingChainAlone: Initialize runs on every start, so
// seeding must be a no-op once a chain has a tip. Overwriting it would rewind a
// running chain to height 0 on restart.
func TestSeedGenesisLeavesAnExistingChainAlone(t *testing.T) {
vm, _ := seedVM(t)
if err := vm.seedGenesis(); err != nil {
t.Fatalf("first seed: %v", err)
}
first := vm.getLastAcceptedID()
// Advance the chain, as a running node would.
blk := &Block{timestamp: time.Unix(100, 0).UTC(), height: 7, parentID: first, vm: vm}
blk.id = blk.computeID()
if err := blk.Accept(context.Background()); err != nil {
t.Fatalf("accept: %v", err)
}
if err := vm.seedGenesis(); err != nil {
t.Fatalf("second seed: %v", err)
}
if got := vm.getLastAcceptedID(); got != blk.id {
t.Fatalf("seeding rewound a live chain: tip %s became %s", blk.id, got)
}
if got := vm.getHeight(); got != 7 {
t.Fatalf("seeding rewound height to %d, want 7", got)
}
}
// TestSeedGenesisSurvivesRestart covers the commit. A tip held only in the
// version layer is gone at the next start, and the node is back to naming no
// block — the same deadlock, one restart later.
func TestSeedGenesisSurvivesRestart(t *testing.T) {
vm, db := seedVM(t)
if err := vm.seedGenesis(); err != nil {
t.Fatalf("seed: %v", err)
}
want := vm.getLastAcceptedID()
// Restart: a new version layer over the SAME underlying store.
restarted := &VM{log: log.NewNoOpLogger(), db: db}
restarted.versiondb = versiondb.New(db)
restarted.state = restarted.versiondb
if got := restarted.getLastAcceptedID(); got != want {
t.Fatalf("genesis did not survive restart: %s != %s (uncommitted)", got, want)
}
}
+56
View File
@@ -10,6 +10,7 @@ import (
"fmt"
"net/http"
"sync"
"time"
"github.com/gorilla/rpc/v2"
"github.com/luxfi/chains/quantumvm/config"
@@ -210,6 +211,12 @@ func (vm *VM) Initialize(ctx context.Context, init luxvm.Init) error {
// Initialize state
vm.state = vm.versiondb
// A chain with no block cannot answer the frontier query bootstrap starts
// with, so write height 0 before anything asks.
if err := vm.seedGenesis(); err != nil {
return fmt.Errorf("failed to seed genesis: %w", err)
}
// Set up HTTP handlers
if err := vm.initializeHTTPHandlers(); err != nil {
return fmt.Errorf("failed to initialize HTTP handlers: %w", err)
@@ -474,6 +481,55 @@ func (vm *VM) parseGenesis(genesisBytes []byte) error {
return nil
}
// seedGenesis writes the height-0 block on a chain that has none, so the VM can
// name a tip the moment it starts.
//
// A VM whose last-accepted is empty answers the bootstrap frontier query with no
// block, and an answer naming no block is not a responder — it neither backs a
// tip nor counts toward the response floor (see tally in the node's
// chains/bootstrap_trust.go). Every node then reads every other node as silent,
// so the beacon floor is unreachable and each waits on the others for as long as
// the chain runs. State derived lazily from config is enough to EXECUTE against,
// but consensus starts by asking what block you hold, and "none" is not an
// answer it can build a quorum from.
//
// The block is a constant, so every node computes one id alone: fixed timestamp,
// height 0, empty parent, no transactions. Wall-clock time here would give each
// node a different id for the same block and make the repair a fork.
func (vm *VM) seedGenesis() error {
if vm.getLastAcceptedID() != ids.Empty {
return nil
}
block := &Block{
timestamp: time.Unix(0, 0).UTC(),
height: 0,
parentID: ids.Empty,
vm: vm,
}
block.id = block.computeID()
if err := vm.state.Put(block.id[:], block.Bytes()); err != nil {
return fmt.Errorf("store genesis block: %w", err)
}
if err := vm.state.Put([]byte("lastAccepted"), block.id[:]); err != nil {
return fmt.Errorf("set genesis last-accepted: %w", err)
}
heightBytes := make([]byte, 8)
binary.BigEndian.PutUint64(heightBytes, block.height)
if err := vm.state.Put([]byte("height"), heightBytes); err != nil {
return fmt.Errorf("set genesis height: %w", err)
}
// Commit, or the tip exists only in memory and the next start is back to
// naming no block.
if err := vm.versiondb.Commit(); err != nil {
return fmt.Errorf("commit genesis: %w", err)
}
vm.log.Info("genesis block written", "blockID", block.id, "height", block.height)
return nil
}
// parseBlock parses a block from its ZAP wire (see wire.go). The transaction set
// is not reconstructed — the concrete Transaction is an interface with no
// on-chain deserializer; the raw wire is retained for signature re-verification.