quantumvm: a block carries no signature of its own

ParseBlock demanded a field the wire has no room for. The block format is
timestamp, height, parentID, tx lengths, tx blob; the stamp lived only on the
object the builder made and was gone the moment the block was serialized, which
is every time it leaves the process. So the gate refused every block that ever
crossed the wire, genesis included, and Q-Chain accepted nothing on any network
for as long as it ran -- certAccepted=0, appliedHead=0, everywhere.

The two doors into the format disagreed, which is what made it a loop rather
than a stall. A responder answers a catch-up request through vm.parseBlock, which
has no gate and succeeds; the requester admits the reply through VM.ParseBlock,
which had the gate and failed. Same bytes, two verdicts: every node served the
block every node refused.

Putting the stamp on the wire would be worse than leaving it off. The block id is
sha256 of its own bytes, so a stamp in those bytes makes the id depend on who
signed, and two honest nodes compute two ids for one block. seedGenesis already
refuses wall-clock time for exactly this reason. The stamp could not have been
load-bearing anyway: it was made with a key generated fresh for each block, and a
signature that ships its own throwaway public key states nothing an attacker
cannot also state. Its thirty-second validity window is also the opposite of what
catch-up needs, which is to serve blocks from hours ago.

A block-level signature belongs to consensus, where it already is: a cert is a
quorum's statement about a block, checkable by anyone holding the validator set,
and it is not a field on the block. Quasar still signs there. Transaction-level
quantum signatures are untouched -- those are serialized, and they are verified.

