mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
Red confirmed by execution that the receive path was not the build path, and three of its findings were chain-integrity defects rather than gaps. Parse discarded the transaction set, so a block arriving over the network had none -- and the ML-DSA check was gated on having some. The check therefore ran only on blocks this node built itself. The same bytes verified in memory and passed off the wire, and a block carrying arbitrary transaction bytes was accepted and committed. Parse now reconstructs the full set, signatures included, and refuses a declared count its bytes cannot hold or lengths that do not partition the blob. Accept never asked whether the parent was the tip -- only that the height followed it. So an old sibling rewound a five-block chain and the height index went on serving the orphaned branch to bootstrapping peers as canonical. The tip reader answered the zero id for any failure, and the genesis seeder read that as a fresh chain: one transient read at boot committed genesis over a live tip, durably, and Initialize returned nil. Not-found is now distinguished from unreadable, and only the first means new. The harness that was supposed to defend the signer-identity fix could not see it: it gave each node its own ChainID, which production never does, so reverting the fix left the suite green. It now holds the chain constant and varies the node, which is the shape the defect lives in. Also: the proposer no longer builds a block its own Verify refuses when the clock trails the tip. 96.4% coverage.
174 lines
6.1 KiB
Go
174 lines
6.1 KiB
Go
// 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, a log and the chain it
|
|
// serves. Building the whole VM would test the constructor, not the property
|
|
// under test.
|
|
func seedVM(t *testing.T) (*VM, *memdb.Database) {
|
|
t.Helper()
|
|
return seedVMOn(t, testNetwork, testChain)
|
|
}
|
|
|
|
func seedVMOn(t *testing.T, networkID uint32, chainID ids.ID) (*VM, *memdb.Database) {
|
|
t.Helper()
|
|
db := memdb.New()
|
|
vm := &VM{log: log.NewNoOpLogger(), db: db, blockchainID: chainID, NetworkID: networkID}
|
|
vm.state = versiondb.New(db)
|
|
vm.txPool = NewTransactionPool(8, vm.log)
|
|
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 := tipOf(t, vm); 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 := tipOf(t, vm); 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 := heightOf(t, vm); got != 0 {
|
|
t.Fatalf("genesis height = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
// genesisID is the one block every node of testChain 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 = "zAaMUekpcRPgjqxqT8kSm9P5xRKJuz2k5v3zN2WfnmwdBdojU"
|
|
|
|
// 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 tipOf(t, a) != tipOf(t, b) {
|
|
t.Fatalf("nodes disagree on genesis: %s vs %s — this would fork the chain",
|
|
tipOf(t, a), tipOf(t, b))
|
|
}
|
|
if got := tipOf(t, a).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)
|
|
}
|
|
}
|
|
|
|
// TestGenesisIsPerChain is the other half of agreement: nodes of ONE chain
|
|
// agree, and nodes of two chains do not.
|
|
//
|
|
// The block carried nothing chain-specific, so genesis was a global constant and
|
|
// every Q-Chain in existence shared it. Two chains agreeing on their first block
|
|
// is two chains whose blocks are interchangeable from the first height up.
|
|
func TestGenesisIsPerChain(t *testing.T) {
|
|
mine, _ := seedVMOn(t, testNetwork, testChain)
|
|
theirs, _ := seedVMOn(t, testNetwork, otherChain)
|
|
elsewhere, _ := seedVMOn(t, otherNetworkID, testChain)
|
|
|
|
for _, vm := range []*VM{mine, theirs, elsewhere} {
|
|
if err := vm.seedGenesis(); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
}
|
|
|
|
if tipOf(t, mine) == tipOf(t, theirs) {
|
|
t.Fatal("two chains share a genesis block, so a block built on one is a block on the other")
|
|
}
|
|
if tipOf(t, mine) == tipOf(t, elsewhere) {
|
|
t.Fatal("two networks share a genesis block, so testnet blocks are mainnet blocks")
|
|
}
|
|
}
|
|
|
|
// 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 := tipOf(t, vm)
|
|
|
|
// Advance the chain, as a running node would.
|
|
genesis, err := vm.blockAt(first)
|
|
if err != nil {
|
|
t.Fatalf("read genesis: %v", err)
|
|
}
|
|
blk := blockOn(vm, genesis, stampedTx(1, "op"))
|
|
blk.timestamp = time.Unix(100, 0).UTC()
|
|
blk.bytes = nil
|
|
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 := tipOf(t, vm); got != blk.id {
|
|
t.Fatalf("seeding rewound a live chain: tip %s became %s", blk.id, got)
|
|
}
|
|
if got := heightOf(t, vm); got != 1 {
|
|
t.Fatalf("seeding rewound height to %d, want 1", 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 := tipOf(t, vm)
|
|
|
|
// Restart: a new version layer over the SAME underlying store.
|
|
restarted := &VM{log: log.NewNoOpLogger(), db: db, blockchainID: testChain, NetworkID: testNetwork}
|
|
restarted.state = versiondb.New(db)
|
|
restarted.txPool = NewTransactionPool(8, restarted.log)
|
|
|
|
if got := tipOf(t, restarted); got != want {
|
|
t.Fatalf("genesis did not survive restart: %s != %s (uncommitted)", got, want)
|
|
}
|
|
}
|