The tests stayed green throughout because none of them sent a block through
Bytes() and back. Three do now: parse what we serialize, parse GENESIS, and the
two parse paths reach the same verdict on identical bytes. Restoring the gate
fails all three.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-20 11:44:10 -07:00
co-authored by Hanzo Dev
parent b9f95308a8
commit d87600fbef
4 changed files with 125 additions and 65 deletions
-11
View File
@@ -26,7 +26,6 @@ type Block struct {
height uint64
parentID ids.ID
transactions []Transaction
quantumSignature *quantum.QuantumSignature
vm *VM
bytes []byte
}
@@ -107,16 +106,6 @@ func (b *Block) Verify(ctx context.Context) error {
}
}
// Verify quantum signature if enabled
if b.vm.Config.QuantumStampEnabled {
if b.quantumSignature == nil {
return errInvalidQuantumStamp
}
if err := b.vm.quantumSigner.Verify(b.Bytes(), b.quantumSignature); err != nil {
return errBlockVerificationFailed
}
}
// Verify transactions in parallel
if len(b.transactions) > 0 {
msgs := make([][]byte, len(b.transactions))
+106
View File
@@ -0,0 +1,106 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package quantumvm
import (
"context"
"testing"
"time"
"github.com/luxfi/chains/quantumvm/config"
"github.com/luxfi/ids"
)
// parseVM is a VM with the stock config — the one production runs — and enough
// wiring to serialize and parse. Building the whole VM would test the
// constructor, not the property.
func parseVM(t *testing.T) *VM {
t.Helper()
vm, _ := seedVM(t)
vm.Config = config.DefaultConfig()
return vm
}
// TestParseAcceptsWhatWeSerialize is the property this chain could not move
// without, and the one nothing asserted.
//
// ParseBlock is the sole entry point for catch-up, gossip and Put. If it refuses
// the bytes this VM itself produces, then every node serves a block every node
// refuses, and the chain accepts nothing for as long as it runs — which is what
// happened: certAccepted=0, appliedHead=0 on every validator, on every network,
// forever, while the package's own tests stayed green because none of them ever
// sent a block through Bytes() and back.
func TestParseAcceptsWhatWeSerialize(t *testing.T) {
vm := parseVM(t)
blk := &Block{
timestamp: time.Unix(1000, 0).UTC(),
height: 7,
parentID: ids.GenerateTestID(),
vm: vm,
}
blk.id = blk.computeID()
got, err := vm.ParseBlock(context.Background(), blk.Bytes())
if err != nil {
t.Fatalf("ParseBlock refused bytes this VM serialized: %v\n"+
"a block every node serves and every node refuses is a chain that cannot move", err)
}
if got.ID() != blk.id {
t.Fatalf("round trip changed the block id: %s -> %s", blk.id, got.ID())
}
if got.Height() != 7 {
t.Fatalf("round trip changed height: %d", got.Height())
}
}
// TestGenesisParses: genesis is the block every node must parse, and it is
// signed by nobody — seedGenesis deliberately writes no signature, because a
// per-node signature would give each node a different genesis id. Any gate that
// demands one therefore refuses the one block the whole chain is anchored to.
func TestGenesisParses(t *testing.T) {
vm := parseVM(t)
if err := vm.seedGenesis(); err != nil {
t.Fatalf("seedGenesis: %v", err)
}
gid := vm.getLastAcceptedID()
raw, err := vm.state.Get(gid[:])
if err != nil {
t.Fatalf("read genesis bytes: %v", err)
}
got, err := vm.ParseBlock(context.Background(), raw)
if err != nil {
t.Fatalf("ParseBlock refused GENESIS: %v", err)
}
if got.Height() != 0 {
t.Fatalf("genesis height = %d, want 0", got.Height())
}
}
// TestBothParsePathsAgree pins the asymmetry itself.
//
// The responder answers a catch-up request through vm.parseBlock; the requester
// admits the reply through VM.ParseBlock. They are two doors into one format, so
// a check on only one of them means the fleet serves what it will not accept.
// Whatever either door does to a block, both must reach the same verdict.
func TestBothParsePathsAgree(t *testing.T) {
vm := parseVM(t)
blk := &Block{
timestamp: time.Unix(2000, 0).UTC(),
height: 3,
parentID: ids.GenerateTestID(),
vm: vm,
}
blk.id = blk.computeID()
raw := blk.Bytes()
_, servErr := vm.parseBlock(raw) // responder side
_, recvErr := vm.ParseBlock(context.Background(), raw) // requester side
if (servErr == nil) != (recvErr == nil) {
t.Fatalf("the two parse paths disagree on identical bytes: serve=%v receive=%v\n"+
"every node would serve this block and every node would refuse it", servErr, recvErr)
}
}
+1 -1
View File
@@ -64,7 +64,7 @@ func (s *Service) GetBlock(r *http.Request, args *GetBlockArgs, reply *GetBlockR
reply.Height = qBlock.height
reply.Timestamp = qBlock.timestamp.Unix()
reply.TxCount = len(qBlock.transactions)
reply.QuantumSig = qBlock.quantumSignature != nil
reply.QuantumSig = false // blocks carry no stamp; see signBlockWithQuasar
return nil
}
+18 -53
View File
@@ -282,13 +282,7 @@ func (vm *VM) BuildBlock(ctx context.Context) (chain.Block, error) {
vm: vm,
}
block.id = block.computeID()
// Sign block with quantum signature if enabled
if vm.Config.QuantumStampEnabled {
if err := vm.signBlockWithQuantum(block); err != nil {
return nil, fmt.Errorf("failed to sign block with quantum stamp: %w", err)
}
}
vm.signBlockWithQuasar(block)
vm.log.Debug("built block",
"blockID", block.ID(),
@@ -309,13 +303,6 @@ func (vm *VM) ParseBlock(ctx context.Context, blockBytes []byte) (chain.Block, e
return nil, fmt.Errorf("failed to parse block: %w", err)
}
// Verify quantum signature if enabled
if vm.Config.QuantumStampEnabled {
if err := vm.verifyBlockQuantumSignature(block); err != nil {
return nil, fmt.Errorf("quantum signature verification failed: %w", err)
}
}
return block, nil
}
@@ -430,47 +417,25 @@ func (vm *VM) processTransactionsParallel(txs []Transaction) ([]Transaction, err
return validTxs, nil
}
// signBlockWithQuantum signs a block with quantum signature using Quasar hybrid consensus
func (vm *VM) signBlockWithQuantum(block *Block) error {
ctx := context.Background()
blockData := block.Bytes()
// Use Quasar bridge for dual BLS+Corona threshold signing
if vm.quasarBridge != nil {
_, err := vm.quasarBridge.SignBlock(ctx, block.ID(), blockData, block.Height())
if err != nil {
vm.log.Warn("Quasar signing failed, falling back to ML-DSA", "error", err)
} else {
vm.log.Debug("Block signed with Quasar BLS threshold",
"blockID", block.ID(),
"height", block.Height(),
)
}
// Quasar signs the block for the CONSENSUS layer, which is where a block-level
// signature belongs: a cert is a quorum's statement about a block, verifiable by
// anyone holding the validator set. It is deliberately not a field on the block.
//
// A per-block stamp cannot be one. The block id is sha256 of its own bytes, so a
// stamp on the wire makes the id depend on WHO signed and two honest nodes compute
// two ids for one block -- a fork by construction, the same hazard seedGenesis
// avoids by refusing to put wall-clock time in a block. Kept off the wire instead,
// it exists only on the object the builder made and is gone the moment the block
// is serialized, which is every time it leaves this process.
func (vm *VM) signBlockWithQuasar(block *Block) {
if vm.quasarBridge == nil {
return
}
// Also sign with ML-DSA for quantum resistance (standalone signature)
key, err := vm.quantumSigner.GenerateCoronaKey()
if err != nil {
return fmt.Errorf("failed to generate corona key: %w", err)
if _, err := vm.quasarBridge.SignBlock(context.Background(), block.ID(), block.Bytes(), block.Height()); err != nil {
vm.log.Warn("quasar block signing failed", "blockID", block.ID(), "error", err)
return
}
sig, err := vm.quantumSigner.Sign(blockData, key)
if err != nil {
return fmt.Errorf("failed to sign block with ML-DSA: %w", err)
}
block.quantumSignature = sig
return nil
}
// verifyBlockQuantumSignature verifies a block's quantum signature
func (vm *VM) verifyBlockQuantumSignature(block *Block) error {
if block.quantumSignature == nil {
return errInvalidQuantumStamp
}
blockData := block.Bytes()
return vm.quantumSigner.Verify(blockData, block.quantumSignature)
vm.log.Debug("block signed with quasar BLS threshold", "blockID", block.ID(), "height", block.Height())
}
// parseGenesis parses genesis data