quantumvm: a block off the wire is held to what a block it built must satisfy

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.
This commit is contained in:
2026-08-28 10:56:43 -07:00
parent 98b2a7fce7
commit 1b02c78541
17 changed files with 2212 additions and 946 deletions
+66 -14
View File
@@ -19,12 +19,15 @@ var (
errInvalidParentID = errors.New("quantumvm: parent block not found")
errTimeBeforeParent = errors.New("quantumvm: block timestamp precedes its parent")
errTimeTooFarAhead = errors.New("quantumvm: block timestamp is beyond the skew allowance")
errEmptyBlock = errors.New("quantumvm: block carries no transactions")
errForeignChain = errors.New("quantumvm: block belongs to another chain")
errExecute = errors.New("quantumvm: block transaction could not be applied")
)
// MaxFutureSkew is how far ahead of the verifying node's clock a proposer may
// stamp a block. Chain time is what the quantum stamp window is measured
// against, so an uncapped timestamp lets one proposer expire every stamp in
// flight by jumping forward, or revive expired ones by rewinding.
// stamp a block. Peers' clocks differ, and this is the allowance for that; an
// uncapped timestamp is a proposer writing chain time, which is what decides
// whether the NEXT block may be stamped at all.
const MaxFutureSkew = 60 * time.Second
// Block represents a QVM block with quantum features
@@ -33,6 +36,8 @@ type Block struct {
timestamp time.Time
height uint64
parentID ids.ID
chainID ids.ID
networkID uint32
transactions []Transaction
vm *VM
bytes []byte
@@ -43,13 +48,11 @@ func (b *Block) ID() ids.ID {
return b.id
}
// Accept persists the block and advances the tip. Every write lands in one
// versiondb commit: the block, its height index and the last-accepted pointer
// move together or not at all. A failure anywhere rolls the whole set back, so
// a node never restarts holding a tip pointer to a block it did not store.
// Accept makes the block the tip: commitBlock admits it, applies it and
// persists it as one step.
//
// Nothing is dropped from the mempool until the commit succeeds. Evicting
// first would lose the transactions of a block that then failed to persist.
// Nothing is dropped from the mempool until that succeeds. Evicting first would
// lose the transactions of a block that then failed to persist.
func (b *Block) Accept(ctx context.Context) error {
b.vm.lock.Lock()
defer b.vm.lock.Unlock()
@@ -73,15 +76,48 @@ func (b *Block) Accept(ctx context.Context) error {
return nil
}
// Reject discards the block. Its transactions are still in the mempool —
// BuildBlock copies from the queue rather than draining it, and only Accept
// removes anything — so there is nothing to give back, and nothing is lost when
// the engine drops a block it never rejects either.
// Reject discards the block. Nothing ran — execution belongs to Accept — and
// its transactions are still in the mempool, because BuildBlock copies from the
// queue rather than draining it and only Accept removes anything. So there is
// nothing to undo and nothing to give back.
func (b *Block) Reject(context.Context) error {
b.vm.log.Debug("block rejected", "blockID", b.id, "height", b.height)
return nil
}
// apply runs the block's transactions.
//
// They ran at BUILD time. A builder that executed them applied the effects of a
// block the network may never accept, applied them a second time when it
// rebuilt, and applied nothing at all on every node that received the block
// instead of building it — which is every node but one, for every block. They
// run where the block becomes the tip, on every node, exactly once.
//
// A transaction that cannot be applied stops the block. A node that cannot
// apply an agreed block stops rather than committing a chain its state no
// longer matches.
func (b *Block) apply() error {
for _, tx := range b.transactions {
if err := tx.Execute(); err != nil {
return fmt.Errorf("%w %s in block %s: %w", errExecute, tx.ID(), b.id, err)
}
}
return nil
}
// onThisChain refuses a block that names another chain or another network.
//
// Nothing else in the wire is chain-specific, and genesis is a constant, so
// without this every Q-Chain in existence shares a genesis id and a block built
// on one is a well-formed block on all of them.
func (b *Block) onThisChain() error {
if b.chainID != b.vm.blockchainID || b.networkID != b.vm.NetworkID {
return fmt.Errorf("%w: chain %s network %d, this node serves chain %s network %d",
errForeignChain, b.chainID, b.networkID, b.vm.blockchainID, b.vm.NetworkID)
}
return nil
}
// Verify decides whether the block may be built on, without changing any state.
//
// It sits on its parent in both height and time. Checking only that the parent
@@ -92,11 +128,27 @@ func (b *Block) Verify(ctx context.Context) error {
b.vm.lock.RLock()
defer b.vm.lock.RUnlock()
if err := b.onThisChain(); err != nil {
return err
}
// Genesis is written by the VM, never proposed.
if b.height == 0 {
return errInvalidBlockHeight
}
// A proposed block carries work. Refusing an empty one is also what keeps
// the signature check below from being satisfiable by removing its
// subject: a parser that dropped the transaction set produces a block that
// verifies nothing, and a block that verifies nothing must not verify.
if len(b.transactions) == 0 {
return errEmptyBlock
}
if len(b.Bytes()) > MaxBlockSize {
return fmt.Errorf("%w: %d bytes over %d", errBlockTooLarge, len(b.Bytes()), MaxBlockSize)
}
// blockAt, not vm.GetBlock: GetBlock takes the same read lock this
// function already holds, and a writer arriving between the two
// acquisitions blocks the second one forever — Go's RWMutex queues a
@@ -119,7 +171,7 @@ func (b *Block) Verify(ctx context.Context) error {
errTimeTooFarAhead, b.timestamp.Unix(), MaxFutureSkew)
}
if len(b.transactions) > 0 && b.vm.Config.CoronaEnabled {
if b.vm.Config.QuantumStampEnabled {
msgs := make([][]byte, len(b.transactions))
sigs := make([]*quantum.QuantumSignature, len(b.transactions))
for i, tx := range b.transactions {
+322 -60
View File
@@ -7,10 +7,13 @@ import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/luxfi/chains/quantumvm/config"
"github.com/luxfi/database"
"github.com/luxfi/database/memdb"
"github.com/luxfi/database/versiondb"
"github.com/luxfi/ids"
"github.com/stretchr/testify/require"
@@ -26,14 +29,14 @@ import (
func TestAcceptedBlockSurvivesRestart(t *testing.T) {
vm, db := bootVM(t, quietConfig())
tip := advance(t, vm, 3)
require.Equal(t, uint64(3), vm.getHeight())
require.Equal(t, uint64(3), heightOf(t, vm))
restarted := bootVMOn(t, quietConfig(), db)
got, err := restarted.LastAccepted(context.Background())
require.NoError(t, err)
require.Equal(t, tip.id, got, "the tip did not survive the restart: the accepted blocks were never committed")
require.Equal(t, uint64(3), restarted.getHeight())
require.Equal(t, uint64(3), heightOf(t, restarted))
stored, err := restarted.GetBlock(context.Background(), tip.id)
require.NoError(t, err, "the tip pointer names a block the store does not hold")
@@ -41,21 +44,35 @@ func TestAcceptedBlockSurvivesRestart(t *testing.T) {
}
// refusing is a store whose batches will not write, which is what a full disk
// looks like from up here: staging succeeds, the commit does not.
type refusing struct{ database.Database }
// looks like from up here: staging succeeds, the commit does not. The refusal
// is switchable, because the interesting question is what the store holds AFTER
// it comes back.
type refusing struct {
database.Database
refuse *bool
}
var errDiskFull = errors.New("store refused the write")
func (d refusing) NewBatch() database.Batch { return refusingBatch{d.Database.NewBatch()} }
func (d refusing) NewBatch() database.Batch { return refusingBatch{d.Database.NewBatch(), d.refuse} }
type refusingBatch struct{ database.Batch }
type refusingBatch struct {
database.Batch
refuse *bool
}
func (b refusingBatch) Write() error { return errDiskFull }
func (b refusingBatch) Write() error {
if *b.refuse {
return errDiskFull
}
return b.Batch.Write()
}
// refuseWrites points the VM's version layer at a store that cannot commit.
// Everything already committed stays readable underneath.
func refuseWrites(vm *VM) {
vm.db = refusing{vm.db}
always := true
vm.db = refusing{vm.db, &always}
vm.state = versiondb.New(vm.db)
}
@@ -69,8 +86,8 @@ func refuseWrites(vm *VM) {
// whatever committed next.
func TestAcceptLeavesNothingBehindWhenAWriteFails(t *testing.T) {
vm, _ := bootVM(t, quietConfig())
before := vm.getLastAcceptedID()
beforeHeight := vm.getHeight()
before := tipOf(t, vm)
beforeHeight := heightOf(t, vm)
blk := buildOn(t, vm)
require.NoError(t, blk.Verify(context.Background()))
@@ -79,14 +96,46 @@ func TestAcceptLeavesNothingBehindWhenAWriteFails(t *testing.T) {
require.ErrorIs(t, blk.Accept(context.Background()), errDiskFull)
require.Equal(t, before, vm.getLastAcceptedID(), "the tip moved to a block whose writes failed")
require.Equal(t, beforeHeight, vm.getHeight(), "the height moved to a block whose writes failed")
require.Equal(t, before, tipOf(t, vm), "the tip moved to a block whose writes failed")
require.Equal(t, beforeHeight, heightOf(t, vm), "the height moved to a block whose writes failed")
_, err := vm.GetBlock(context.Background(), blk.id)
require.Error(t, err, "a partially written block is readable")
_, err = vm.GetBlockIDAtHeight(context.Background(), blk.height)
require.Error(t, err, "a partially written block is indexed")
}
// TestAFailedCommitStagesNothingForTheNextOne.
//
// Staged writes that are not discarded are not discarded LATER either — they
// are flushed, wholesale, by the next commit that succeeds. So a block whose
// Accept returned an error to the engine still reached the store, riding into
// it on an unrelated block minutes afterwards.
func TestAFailedCommitStagesNothingForTheNextOne(t *testing.T) {
refuse := false
vm := bootVMOn(t, quietConfig(), refusing{memdb.New(), &refuse})
tip := advance(t, vm, 1)
refuse = true
orphan := buildOn(t, vm)
require.ErrorIs(t, orphan.Accept(context.Background()), errDiskFull)
// The store comes back, and an unrelated block commits.
refuse = false
vm.clock.Set(chainTime.Add(time.Hour))
next := buildOn(t, vm)
require.NotEqual(t, orphan.id, next.id, "precondition: two distinct blocks")
require.NoError(t, next.Accept(context.Background()))
held, err := vm.state.Has(orphan.id[:])
require.NoError(t, err)
require.False(t, held, "the refused block was flushed into the store by the next successful commit")
require.Equal(t, next.id, tipOf(t, vm))
at, err := vm.GetBlockIDAtHeight(context.Background(), tip.height+1)
require.NoError(t, err)
require.Equal(t, next.id, at, "the height index names a block that never committed")
}
// TestAcceptKeepsTheMempoolWhenTheWriteFails: the transactions of a block that
// did not persist must still be there to go into the next one.
func TestAcceptKeepsTheMempoolWhenTheWriteFails(t *testing.T) {
@@ -111,6 +160,128 @@ func TestAcceptEvictsWhatItCommitted(t *testing.T) {
require.Zero(t, vm.txPool.PendingCount(), "an accepted block's transactions stayed pending")
}
// TestAcceptTakesOnlyTheBlockThatFollOwsTheTip.
//
// Accept wrote the tip pointer unconditionally, and Verify only ever compared a
// block against its PARENT — never against the chain's head. So any block that
// verified could be accepted, at any height, at any time:
//
// - a height-1 block accepted onto a height-5 chain rewound it to height 1,
// while heights 2 through 5 stayed indexed to the branch just abandoned —
// and GetBlockIDAtHeight served those to bootstrapping peers as canonical;
// - a block already accepted, accepted again, did the same;
// - two siblings both verified and both committed, the second silently
// replacing the first.
func TestAcceptTakesOnlyTheBlockThatFollowsTheTip(t *testing.T) {
ctx := context.Background()
vm, _ := bootVM(t, quietConfig())
chain := make([]*Block, 0, 5)
for i := 0; i < 5; i++ {
chain = append(chain, advance(t, vm, 1))
}
tip := chain[len(chain)-1]
require.Equal(t, uint64(5), heightOf(t, vm))
// An old block, re-offered.
require.ErrorIs(t, chain[0].Accept(ctx), errNotTheTip,
"a height-1 block rewound a height-5 chain")
require.Equal(t, tip.id, tipOf(t, vm))
require.Equal(t, uint64(5), heightOf(t, vm))
for i, blk := range chain {
at, err := vm.GetBlockIDAtHeight(ctx, uint64(i+1))
require.NoError(t, err)
require.Equal(t, blk.id, at, "height %d was re-indexed to an abandoned branch", i+1)
}
// The tip itself, re-offered.
require.ErrorIs(t, tip.Accept(ctx), errNotTheTip, "an accepted block was accepted a second time")
// Two siblings. Both verify — that is what makes consensus a choice — and
// exactly one may be accepted.
first := buildOn(t, vm)
require.NoError(t, first.Verify(ctx))
vm.clock.Set(chainTime.Add(time.Minute))
second := buildOn(t, vm)
require.NoError(t, second.Verify(ctx))
require.NotEqual(t, first.id, second.id, "precondition: two distinct siblings")
require.Equal(t, first.parentID, second.parentID, "precondition: they are siblings")
require.NoError(t, first.Accept(ctx))
require.ErrorIs(t, second.Accept(ctx), errNotTheTip, "a sibling silently replaced the accepted block")
require.Equal(t, first.id, tipOf(t, vm))
}
// countingTx records how many times it was applied, and can refuse.
type countingTx struct {
*BaseTransaction
runs *int32
fail error
}
func (tx countingTx) Execute() error {
atomic.AddInt32(tx.runs, 1)
return tx.fail
}
// TestTransactionsRunWhenTheBlockIsAccepted, and only then.
//
// They ran at BUILD time. A block the network never accepted had already
// applied its effects; a rebuild applied them twice; and a node that RECEIVED
// the block rather than building it applied them never — which is every node
// but one, for every block.
func TestTransactionsRunWhenTheBlockIsAccepted(t *testing.T) {
ctx := context.Background()
vm, _ := bootVM(t, quietConfig())
var runs int32
tx := countingTx{BaseTransaction: stampedTx(1, "apply me"), runs: &runs}
blk := buildWith(t, vm, tx)
require.Zero(t, atomic.LoadInt32(&runs), "building a block applied its transactions")
// Building again over the same pool contents must not apply them again.
rebuilt, err := vm.BuildBlock(ctx)
require.NoError(t, err)
require.Zero(t, atomic.LoadInt32(&runs), "a rebuild applied the same transaction a second time")
require.NoError(t, blk.Verify(ctx))
require.NoError(t, blk.Accept(ctx))
require.Equal(t, int32(1), atomic.LoadInt32(&runs), "an accepted block applied nothing")
// The sibling can no longer be accepted, so nothing runs twice.
require.Error(t, rebuilt.(*Block).Accept(ctx))
require.Equal(t, int32(1), atomic.LoadInt32(&runs))
}
// TestRejectAppliesNothing: a block that loses never ran, so there is nothing
// for Reject to undo — which is why Reject undoing nothing is correct rather
// than a gap.
func TestRejectAppliesNothing(t *testing.T) {
vm, _ := bootVM(t, quietConfig())
var runs int32
blk := buildWith(t, vm, countingTx{BaseTransaction: stampedTx(1, "never"), runs: &runs})
require.NoError(t, blk.Reject(context.Background()))
require.Zero(t, atomic.LoadInt32(&runs), "a rejected block had already applied its transactions")
}
// TestAcceptCommitsNothingWhenATransactionCannotBeApplied. A node that cannot
// apply an agreed block stops rather than committing a chain its state no
// longer matches.
func TestAcceptCommitsNothingWhenATransactionCannotBeApplied(t *testing.T) {
vm, _ := bootVM(t, quietConfig())
before := tipOf(t, vm)
var runs int32
blk := buildWith(t, vm, countingTx{
BaseTransaction: stampedTx(1, "cannot apply"),
runs: &runs,
fail: errors.New("state says no"),
})
require.ErrorIs(t, blk.Accept(context.Background()), errExecute)
require.Equal(t, before, tipOf(t, vm), "the tip advanced past a block that could not be applied")
}
// TestVerifyRefusesAnOrphan. Checking only that the parent exists ABOVE height
// one meant a height-one block could name any parent at all, including one no
// node has ever seen: it verified, it was accepted, and the chain it extended
@@ -119,10 +290,13 @@ func TestVerifyRefusesAnOrphan(t *testing.T) {
vm, _ := bootVM(t, quietConfig())
orphan := &Block{
timestamp: chainTime,
height: 1,
parentID: ids.GenerateTestID(), // a block this node has never held
vm: vm,
timestamp: chainTime,
height: 1,
parentID: ids.GenerateTestID(), // a block this node has never held
chainID: vm.blockchainID,
networkID: vm.NetworkID,
transactions: []Transaction{stampedTx(1, "op")},
vm: vm,
}
orphan.id = orphan.computeID()
@@ -134,10 +308,13 @@ func TestVerifyRefusesAnOrphan(t *testing.T) {
// block and the 499 heights in between simply do not exist.
func TestVerifyRefusesAHeightThatSkipsItsParent(t *testing.T) {
vm, _ := bootVM(t, quietConfig())
genesis := vm.getLastAcceptedID()
genesis, err := vm.blockAt(tipOf(t, vm))
require.NoError(t, err)
for _, height := range []uint64{2, 7, 500} {
blk := &Block{timestamp: chainTime, height: height, parentID: genesis, vm: vm}
blk := blockOn(vm, genesis, stampedTx(height, "op"))
blk.height = height
blk.bytes = nil
blk.id = blk.computeID()
require.ErrorIs(t, blk.Verify(context.Background()), errInvalidBlockHeight,
"height %d was accepted as the child of height 0", height)
@@ -148,78 +325,73 @@ func TestVerifyRefusesAHeightThatSkipsItsParent(t *testing.T) {
// start, identically on every node. A proposed one would be a second genesis.
func TestVerifyRefusesGenesisAsAProposal(t *testing.T) {
vm, _ := bootVM(t, quietConfig())
blk := &Block{timestamp: chainTime, height: 0, parentID: ids.Empty, vm: vm}
blk := &Block{
timestamp: chainTime, height: 0, parentID: ids.Empty,
chainID: vm.blockchainID, networkID: vm.NetworkID, vm: vm,
}
blk.id = blk.computeID()
require.ErrorIs(t, blk.Verify(context.Background()), errInvalidBlockHeight)
}
// TestVerifyRefusesTimeRunningBackwards. Chain time is what a quantum stamp's
// validity window is measured against, so a proposer that rewinds it revives
// stamps the chain has already aged out.
// TestVerifyRefusesAnEmptyBlock. A proposal carries work, and refusing an empty
// one is also what stops the transaction-signature check being satisfied by
// removing its subject: a block with no transactions verifies no signatures, so
// it must not verify at all.
func TestVerifyRefusesAnEmptyBlock(t *testing.T) {
vm, _ := bootVM(t, quietConfig())
genesis, err := vm.blockAt(tipOf(t, vm))
require.NoError(t, err)
require.ErrorIs(t, blockOn(vm, genesis).Verify(context.Background()), errEmptyBlock)
}
// TestVerifyRefusesTimeRunningBackwards. Chain time is what orders the chain, and
// a proposer that rewinds it decides what the block after it may be stamped.
func TestVerifyRefusesTimeRunningBackwards(t *testing.T) {
vm, _ := bootVM(t, quietConfig())
parent := advance(t, vm, 1)
blk := &Block{
timestamp: parent.timestamp.Add(-time.Second),
height: parent.height + 1,
parentID: parent.id,
vm: vm,
}
blk := blockOn(vm, parent, stampedTx(2, "op"))
blk.timestamp = parent.timestamp.Add(-time.Second)
blk.bytes = nil
blk.id = blk.computeID()
require.ErrorIs(t, blk.Verify(context.Background()), errTimeBeforeParent)
// The parent's own timestamp is fine — time may stand still, only not reverse.
same := &Block{timestamp: parent.timestamp, height: parent.height + 1, parentID: parent.id, vm: vm}
same.id = same.computeID()
same := blockOn(vm, parent, stampedTx(3, "op"))
require.NoError(t, same.Verify(context.Background()))
}
// TestVerifyRefusesTimeJumpingAhead. Uncapped, one proposer stamping a block
// far in the future expires every quantum stamp in flight at once — every
// pending transaction becomes unverifiable in a single block.
// far in the future decides chain time for everyone behind it.
func TestVerifyRefusesTimeJumpingAhead(t *testing.T) {
vm, _ := bootVM(t, quietConfig())
parent := advance(t, vm, 1)
blk := &Block{
timestamp: vm.clock.Time().Add(MaxFutureSkew + time.Second),
height: parent.height + 1,
parentID: parent.id,
vm: vm,
}
blk := blockOn(vm, parent, stampedTx(2, "op"))
blk.timestamp = vm.clock.Time().Add(MaxFutureSkew + time.Second)
blk.bytes = nil
blk.id = blk.computeID()
require.ErrorIs(t, blk.Verify(context.Background()), errTimeTooFarAhead)
// Just inside the allowance is a clock that is merely fast, not a rewrite
// of chain time, and it verifies.
ok := &Block{
timestamp: vm.clock.Time().Add(MaxFutureSkew - time.Second),
height: parent.height + 1,
parentID: parent.id,
vm: vm,
}
ok := blockOn(vm, parent, stampedTx(3, "op"))
ok.timestamp = vm.clock.Time().Add(MaxFutureSkew)
ok.bytes = nil
ok.id = ok.computeID()
require.NoError(t, ok.Verify(context.Background()))
}
// TestVerifyRefusesABlockWhoseStampsDoNotCheck: with Corona on, the signatures
// over the transactions are checked, and a block carrying one that does not
// verify is refused whole.
// TestVerifyRefusesABlockWhoseStampsDoNotCheck: the signatures over the
// transactions are checked, and a block carrying one that does not verify is
// refused whole.
func TestVerifyRefusesABlockWhoseStampsDoNotCheck(t *testing.T) {
vm, _ := bootVM(t, quietConfig())
parent := advance(t, vm, 1)
vm.Config.CoronaEnabled = true
vm, _ := bootVM(t, config.DefaultConfig()) // stamps ON
parent := advanceSigned(t, vm, 1)
good := signedTx(t, vm, 1, "honest")
blk := &Block{
timestamp: parent.timestamp,
height: parent.height + 1,
parentID: parent.id,
transactions: []Transaction{good},
vm: vm,
}
blk.id = blk.computeID()
blk := blockOn(vm, parent, good)
require.NoError(t, blk.Verify(context.Background()), "a correctly signed transaction must verify")
// One flipped bit in the signature is the whole difference.
@@ -228,6 +400,97 @@ func TestVerifyRefusesABlockWhoseStampsDoNotCheck(t *testing.T) {
require.ErrorIs(t, blk.Verify(context.Background()), errBlockVerificationFailed)
}
// TestVerifyChecksTheSignaturesOfABlockItDidNotBuild.
//
// This is the one that mattered. The parser did not reconstruct the transaction
// set, so a received block held ZERO transactions, and the signature check was
// gated on there being some — so it ran only on blocks this node built itself
// and never once on the receive path. Identical bytes got opposite verdicts, and
// a block whose transactions were arbitrary bytes verified and committed.
func TestVerifyChecksTheSignaturesOfABlockItDidNotBuild(t *testing.T) {
ctx := context.Background()
vm, _ := bootVM(t, config.DefaultConfig()) // stamps ON
parent := advanceSigned(t, vm, 1)
built := blockOn(vm, parent, signedTx(t, vm, 1, "honest"))
require.NoError(t, built.Verify(ctx))
// The same bytes, arriving over the network.
received, err := vm.ParseBlock(ctx, built.Bytes())
require.NoError(t, err)
require.Equal(t, built.id, received.ID(), "one block, two ids")
require.Len(t, received.(*Block).transactions, 1, "the parser dropped the transaction set")
require.NoError(t, received.Verify(ctx))
// And a block whose signature does not check must be refused on that same
// path — the verdict cannot depend on who assembled the object.
forgedTx := signedTx(t, vm, 2, "forged")
forgedTx.quantumSignature.Signature[0] ^= 0xFF
forged := blockOn(vm, parent, forgedTx)
parsed, err := vm.ParseBlock(ctx, forged.Bytes())
require.NoError(t, err, "precondition: the bytes are a well-formed block")
require.ErrorIs(t, parsed.Verify(ctx), errBlockVerificationFailed,
"a received block with a bad signature verified")
require.ErrorIs(t, forged.Verify(ctx), errBlockVerificationFailed,
"the same block verified differently depending on how it was assembled")
}
// TestTheTransactionBlobIsNotMutable. The transactions ride in the block's
// bytes; a byte changed in there either stops being a block or stops verifying,
// and never rides through as the same block.
func TestTheTransactionBlobIsNotMutable(t *testing.T) {
ctx := context.Background()
vm, _ := bootVM(t, config.DefaultConfig()) // stamps ON
parent := advanceSigned(t, vm, 1)
blk := blockOn(vm, parent, signedTx(t, vm, 1, "honest"))
wire := blk.Bytes()
tampered := make([]byte, len(wire))
copy(tampered, wire)
tampered[len(tampered)-1] ^= 0xFF
parsed, err := vm.ParseBlock(ctx, tampered)
require.NoError(t, err, "precondition: the tampered bytes still decode")
require.NotEqual(t, blk.id, parsed.ID())
require.ErrorIs(t, parsed.Verify(ctx), errBlockVerificationFailed,
"a transaction blob was edited in flight and the block still verified")
}
// TestVerifyRefusesABlockFromAnotherChain.
//
// The wire carried nothing naming a chain, so a block was a block anywhere: one
// built on chain A was accepted verbatim by chain B, and every Q-Chain shared a
// genesis id to build that on.
func TestVerifyRefusesABlockFromAnotherChain(t *testing.T) {
ctx := context.Background()
mine, _ := bootVM(t, quietConfig())
theirs := bootVMAs(t, quietConfig(), memdb.New(), testNetwork, otherChain)
tip, err := mine.blockAt(tipOf(t, mine))
require.NoError(t, err)
// A block naming this node's tip as its parent, stamped for another chain.
foreign := blockOn(mine, tip, stampedTx(1, "op"))
foreign.chainID = theirs.blockchainID
foreign.bytes = nil
foreign.id = foreign.computeID()
require.ErrorIs(t, foreign.Verify(ctx), errForeignChain)
require.ErrorIs(t, foreign.Accept(ctx), errForeignChain,
"a block belonging to another chain was committed to this one")
require.Equal(t, tip.id, tipOf(t, mine))
// The same for a block from another network on this chain id.
elsewhere := blockOn(mine, tip, stampedTx(2, "op"))
elsewhere.networkID = mine.NetworkID + 1
elsewhere.bytes = nil
elsewhere.id = elsewhere.computeID()
require.ErrorIs(t, elsewhere.Verify(ctx), errForeignChain)
require.ErrorIs(t, elsewhere.Accept(ctx), errForeignChain)
}
// TestVerifyDoesNotDeadlockAgainstABuilder.
//
// Verify held the VM's read lock and then called the exported GetBlock, which
@@ -242,8 +505,7 @@ func TestVerifyDoesNotDeadlockAgainstABuilder(t *testing.T) {
vm, _ := bootVM(t, quietConfig())
parent := advance(t, vm, 1)
blk := &Block{timestamp: parent.timestamp, height: parent.height + 1, parentID: parent.id, vm: vm}
blk.id = blk.computeID()
blk := blockOn(vm, parent, stampedTx(9999, "op"))
done := make(chan struct{})
go func() {
+26 -2
View File
@@ -38,25 +38,46 @@ type Config struct {
// Minimum batch size before GPU acceleration kicks in
GPUBatchThreshold int
// Committee is how many validators the finality committee holds. The
// threshold is derived from it, so it is the one number that decides how
// many faults the chain survives. Zero means unset and settles on
// CommitteeMin; anything below CommitteeMin is refused.
Committee int
}
// DefaultConfig returns a Config with default values
func DefaultConfig() Config {
return Config{
MaxParallelTxs: 100,
QuantumAlgorithmVersion: 1,
QuantumAlgorithmVersion: AlgorithmDefault,
QuantumStampEnabled: true,
QuantumStampWindow: 30 * time.Second,
ParallelBatchSize: 10,
CoronaEnabled: true,
GPUBatchThreshold: 8,
Committee: CommitteeMin,
}
}
// AlgorithmDefault is the parameter set an unset config settles on: ML-DSA-65,
// NIST level 3.
// NIST level 3. DefaultConfig names the same constant, so a config the operator
// filled in and one left blank land on the same parameter set — two spellings of
// the default meant a chain signed under ML-DSA-44 or ML-DSA-65 depending on
// which door it came through.
const AlgorithmDefault uint32 = 2
// CommitteeMin is the smallest committee that survives one Byzantine validator.
// BFT tolerates f faults out of n ≥ 3f+1, so f ≥ 1 needs n ≥ 4; below that the
// quorum ⌊2n/3⌋+1 is the whole committee and one absent validator stops the
// chain while one dishonest validator decides it.
const CommitteeMin = 4
// Quorum is how many validators must agree, for a committee of n. It is the
// classical ⌊2n/3⌋+1, which for n ≥ CommitteeMin always lands strictly between
// 2 and n — the range the consensus core accepts.
func Quorum(n int) int { return n*2/3 + 1 }
// Validate replaces any non-positive sizing with its default and refuses an
// algorithm that does not exist.
//
@@ -86,5 +107,8 @@ func (c *Config) Validate() error {
if c.QuantumStampWindow <= 0 {
c.QuantumStampWindow = 30 * time.Second
}
if c.Committee == 0 {
c.Committee = CommitteeMin
}
return nil
}
+105 -35
View File
@@ -5,44 +5,50 @@ package quantumvm
import (
"context"
"errors"
"testing"
"github.com/luxfi/chains/quantumvm/config"
"github.com/luxfi/chains/quantumvm/quantum"
"github.com/luxfi/consensus/protocol/quasar"
"github.com/luxfi/database"
"github.com/luxfi/database/memdb"
"github.com/luxfi/database/versiondb"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/runtime"
luxvm "github.com/luxfi/vm"
"github.com/luxfi/zap"
"github.com/stretchr/testify/require"
)
// TestTheVMDoesNotClaimASignatureItCouldNotMake.
// TestTheVMSignsEveryBlockItBuilds.
//
// Signing needs a Corona share, and shares come from a dealerless DKG that has
// not landed — AddValidator issues BLS keys only. So the post-quantum leg
// cannot sign, and what matters is what the node does about it: it builds the
// block, records no signature for it, and keeps nothing pending. Reporting the
// block as signed would be a node claiming post-quantum finality it never had.
func TestTheVMDoesNotClaimASignatureItCouldNotMake(t *testing.T) {
// The node signs with its own key and tracks the block, so a peer's signature
// has somewhere to land. That is the whole bridge: with the node unregistered
// it signed nothing, tracked nothing, and every peer signature was answered
// "pending block not found" — a finality engine that could never finalize, one
// warning line per block.
func TestTheVMSignsEveryBlockItBuilds(t *testing.T) {
vm, _ := bootVM(t, quietConfig())
bridge := vm.GetQuasarBridge()
blk := buildOn(t, vm)
require.NotContains(t, bridge.pendingBlocks, blk.ID(),
"the node tracked a block as signed when neither leg produced a signature")
require.Contains(t, bridge.pendingBlocks, blk.ID(),
"the node built a block and recorded no signature for it")
pending := bridge.pendingBlocks[blk.ID()]
require.Len(t, pending.Signatures, 1)
require.True(t, bridge.VerifySignature(blk.Bytes(), pending.Signatures[0]),
"the recorded signature does not check out over the block it signs")
// The block itself is unaffected: signing is a consensus-layer statement
// about a block, not a field on it.
require.NoError(t, blk.Verify(context.Background()))
require.NoError(t, blk.Accept(context.Background()))
// The finality bridge's stamp falls back to ML-DSA for the same reason.
// The finality bridge's stamp is that same signature.
stamp, err := vm.StampBlock(blk.ID(), 42, blk.Bytes())
require.NoError(t, err)
require.IsType(t, &quantum.QuantumSignature{}, stamp)
require.NoError(t, vm.VerifyStamp(stamp))
require.IsType(t, &quasar.QuasarSig{}, stamp)
require.NoError(t, vm.VerifyStamp(blk.Bytes(), stamp))
}
// TestBuildBlockWithNoBridgeStillBuilds: signing is best-effort, so a VM with
@@ -61,20 +67,13 @@ func TestBuildBlockWithNoBridgeStillBuilds(t *testing.T) {
// block that is already durable.
func TestAcceptSurvivesATransactionAlreadyGone(t *testing.T) {
vm, _ := bootVM(t, quietConfig())
tip, err := vm.blockAt(vm.getLastAcceptedID())
tip, err := vm.blockAt(tipOf(t, vm))
require.NoError(t, err)
blk := &Block{
timestamp: tip.timestamp,
height: tip.height + 1,
parentID: tip.id,
transactions: []Transaction{stampedTx(7, "never in this pool")},
vm: vm,
}
blk.id = blk.computeID()
blk := blockOn(vm, tip, stampedTx(7, "never in this pool"))
require.NoError(t, blk.Accept(context.Background()))
require.Equal(t, blk.id, vm.getLastAcceptedID())
require.Equal(t, blk.id, tipOf(t, vm))
}
// TestBuildBlockRefusesWhenTheTipIsUnreadable. The builder reads its parent to
@@ -82,7 +81,7 @@ func TestAcceptSurvivesATransactionAlreadyGone(t *testing.T) {
// never a block built on a guess.
func TestBuildBlockRefusesWhenTheTipIsUnreadable(t *testing.T) {
vm, _ := bootVM(t, quietConfig())
tip := vm.getLastAcceptedID()
tip := tipOf(t, vm)
require.NoError(t, vm.state.Put(tip[:], []byte("no longer a block")))
require.NoError(t, vm.txPool.AddTransaction(stampedTx(1, "op")))
@@ -94,14 +93,15 @@ func TestBuildBlockRefusesWhenTheTipIsUnreadable(t *testing.T) {
// that could not write it would come up naming no block, which is the one state
// bootstrap cannot recover from.
func TestInitializeRefusesAStoreItCannotCommitTo(t *testing.T) {
always := true
vm := &VM{Config: config.DefaultConfig()}
err := vm.Initialize(context.Background(), luxvm.Init{
DB: refusing{memdb.New()},
DB: refusing{memdb.New(), &always},
Log: log.NewNoOpLogger(),
Runtime: &runtime.Runtime{NetworkID: 96369, NodeID: ids.GenerateTestNodeID()},
Runtime: testRuntime(),
})
require.ErrorIs(t, err, errDiskFull)
require.Equal(t, ids.Empty, vm.getLastAcceptedID())
require.Equal(t, ids.Empty, tipOf(t, vm))
}
// TestShutdownCompletesWhenTheStoreIsAlreadyGone. Shutdown runs on the way out
@@ -116,15 +116,85 @@ func TestShutdownCompletesWhenTheStoreIsAlreadyGone(t *testing.T) {
require.NoError(t, vm.Shutdown(context.Background()))
}
// TestHeightReadsZeroFromAMalformedEntry rather than a number made of whatever
// bytes are there — the tip height decides what the next block's height is.
func TestHeightReadsZeroFromAMalformedEntry(t *testing.T) {
// unreadable is a store that holds everything and answers nothing: the shape of
// a disk that is failing rather than empty.
type unreadable struct{ database.Database }
var errUnreadable = errors.New("store could not answer the read")
func (d unreadable) Get([]byte) ([]byte, error) { return nil, errUnreadable }
// TestAReadThatFailedIsNotAChainThatIsEmpty.
//
// The tip was read as "ids.Empty on any error, and on any value that is not
// 32 bytes" — and seedGenesis reads ids.Empty as "a fresh chain". So ONE
// transient read failure at boot committed genesis over a live tip, and
// Initialize returned success: a node holding five blocks came back holding one
// and told its peers so. A short read did exactly the same. Not-found is the
// only reason for an empty answer; every other reason is a refusal to run.
func TestAReadThatFailedIsNotAChainThatIsEmpty(t *testing.T) {
vm, db := bootVM(t, quietConfig())
tip := advance(t, vm, 5)
require.Equal(t, uint64(5), heightOf(t, vm))
// A store that cannot answer.
blind := &VM{log: log.NewNoOpLogger(), blockchainID: testChain, NetworkID: testNetwork}
blind.state = versiondb.New(unreadable{db})
_, err := blind.tip()
require.ErrorIs(t, err, errTipUnreadable, "a failed read answered 'this chain is empty'")
_, err = blind.tipHeight()
require.ErrorIs(t, err, errTipUnreadable)
require.Error(t, blind.seedGenesis(), "genesis was written over a chain that could not be read")
_, err = blind.LastAccepted(context.Background())
require.Error(t, err)
// A short read is the same fact: an answer that is not an id.
short := &VM{log: log.NewNoOpLogger(), blockchainID: testChain, NetworkID: testNetwork}
short.state = versiondb.New(memdb.New())
require.NoError(t, short.state.Put(lastAcceptedKey, []byte("not an id")))
_, err = short.tip()
require.ErrorIs(t, err, errTipUnreadable)
require.Error(t, short.seedGenesis(), "genesis was written over a chain whose tip read short")
// The chain underneath is untouched: nothing rewound it.
reopened := bootVMOn(t, quietConfig(), db)
require.Equal(t, tip.id, tipOf(t, reopened))
require.Equal(t, uint64(5), heightOf(t, reopened))
}
// TestAMalformedHeightIsNotAHeightOfZero: the tip height decides what the next
// block's height is, so bytes that are not a height must refuse rather than
// read as the start of the chain.
func TestAMalformedHeightIsNotAHeightOfZero(t *testing.T) {
vm, _ := bootVM(t, quietConfig())
advance(t, vm, 1)
require.Equal(t, uint64(1), vm.getHeight())
require.Equal(t, uint64(1), heightOf(t, vm))
require.NoError(t, vm.state.Put(tipHeightKey, []byte{1, 2, 3}))
require.Zero(t, vm.getHeight(), "three bytes were read as a height")
_, err := vm.tipHeight()
require.ErrorIs(t, err, errTipUnreadable, "three bytes were read as a height")
require.NoError(t, vm.txPool.AddTransaction(stampedTx(9, "op")))
blk, err := vm.BuildBlock(context.Background())
require.NoError(t, err)
require.ErrorIs(t, blk.(*Block).Accept(context.Background()), errTipUnreadable)
}
// TestInitializeRefusesToBootOnAStoreItCannotRead is the same fact one level up:
// a node that cannot read its own tip does not start, because the alternative is
// starting on a chain it has just destroyed.
func TestInitializeRefusesToBootOnAStoreItCannotRead(t *testing.T) {
vm, db := bootVM(t, quietConfig())
advance(t, vm, 3)
fresh := &VM{Config: quietConfig()}
err := fresh.Initialize(context.Background(), luxvm.Init{
DB: unreadable{db},
Log: log.NewNoOpLogger(),
Runtime: testRuntime(),
})
require.ErrorIs(t, err, errTipUnreadable)
}
// TestStoreKeysCannotCollide. Block ids, the height index and the two tip
@@ -176,8 +246,8 @@ func TestProcessBatchTakesTheWholeBatchWhenEverySignatureIsGood(t *testing.T) {
// under a different id, which is two byte strings for one block and as many
// more as an attacker cares to send.
func TestWireRefusesAHeaderThatIsNotThere(t *testing.T) {
b := zap.NewBuilder(zap.HeaderSize + 64)
ob := b.StartObject(8) // eight bytes where the header needs sixty-four
b := zap.NewBuilder(zap.HeaderSize + blkSize)
ob := b.StartObject(8) // eight bytes where the header needs a hundred
ob.SetInt64(blkTime, 0)
ob.FinishAsRoot()
short := b.Finish()
+58 -19
View File
@@ -14,13 +14,20 @@ import (
"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.
// 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}
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
}
@@ -33,7 +40,7 @@ func seedVM(t *testing.T) (*VM, *memdb.Database) {
func TestSeedGenesisNamesATip(t *testing.T) {
vm, _ := seedVM(t)
if got := vm.getLastAcceptedID(); got != ids.Empty {
if got := tipOf(t, vm); got != ids.Empty {
t.Fatalf("precondition: a fresh VM should name no block, got %s", got)
}
@@ -41,16 +48,16 @@ func TestSeedGenesisNamesATip(t *testing.T) {
t.Fatalf("seedGenesis: %v", err)
}
if got := vm.getLastAcceptedID(); got == ids.Empty {
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 := vm.getHeight(); got != 0 {
if got := heightOf(t, vm); got != 0 {
t.Fatalf("genesis height = %d, want 0", got)
}
}
// genesisID is the one block every Q-Chain node must name at height 0.
// 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
@@ -59,7 +66,7 @@ func TestSeedGenesisNamesATip(t *testing.T) {
// 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"
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
@@ -76,16 +83,41 @@ func TestSeedGenesisAgreesAcrossNodes(t *testing.T) {
t.Fatalf("node b: %v", err)
}
if a.getLastAcceptedID() != b.getLastAcceptedID() {
if tipOf(t, a) != tipOf(t, b) {
t.Fatalf("nodes disagree on genesis: %s vs %s — this would fork the chain",
a.getLastAcceptedID(), b.getLastAcceptedID())
tipOf(t, a), tipOf(t, b))
}
if got := a.getLastAcceptedID().String(); got != genesisID {
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.
@@ -94,10 +126,16 @@ func TestSeedGenesisLeavesAnExistingChainAlone(t *testing.T) {
if err := vm.seedGenesis(); err != nil {
t.Fatalf("first seed: %v", err)
}
first := vm.getLastAcceptedID()
first := tipOf(t, vm)
// Advance the chain, as a running node would.
blk := &Block{timestamp: time.Unix(100, 0).UTC(), height: 7, parentID: first, vm: vm}
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)
@@ -106,11 +144,11 @@ func TestSeedGenesisLeavesAnExistingChainAlone(t *testing.T) {
if err := vm.seedGenesis(); err != nil {
t.Fatalf("second seed: %v", err)
}
if got := vm.getLastAcceptedID(); got != blk.id {
if got := tipOf(t, vm); 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)
if got := heightOf(t, vm); got != 1 {
t.Fatalf("seeding rewound height to %d, want 1", got)
}
}
@@ -122,13 +160,14 @@ func TestSeedGenesisSurvivesRestart(t *testing.T) {
if err := vm.seedGenesis(); err != nil {
t.Fatalf("seed: %v", err)
}
want := vm.getLastAcceptedID()
want := tipOf(t, vm)
// Restart: a new version layer over the SAME underlying store.
restarted := &VM{log: log.NewNoOpLogger(), db: db}
restarted := &VM{log: log.NewNoOpLogger(), db: db, blockchainID: testChain, NetworkID: testNetwork}
restarted.state = versiondb.New(db)
restarted.txPool = NewTransactionPool(8, restarted.log)
if got := restarted.getLastAcceptedID(); got != want {
if got := tipOf(t, restarted); got != want {
t.Fatalf("genesis did not survive restart: %s != %s (uncommitted)", got, want)
}
}
+83 -5
View File
@@ -5,6 +5,7 @@ package quantumvm
import (
"context"
"sync/atomic"
"testing"
"time"
@@ -24,6 +25,19 @@ import (
// machine happens to have.
var chainTime = time.Unix(1_700_000_000, 0).UTC()
// testNetwork and testChain are the SAME on every VM a test boots, because that
// is the production shape: one chain, many nodes. Giving each test VM its own
// chain id varied the wrong field — every per-node value derived from the chain
// then differed by accident, and a bug that gave all nodes ONE identity could
// not be seen, because the test's chain ids were all different anyway.
const testNetwork uint32 = 96369
var (
testChain = ids.ID{'q', 'c', 'h', 'a', 'i', 'n'}
otherChain = ids.ID{'o', 't', 'h', 'e', 'r'}
otherNetworkID = testNetwork + 1
)
// bootVM starts a VM the way the node does — through Initialize, over a real
// store — and returns it with the base store, so a test can reopen the same
// bytes and see what actually survived.
@@ -34,14 +48,22 @@ func bootVM(t *testing.T, cfg config.Config) (*VM, database.Database) {
}
func bootVMOn(t *testing.T, cfg config.Config, db database.Database) *VM {
t.Helper()
return bootVMAs(t, cfg, db, testNetwork, testChain)
}
// bootVMAs starts a VM on a named network and chain. Every node gets its own
// NodeID and they share the chain, which is the only combination production
// ever has.
func bootVMAs(t *testing.T, cfg config.Config, db database.Database, networkID uint32, chainID ids.ID) *VM {
t.Helper()
vm := &VM{Config: cfg}
err := vm.Initialize(context.Background(), luxvm.Init{
DB: db,
Log: log.NewNoOpLogger(),
Runtime: &runtime.Runtime{
NetworkID: 96369,
ChainID: ids.GenerateTestID(),
NetworkID: networkID,
ChainID: chainID,
NodeID: ids.GenerateTestNodeID(),
},
Genesis: []byte("q-chain genesis"),
@@ -62,6 +84,22 @@ func quietConfig() config.Config {
return cfg
}
// tipOf is the last accepted block id, which a test may always read.
func tipOf(t *testing.T, vm *VM) ids.ID {
t.Helper()
id, err := vm.tip()
require.NoError(t, err)
return id
}
// heightOf is the tip height, which a test may always read.
func heightOf(t *testing.T, vm *VM) uint64 {
t.Helper()
h, err := vm.tipHeight()
require.NoError(t, err)
return h
}
// signedTx is a transaction carrying a genuine ML-DSA signature over its own
// wire, produced by the VM's own signer — the shape ProcessBatch expects.
func signedTx(t *testing.T, vm *VM, nonce uint64, data string) *BaseTransaction {
@@ -86,15 +124,42 @@ func stampedTx(nonce uint64, data string) *BaseTransaction {
}
}
// buildOn produces the next block on the tip with one transaction in it.
func buildOn(t *testing.T, vm *VM) *Block {
// blockOn assembles a block on a parent without going through the builder, so a
// test can state a field the builder would never produce.
func blockOn(vm *VM, parent *Block, txs ...Transaction) *Block {
blk := &Block{
timestamp: parent.timestamp,
height: parent.height + 1,
parentID: parent.id,
chainID: vm.blockchainID,
networkID: vm.NetworkID,
transactions: txs,
vm: vm,
}
blk.id = blk.computeID()
return blk
}
// buildWith pools one transaction and builds the next block from it.
func buildWith(t *testing.T, vm *VM, tx Transaction) *Block {
t.Helper()
require.NoError(t, vm.txPool.AddTransaction(stampedTx(uint64(vm.getHeight()+1), "op")))
require.NoError(t, vm.txPool.AddTransaction(tx))
blk, err := vm.BuildBlock(context.Background())
require.NoError(t, err)
return blk.(*Block)
}
// nonce hands out a distinct transaction nonce per call, so two blocks built in
// one test never carry the same transaction — the pool refuses a duplicate, and
// a test that hit that would be measuring the pool rather than what it meant to.
var nonce atomic.Uint64
// buildOn produces the next block on the tip with one transaction in it.
func buildOn(t *testing.T, vm *VM) *Block {
t.Helper()
return buildWith(t, vm, stampedTx(nonce.Add(1), "op"))
}
// advance builds, verifies and accepts n blocks, returning the last.
func advance(t *testing.T, vm *VM, n int) *Block {
t.Helper()
@@ -106,3 +171,16 @@ func advance(t *testing.T, vm *VM, n int) *Block {
}
return last
}
// advanceSigned is advance for a VM with quantum stamping ON, where a
// transaction has to carry a signature that actually checks out.
func advanceSigned(t *testing.T, vm *VM, n int) *Block {
t.Helper()
var last *Block
for i := 0; i < n; i++ {
last = buildWith(t, vm, signedTx(t, vm, nonce.Add(1), "op"))
require.NoError(t, last.Verify(context.Background()))
require.NoError(t, last.Accept(context.Background()))
}
return last
}
+15 -9
View File
@@ -35,10 +35,13 @@ func TestParseAcceptsWhatWeSerialize(t *testing.T) {
vm := parseVM(t)
blk := &Block{
timestamp: time.Unix(1000, 0).UTC(),
height: 7,
parentID: ids.GenerateTestID(),
vm: vm,
timestamp: time.Unix(1000, 0).UTC(),
height: 7,
parentID: ids.GenerateTestID(),
chainID: vm.blockchainID,
networkID: vm.NetworkID,
transactions: []Transaction{stampedTx(1, "op")},
vm: vm,
}
blk.id = blk.computeID()
@@ -64,7 +67,7 @@ func TestGenesisParses(t *testing.T) {
if err := vm.seedGenesis(); err != nil {
t.Fatalf("seedGenesis: %v", err)
}
gid := vm.getLastAcceptedID()
gid := tipOf(t, vm)
raw, err := vm.state.Get(gid[:])
if err != nil {
t.Fatalf("read genesis bytes: %v", err)
@@ -92,10 +95,13 @@ func TestServeAndReceiveAgree(t *testing.T) {
}
blk := &Block{
timestamp: time.Unix(2000, 0).UTC(),
height: 1,
parentID: vm.getLastAcceptedID(),
vm: vm,
timestamp: time.Unix(2000, 0).UTC(),
height: 1,
parentID: tipOf(t, vm),
chainID: vm.blockchainID,
networkID: vm.NetworkID,
transactions: []Transaction{stampedTx(1, "op")},
vm: vm,
}
blk.id = blk.computeID()
if err := blk.Accept(context.Background()); err != nil {
+91 -58
View File
@@ -81,7 +81,12 @@ func (k *MLDSAValidatorKey) signer(mode mldsa.Mode) (*mldsa.PrivateKey, error) {
// NewQuantumSigner creates a new quantum signer with real ML-DSA.
// algorithmVersion: 1=MLDSA44, 2=MLDSA65, 3=MLDSA87 — the parameter set
// determines every key and signature width, so there is nothing else to size.
func NewQuantumSigner(log log.Logger, algorithmVersion uint32, stampWindow time.Duration) *QuantumSigner {
//
// A version that does not exist is refused. Falling through to ML-DSA-65
// instead meant an operator who asked for something else got a chain signing
// under a parameter set nobody chose, and never heard about it — and it made
// the signer disagree with the config about what "unset" means.
func NewQuantumSigner(log log.Logger, algorithmVersion uint32, stampWindow time.Duration) (*QuantumSigner, error) {
var mode mldsa.Mode
switch algorithmVersion {
case AlgorithmMLDSA44:
@@ -91,8 +96,8 @@ func NewQuantumSigner(log log.Logger, algorithmVersion uint32, stampWindow time.
case AlgorithmMLDSA87:
mode = mldsa.MLDSA87
default:
mode = mldsa.MLDSA65 // Default to NIST Level 3
algorithmVersion = AlgorithmMLDSA65
return nil, fmt.Errorf("%w: %d (1=ML-DSA-44, 2=ML-DSA-65, 3=ML-DSA-87)",
ErrUnsupportedAlgorithm, algorithmVersion)
}
return &QuantumSigner{
@@ -100,7 +105,7 @@ func NewQuantumSigner(log log.Logger, algorithmVersion uint32, stampWindow time.
algorithmVersion: algorithmVersion,
mldsaMode: mode,
stampWindow: stampWindow,
}
}, nil
}
// GenerateCoronaKey generates a new ML-DSA validator identity key.
@@ -144,26 +149,25 @@ func (qs *QuantumSigner) Sign(message []byte, key *MLDSAValidatorKey) (*QuantumS
return nil, fmt.Errorf("failed to restore ML-DSA key: %w", err)
}
// Generate quantum stamp
stamp, err := qs.generateQuantumStamp(message, key)
// One reading of the clock: the stamp is derived from it, the signature
// covers it, and it is what the signature reports. Two readings would be
// two different times for one signature.
stamped := time.Now()
stamp, err := generateQuantumStamp(message, key, stamped)
if err != nil {
return nil, fmt.Errorf("failed to generate quantum stamp: %w", err)
}
// Create message to sign: message || stamp
data := make([]byte, len(message)+len(stamp))
copy(data, message)
copy(data[len(message):], stamp)
// Sign with ML-DSA (real post-quantum signature!)
signature, err := mldsaPriv.Sign(rand.Reader, data, nil)
signature, err := mldsaPriv.Sign(rand.Reader, signedData(message, stamp, stamped), nil)
if err != nil {
return nil, fmt.Errorf("ML-DSA signing failed: %w", err)
}
return &QuantumSignature{
Algorithm: qs.algorithmVersion,
Timestamp: time.Now(),
Timestamp: stamped,
PublicKey: key.PublicKey,
Signature: signature,
CoronaKey: key.PublicKey,
@@ -182,8 +186,11 @@ func (qs *QuantumSigner) Verify(message []byte, sig *QuantumSignature) error {
return ErrUnsupportedAlgorithm
}
// Verify timestamp
if time.Since(sig.Timestamp) > qs.stampWindow {
// The stamp is fresh in both directions. Only one side was checked, and
// time.Since goes NEGATIVE for a future date, so any timestamp ahead of now
// compared as arbitrarily fresh and never expired at all.
age := time.Since(sig.Timestamp)
if age > qs.stampWindow || age < -qs.stampWindow {
return ErrQuantumStampExpired
}
@@ -193,27 +200,37 @@ func (qs *QuantumSigner) Verify(message []byte, sig *QuantumSignature) error {
return fmt.Errorf("invalid ML-DSA public key: %w", err)
}
// Recreate the signed message: message || stamp
data := make([]byte, len(message)+len(sig.QuantumStamp))
copy(data, message)
copy(data[len(message):], sig.QuantumStamp)
// Verify with ML-DSA (real post-quantum verification!)
if !pubKey.VerifySignature(data, sig.Signature) {
if !pubKey.VerifySignature(signedData(message, sig.QuantumStamp, sig.Timestamp), sig.Signature) {
return ErrQuantumVerificationFailed
}
return nil
}
// signedData is what the ML-DSA signature covers: the message, the stamp, and
// the TIME the stamp was made.
//
// The time was a plain field on the signature and nothing signed it, so the
// freshness check ran against a number the holder could rewrite: an expired
// stamp revived by setting its Timestamp to now, and the same edit forward
// produced one that never expired. A field a verifier trusts has to be a field
// the signature covers.
func signedData(message, stamp []byte, stamped time.Time) []byte {
data := make([]byte, len(message)+len(stamp)+8)
copy(data, message)
copy(data[len(message):], stamp)
binary.BigEndian.PutUint64(data[len(message)+len(stamp):], uint64(stamped.UnixNano()))
return data
}
// generateQuantumStamp generates a quantum stamp for message authentication
func (qs *QuantumSigner) generateQuantumStamp(message []byte, key *MLDSAValidatorKey) ([]byte, error) {
func generateQuantumStamp(message []byte, key *MLDSAValidatorKey, stamped time.Time) ([]byte, error) {
// Combine message, key nonce, and timestamp
timestamp := time.Now().UnixNano()
data := make([]byte, len(message)+len(key.Nonce)+8)
copy(data, message)
copy(data[len(message):], key.Nonce)
binary.BigEndian.PutUint64(data[len(message)+len(key.Nonce):], uint64(timestamp))
binary.BigEndian.PutUint64(data[len(message)+len(key.Nonce):], uint64(stamped.UnixNano()))
// Generate quantum stamp using SHA-512
hash := sha512.Sum512(data)
@@ -261,6 +278,52 @@ func (qs *QuantumSigner) ParallelVerifyWithThreshold(messages [][]byte, signatur
return qs.cpuParallelVerify(messages, signatures)
}
// packBatch lays the batch out as three flat row-major buffers, one row per
// signature, for tensor creation.
//
// Every copy is bounded at BOTH ends, and every input is measured before it is
// copied. Slicing only the low end let row i run off the end of itself into row
// i+1: an oversized signature or public key overwrote the NEXT entry's row, so a
// caller supplying a matching over-long pair made row i+1 verify under a key of
// its own choosing. ML-DSA widths are fixed by the parameter set, so anything
// off-width is refused rather than padded.
func (qs *QuantumSigner) packBatch(messages [][]byte, signatures []*QuantumSignature) (msgBuf, sigBuf, pkBuf []uint8, maxMsgLen int, err error) {
n := len(messages)
sigSize := mldsa.GetSignatureSize(qs.mldsaMode)
pkSize := mldsa.GetPublicKeySize(qs.mldsaMode)
full := make([][]byte, n)
for i := 0; i < n; i++ {
sig := signatures[i]
if sig == nil {
return nil, nil, nil, 0, fmt.Errorf("signature %d: nil", i)
}
if len(sig.Signature) != sigSize {
return nil, nil, nil, 0, fmt.Errorf("signature %d: %d bytes, ML-DSA takes %d",
i, len(sig.Signature), sigSize)
}
if len(sig.PublicKey) != pkSize {
return nil, nil, nil, 0, fmt.Errorf("public key %d: %d bytes, ML-DSA takes %d",
i, len(sig.PublicKey), pkSize)
}
// The signed data is what Sign covered: message || stamp || stamp time.
full[i] = signedData(messages[i], sig.QuantumStamp, sig.Timestamp)
if len(full[i]) > maxMsgLen {
maxMsgLen = len(full[i])
}
}
msgBuf = make([]uint8, n*maxMsgLen)
sigBuf = make([]uint8, n*sigSize)
pkBuf = make([]uint8, n*pkSize)
for i := 0; i < n; i++ {
copy(msgBuf[i*maxMsgLen:(i+1)*maxMsgLen], full[i])
copy(sigBuf[i*sigSize:(i+1)*sigSize], signatures[i].Signature)
copy(pkBuf[i*pkSize:(i+1)*pkSize], signatures[i].PublicKey)
}
return msgBuf, sigBuf, pkBuf, maxMsgLen, nil
}
// gpuBatchVerify runs DilithiumVerifyBatch on GPU via accel session.
func (qs *QuantumSigner) gpuBatchVerify(messages [][]byte, signatures []*QuantumSignature) error {
n := len(messages)
@@ -273,43 +336,13 @@ func (qs *QuantumSigner) gpuBatchVerify(messages [][]byte, signatures []*Quantum
latticeOps := sess.Lattice()
// Determine fixed sizes for this ML-DSA mode
msgBuf, sigBuf, pkBuf, maxMsgLen, err := qs.packBatch(messages, signatures)
if err != nil {
return err
}
sigSize := mldsa.GetSignatureSize(qs.mldsaMode)
pkSize := mldsa.GetPublicKeySize(qs.mldsaMode)
// Find max message length (messages include appended quantum stamp)
maxMsgLen := 0
for i := 0; i < n; i++ {
fullLen := len(messages[i]) + len(signatures[i].QuantumStamp)
if fullLen > maxMsgLen {
maxMsgLen = fullLen
}
}
// Pack into flat byte arrays for tensor creation
msgBuf := make([]uint8, n*maxMsgLen)
sigBuf := make([]uint8, n*sigSize)
pkBuf := make([]uint8, n*pkSize)
for i := 0; i < n; i++ {
sig := signatures[i]
if sig == nil || len(sig.Signature) == 0 {
return fmt.Errorf("signature %d: nil or empty", i)
}
// Reconstruct signed data: message || stamp
fullMsg := make([]byte, len(messages[i])+len(sig.QuantumStamp))
copy(fullMsg, messages[i])
copy(fullMsg[len(messages[i]):], sig.QuantumStamp)
copy(msgBuf[i*maxMsgLen:], fullMsg)
// Copy signature bytes (pad if shorter)
copy(sigBuf[i*sigSize:], sig.Signature)
// Copy public key bytes
copy(pkBuf[i*pkSize:], sig.PublicKey)
}
// Create tensors
msgTensor, err := accel.NewTensorWithData[uint8](sess, []int{n, maxMsgLen}, msgBuf)
if err != nil {
+151 -7
View File
@@ -5,6 +5,7 @@ package quantum
import (
"bytes"
"errors"
"runtime"
"testing"
"time"
@@ -23,7 +24,16 @@ func runtimeCollect() {
func signer(t *testing.T) *QuantumSigner {
t.Helper()
return NewQuantumSigner(log.NewNoOpLogger(), AlgorithmMLDSA65, time.Minute)
return newSigner(t, AlgorithmMLDSA65, time.Minute)
}
func newSigner(t *testing.T, version uint32, window time.Duration) *QuantumSigner {
t.Helper()
qs, err := NewQuantumSigner(log.NewNoOpLogger(), version, window)
if err != nil {
t.Fatalf("NewQuantumSigner(%d): %v", version, err)
}
return qs
}
// stored is a validator key in the shape it takes coming back from storage or
@@ -130,7 +140,7 @@ func TestSignRejectsUnparseableSecret(t *testing.T) {
// window is the whole reason a stamp exists — without the check, a signature
// captured once is replayable for as long as the chain runs.
func TestVerifyRefusesAnExpiredStamp(t *testing.T) {
qs := NewQuantumSigner(log.NewNoOpLogger(), AlgorithmMLDSA65, 50*time.Millisecond)
qs := newSigner(t, AlgorithmMLDSA65, 50*time.Millisecond)
key := stored(t, qs)
msg := []byte("round digest")
@@ -153,8 +163,8 @@ func TestVerifyRefusesAnExpiredStamp(t *testing.T) {
// signature widths differ, so accepting one would mean verifying against a
// truncated key.
func TestVerifyRefusesAnotherAlgorithm(t *testing.T) {
weak := NewQuantumSigner(log.NewNoOpLogger(), AlgorithmMLDSA44, time.Minute)
strong := NewQuantumSigner(log.NewNoOpLogger(), AlgorithmMLDSA87, time.Minute)
weak := newSigner(t, AlgorithmMLDSA44, time.Minute)
strong := newSigner(t, AlgorithmMLDSA87, time.Minute)
sig, err := weak.Sign([]byte("digest"), stored(t, weak))
if err != nil {
@@ -249,9 +259,8 @@ func TestParallelVerifyRefusesMismatchedInputs(t *testing.T) {
func TestSignerReportsItsWidths(t *testing.T) {
for _, tc := range []struct{ version uint32 }{
{AlgorithmMLDSA44}, {AlgorithmMLDSA65}, {AlgorithmMLDSA87},
{99}, // anything unknown settles on the level-3 default
} {
qs := NewQuantumSigner(log.NewNoOpLogger(), tc.version, time.Minute)
qs := newSigner(t, tc.version, time.Minute)
key, err := qs.GenerateCoronaKey()
if err != nil {
t.Fatalf("GenerateCoronaKey: %v", err)
@@ -268,7 +277,7 @@ func TestSignerReportsItsWidths(t *testing.T) {
}
// A second signer named the same way agrees on the mode, so it can
// verify what the first produced.
peer := NewQuantumSigner(log.NewNoOpLogger(), tc.version, time.Minute)
peer := newSigner(t, tc.version, time.Minute)
if peer.GetMode() != qs.GetMode() {
t.Fatalf("version %d resolved to two different modes", tc.version)
}
@@ -317,3 +326,138 @@ func TestGPUVerifyFailsClosedWhenThereIsNoGPU(t *testing.T) {
t.Fatal("the fallback accepted a forged signature")
}
}
// TestTheStampTimeIsSigned.
//
// Freshness was decided by a plain field on the signature — Sign covered
// message || stamp and NOT the Timestamp — so the holder of an expired stamp
// revived it by writing the current time into it, and the same edit forward
// produced one that never expired at all. The window is the only thing standing
// between a captured signature and unlimited replay, so the value it reads has
// to be one the signature covers.
func TestTheStampTimeIsSigned(t *testing.T) {
qs := newSigner(t, AlgorithmMLDSA65, 50*time.Millisecond)
key := stored(t, qs)
msg := []byte("round digest")
sig, err := qs.Sign(msg, key)
if err != nil {
t.Fatalf("Sign: %v", err)
}
if err := qs.Verify(msg, sig); err != nil {
t.Fatalf("a fresh signature did not verify: %v", err)
}
time.Sleep(120 * time.Millisecond)
if err := qs.Verify(msg, sig); err != ErrQuantumStampExpired {
t.Fatalf("precondition: the stamp should have expired, got %v", err)
}
// Rewrite the field to now. The window is satisfied — and the signature is
// not, because the field is part of what was signed.
sig.Timestamp = time.Now()
if err := qs.Verify(msg, sig); err != ErrQuantumVerificationFailed {
t.Fatalf("an expired stamp was revived by rewriting its timestamp: %v", err)
}
}
// TestVerifyRefusesAFutureStamp. time.Since goes NEGATIVE for a date ahead of
// now, so a stamp dated forward compared as arbitrarily fresh and never expired
// — one write to an unsigned field bought a signature that is valid forever.
func TestVerifyRefusesAFutureStamp(t *testing.T) {
qs := newSigner(t, AlgorithmMLDSA65, time.Minute)
key := stored(t, qs)
msg := []byte("round digest")
sig, err := qs.Sign(msg, key)
if err != nil {
t.Fatalf("Sign: %v", err)
}
for _, ahead := range []time.Duration{2 * time.Minute, time.Hour, 24 * 365 * time.Hour} {
sig.Timestamp = time.Now().Add(ahead)
if err := qs.Verify(msg, sig); err != ErrQuantumStampExpired {
t.Fatalf("a stamp dated %s ahead was accepted: %v", ahead, err)
}
}
}
// TestNewQuantumSignerRefusesAnAlgorithmThatDoesNotExist.
//
// It fell through to ML-DSA-65 for anything unrecognised, so a caller that
// asked for something else got a signer running a parameter set nobody chose
// and never heard about it — and the config and the signer disagreed about what
// an unset version means.
func TestNewQuantumSignerRefusesAnAlgorithmThatDoesNotExist(t *testing.T) {
for _, version := range []uint32{0, 4, 7, 42, 99, ^uint32(0)} {
qs, err := NewQuantumSigner(log.NewNoOpLogger(), version, time.Minute)
if err == nil {
t.Fatalf("version %d was accepted and resolved to %v", version, qs.GetMode())
}
if !errors.Is(err, ErrUnsupportedAlgorithm) {
t.Fatalf("version %d: %v, want ErrUnsupportedAlgorithm", version, err)
}
if qs != nil {
t.Fatalf("version %d returned a signer alongside its refusal", version)
}
}
}
// TestBatchPackingKeepsEveryEntryInItsOwnRow.
//
// The rows are fixed-width and were copied with the low end bounded only, so an
// over-long signature or public key at index i ran off its row and overwrote
// index i+1's. A caller supplying a matching over-long pair chose the key that
// entry i+1 would be verified under. ML-DSA widths are fixed by the parameter
// set, so an off-width input is refused rather than trusted to fit.
func TestBatchPackingKeepsEveryEntryInItsOwnRow(t *testing.T) {
qs := signer(t)
sigSize, pkSize := qs.GetSignatureSize(), qs.GetPublicKeySize()
msgs := [][]byte{[]byte("one"), []byte("two")}
sigs := make([]*QuantumSignature, len(msgs))
for i := range msgs {
sig, err := qs.Sign(msgs[i], stored(t, qs))
if err != nil {
t.Fatalf("Sign: %v", err)
}
sigs[i] = sig
}
_, sigBuf, pkBuf, _, err := qs.packBatch(msgs, sigs)
if err != nil {
t.Fatalf("a well-formed batch was refused: %v", err)
}
if len(sigBuf) != len(msgs)*sigSize || len(pkBuf) != len(msgs)*pkSize {
t.Fatalf("rows are %d/%d bytes, want %d/%d", len(sigBuf), len(pkBuf), len(msgs)*sigSize, len(msgs)*pkSize)
}
if !bytes.Equal(pkBuf[pkSize:], sigs[1].PublicKey) {
t.Fatal("entry 1's key is not in entry 1's row")
}
// An over-long signature at index 0 would reach into index 1's row.
oversized := *sigs[0]
oversized.Signature = bytes.Repeat([]byte{0xAA}, sigSize+64)
if _, _, _, _, err := qs.packBatch(msgs, []*QuantumSignature{&oversized, sigs[1]}); err == nil {
t.Fatal("an over-long signature was packed, overwriting the next entry's row")
}
// So would an over-long public key — which is the half that decides what
// the next entry is verified against.
forged := *sigs[0]
forged.PublicKey = bytes.Repeat([]byte{0xBB}, pkSize+pkSize)
if _, _, _, _, err := qs.packBatch(msgs, []*QuantumSignature{&forged, sigs[1]}); err == nil {
t.Fatal("an over-long public key was packed, choosing the key the next entry verifies under")
}
// Short is refused too: padding it would verify against a key nobody sent.
short := *sigs[0]
short.Signature = sigs[0].Signature[:sigSize-1]
if _, _, _, _, err := qs.packBatch(msgs, []*QuantumSignature{&short, sigs[1]}); err == nil {
t.Fatal("a short signature was padded into a row")
}
if _, _, _, _, err := qs.packBatch(msgs, []*QuantumSignature{nil, sigs[1]}); err == nil {
t.Fatal("a nil signature was packed")
}
}
+231 -301
View File
@@ -1,201 +1,175 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//
// Quasar: Quantum-Safe Finality Engine
// Quasar: the Q-Chain finality bridge.
//
// Like stellar fusion combining hydrogen into helium, Quasar unifies
// classical BLS signatures with post-quantum Corona signatures.
// Both burn in parallel - classical for speed, quantum for eternity.
//
// No block escapes the event horizon without quantum finality.
// A block is final when a quorum of the committee has SIGNED it and the
// aggregate of those signatures verifies against the committee's keys. Every
// word of that is load-bearing here: signed, not claimed; verified, not counted;
// aggregate, not a tally of names a sender chose for itself.
package quantumvm
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"sync"
"github.com/luxfi/chains/quantumvm/config"
"github.com/luxfi/consensus/protocol/quasar"
"github.com/luxfi/ids"
"github.com/luxfi/log"
)
// BlockSigs contains both BLS and Corona signatures for a block.
// Both are produced in parallel during signing.
type BlockSigs struct {
BLS *quasar.BLSSignature
Corona *quasar.CoronaSignature
}
var (
// errDuplicateSigner — a validator that already contributed a signature for
// this block sent another. The quorum is counted over signers, so admitting
// a second signature from one validator would let a single peer reach the
// threshold alone by resending.
errDuplicateSigner = errors.New("quantumvm: validator already signed this block")
// Quasar is the core Post-Quantum BFT consensus engine for Q-Chain.
// Like a supermassive black hole, it pulls all blocks to quantum finality
// using dual BLS+Corona threshold signatures:
// - BLS threshold signatures (classical security, fast path)
// - Corona threshold signatures (post-quantum, Ring-LWE based)
// errUnverifiedSigner — the signature does not check out against the
// registered key of the validator it names, over the block it names.
errUnverifiedSigner = errors.New("quantumvm: signature does not verify for the validator it claims")
errUnknownBlock = errors.New("quantumvm: no block awaiting signatures")
errNoValidatorID = errors.New("quantumvm: a signer with no identity cannot be a member of a quorum")
errAggregateRefused = errors.New("quantumvm: the aggregate of the collected signatures does not verify")
errAlreadyRegistered = errors.New("quantumvm: validator is already in the committee")
errCommitteeFull = errors.New("quantumvm: the committee is full")
)
// Quasar collects validator signatures for Q-Chain blocks and finalizes a block
// once the quorum's aggregate signature verifies.
//
// Blocks are NOT considered produced without BOTH thresholds being met.
// The core underneath signs with the validator's BLS key and, in the same call,
// attests the same message with that validator's ML-DSA-65 identity key
// (FIPS 204) — so the post-quantum half rides inside every signature this
// collects, and VerifyQuasarSig checks both halves or rejects.
type Quasar struct {
mu sync.RWMutex
// Core Quasar engine - provides both BLS and Corona signing directly
quasar *quasar.Quasar
// core is the consensus signing engine: it holds the committee's keys and
// is the only thing that can say whether a signature is real.
core *quasar.Quasar
// Validator configuration
validatorID string
threshold int
totalNodes int
committee int
// Logging
log log.Logger
// Block finality tracking
// validators is the committee roster this bridge registered. The core
// holds their keys; this holds the membership, which is what makes the
// committee a set of a declared size rather than whatever accumulated.
validators map[string]struct{}
finalizedBlocks map[ids.ID]bool
pendingBlocks map[ids.ID]*PendingBlock
}
// PendingBlock tracks a block awaiting dual signature finality.
// Both BLS AND Corona must reach threshold for quantum finality.
// Signatures are collected in parallel - either can complete first.
// PendingBlock tracks a block gathering signatures. Every signature in the slice
// has been verified against BlockHash and against the registered key of the
// validator it names, and no two name the same validator.
type PendingBlock struct {
BlockID ids.ID
BlockHash []byte
Height uint64
BLSSignatures []*quasar.BLSSignature // Classical threshold signatures (parallel)
CoronaSignatures []*quasar.CoronaSignature // Post-quantum threshold signatures (parallel)
BLSFinalized bool // BLS threshold reached
CoronaFinalized bool // Corona threshold reached
Finalized bool // BOTH complete = quantum finality
BlockID ids.ID
BlockHash []byte
Height uint64
Signatures []*quasar.QuasarSig
Finalized bool
}
// QuasarConfig configures the Quasar PQ-BFT consensus
// by returns this block's signature from one validator, or nil.
func (p *PendingBlock) by(validatorID string) *quasar.QuasarSig {
for _, sig := range p.Signatures {
if sig.ValidatorID == validatorID {
return sig
}
}
return nil
}
// QuasarConfig configures the finality bridge. The committee size is the only
// quorum input: the threshold follows from it, so the two cannot be set to
// disagree.
type QuasarConfig struct {
ValidatorID string
Threshold int
TotalNodes int
Committee int
Logger log.Logger
}
// NewQuasar creates a new Quasar PQ-BFT consensus engine.
// NewQuasar creates the finality bridge and registers this node with the
// consensus core as its own validator.
//
// The committee size is settled BEFORE the threshold is derived from it.
// Deriving first read TotalNodes while it was still zero, so a config that left
// it unset got (0*2/3)+1 = 1 — a one-of-three quorum on the three-node network
// the very next line then assumed. Any single validator would have finalized
// alone.
// Registration is not optional and not deferred, because a bridge that cannot
// sign for its own identity does nothing at all: SignMessage answers "validator
// not found", so no signature is ever recorded, so no peer signature ever finds
// a block to attach to, so the quorum is never reached and finality is
// unreachable — silently, one warning line per block.
//
// The committee must be able to survive a fault. Below config.CommitteeMin the
// quorum ⌊2n/3⌋+1 is the entire committee, which the consensus core refuses
// outright and which would in any case make one absent validator a halt and one
// dishonest validator the decision.
func NewQuasar(cfg QuasarConfig) (*Quasar, error) {
if cfg.TotalNodes < 1 {
cfg.TotalNodes = 3 // Default 3-node network
if cfg.ValidatorID == "" {
return nil, errNoValidatorID
}
if cfg.Threshold < 1 {
cfg.Threshold = (cfg.TotalNodes * 2 / 3) + 1 // 2/3+1 BFT threshold
if cfg.Committee == 0 {
cfg.Committee = config.CommitteeMin
}
if cfg.Committee < config.CommitteeMin {
return nil, fmt.Errorf("quantumvm: a committee of %d tolerates no fault; %d is the smallest that does",
cfg.Committee, config.CommitteeMin)
}
if cfg.Logger == nil {
cfg.Logger = log.NewNoOpLogger()
}
// Initialize Quasar core with BLS + Corona
qcore, err := quasar.NewQuasar(cfg.Threshold)
threshold := config.Quorum(cfg.Committee)
core, err := quasar.NewQuasar(threshold)
if err != nil {
return nil, fmt.Errorf("failed to create Quasar core: %w", err)
}
q := &Quasar{
quasar: qcore,
core: core,
validatorID: cfg.ValidatorID,
threshold: cfg.Threshold,
totalNodes: cfg.TotalNodes,
threshold: threshold,
committee: cfg.Committee,
log: cfg.Logger,
validators: make(map[string]struct{}, cfg.Committee),
finalizedBlocks: make(map[ids.ID]bool),
pendingBlocks: make(map[ids.ID]*PendingBlock),
}
if err := q.AddValidator(cfg.ValidatorID, 1); err != nil {
return nil, fmt.Errorf("failed to register this node with its own consensus core: %w", err)
}
return q, nil
}
// NOTE: there is intentionally no per-epoch dual-threshold key generation here.
// A prior helper (InitializeDualThreshold) called consensus quasar.GenerateDualKeys,
// a TRUSTED-DEALER keygen in which one process mints and holds every validator's
// BLS+Corona secret share — defeating threshold security. Consensus fenced that
// helper test-only (corona-genesis hardening), and this VM never invoked it: the
// Quasar bridge below signs through the consensus core with per-validator keys, and
// validators join via AddValidator. If a genuine per-epoch group key is ever needed,
// it MUST come from a dealerless DKG (corona dkg2 / pulsar v0.3 distributed), never a
// trusted dealer.
// SignBlock creates both BLS and Corona signatures for a block in parallel.
// Returns both signatures; both must reach threshold for quantum finality.
func (q *Quasar) SignBlock(ctx context.Context, blockID ids.ID, blockHash []byte, height uint64) (*BlockSigs, error) {
// SignBlock signs a block with this node's validator key and records the
// signature. Signing the same block again returns the signature already
// recorded: one validator makes one statement about one block.
func (q *Quasar) SignBlock(ctx context.Context, blockID ids.ID, blockHash []byte, height uint64) (*quasar.QuasarSig, error) {
q.mu.Lock()
pending, tracked := q.pendingBlocks[blockID]
if !tracked {
pending = &PendingBlock{
BlockID: blockID,
BlockHash: blockHash,
Height: height,
BLSSignatures: make([]*quasar.BLSSignature, 0),
CoronaSignatures: make([]*quasar.CoronaSignature, 0),
}
pending = &PendingBlock{BlockID: blockID, BlockHash: blockHash, Height: height}
q.pendingBlocks[blockID] = pending
}
qcore := q.quasar
validatorID := q.validatorID
if have := pending.by(q.validatorID); have != nil {
q.mu.Unlock()
return have, nil
}
core, validatorID := q.core, q.validatorID
q.mu.Unlock()
// Run both lanes in parallel
var (
blsSig *quasar.BLSSignature
pqSig *quasar.CoronaSignature
blsErr error
pqErr error
)
var wg sync.WaitGroup
wg.Add(2)
// BLS signing (single round, fast path)
go func() {
defer wg.Done()
quasarSig, err := qcore.SignMessageWithContext(ctx, validatorID, blockHash)
if err != nil {
blsErr = err
return
}
blsSig = &quasar.BLSSignature{
Signature: quasarSig.BLS,
ValidatorID: quasarSig.ValidatorID,
SignerIndex: quasarSig.SignerIndex,
}
}()
// Corona signing (Round 1 - D matrix + MACs)
go func() {
defer wg.Done()
sessionID := int(height) // Use height as session ID
// A digest, not a prefix. Slicing blockHash[:32] panicked on anything
// shorter, and the caller decides what it signs over — StampBlock takes
// an arbitrary message from the finality bridge.
digest := sha256.Sum256(blockHash)
round1Data, err := qcore.CoronaRound1(validatorID, sessionID, digest[:])
if err != nil {
pqErr = err
return
}
// Round1Data contains D matrix and MACs - we store the party ID and a marker
// The actual signature aggregation happens via Round2 + Finalize
pqSig = &quasar.CoronaSignature{
Signature: []byte{byte(round1Data.PartyID)}, // Store party ID, full data in aggregation
ValidatorID: validatorID,
SignerIndex: round1Data.PartyID,
Round: 1,
}
}()
wg.Wait()
if blsErr != nil || pqErr != nil {
sig, err := core.SignMessageWithContext(ctx, validatorID, pending.BlockHash)
if err != nil {
// The entry was created for a signature that never arrived. Only a
// block this node signed is ever finalized, and only a finalized block
// is ever cleaned up, so leaving it behind leaks one map entry per
@@ -205,207 +179,141 @@ func (q *Quasar) SignBlock(ctx context.Context, blockID ids.ID, blockHash []byte
delete(q.pendingBlocks, blockID)
q.mu.Unlock()
}
if blsErr != nil {
return nil, fmt.Errorf("BLS sign failed: %w", blsErr)
}
return nil, fmt.Errorf("Corona sign failed: %w", pqErr)
return nil, fmt.Errorf("quantumvm: sign failed: %w", err)
}
// The counts are read under the same lock that appends them. Reading them
// afterwards for the log line raced every peer signature arriving through
// AddBLSSignature.
q.mu.Lock()
pending.addBLS(blsSig)
pending.addCorona(pqSig)
blsCount, coronaCount := len(pending.BLSSignatures), len(pending.CoronaSignatures)
q.mu.Unlock()
defer q.mu.Unlock()
if err := q.record(pending, sig); err != nil {
if have := pending.by(validatorID); have != nil {
return have, nil
}
return nil, err
}
q.log.Debug("Block signed with Quasar (BLS + Corona parallel)",
q.log.Debug("block signed",
"blockID", blockID,
"height", height,
"blsSigCount", blsCount,
"coronaSigCount", coronaCount,
)
return &BlockSigs{BLS: blsSig, Corona: pqSig}, nil
}
// errDuplicateSigner — a validator that already contributed a signature for this
// block sent another. Both finality legs are counted by len(signatures) against
// the threshold, so admitting a second signature from one validator would let a
// single peer reach the threshold alone by resending.
var errDuplicateSigner = errors.New("quantumvm: validator already signed this block")
// addBLS records one BLS signature per validator and reports whether it was new.
// Caller holds q.mu.
func (p *PendingBlock) addBLS(sig *quasar.BLSSignature) bool {
for _, have := range p.BLSSignatures {
if have.ValidatorID == sig.ValidatorID {
return false
}
}
p.BLSSignatures = append(p.BLSSignatures, sig)
return true
}
// addCorona records one Corona signature per validator and reports whether it was
// new. Caller holds q.mu.
func (p *PendingBlock) addCorona(sig *quasar.CoronaSignature) bool {
for _, have := range p.CoronaSignatures {
if have.ValidatorID == sig.ValidatorID {
return false
}
}
p.CoronaSignatures = append(p.CoronaSignatures, sig)
return true
}
// AddBLSSignature adds a BLS signature from another validator
func (q *Quasar) AddBLSSignature(blockID ids.ID, sig *quasar.BLSSignature) error {
q.mu.Lock()
defer q.mu.Unlock()
pending := q.pendingBlocks[blockID]
if pending == nil {
return fmt.Errorf("pending block not found: %s", blockID)
}
if !pending.addBLS(sig) {
return fmt.Errorf("%w: %s (BLS)", errDuplicateSigner, sig.ValidatorID)
}
q.log.Debug("Added BLS signature",
"blockID", blockID,
"blsSigCount", len(pending.BLSSignatures),
"signatures", len(pending.Signatures),
"threshold", q.threshold,
)
return nil
return sig, nil
}
// AddCoronaSignature adds a Corona signature from another validator.
// record admits one signature into a block's set. Caller holds q.mu.
//
// The Corona leg is finalized on count alone (TryFinalize does not verify Corona
// signatures the way the BLS leg verifies its aggregate), so the count must be a
// count of DISTINCT validators or the post-quantum half of quantum finality is
// satisfied by one peer resending one signature.
func (q *Quasar) AddCoronaSignature(blockID ids.ID, sig *quasar.CoronaSignature) error {
// It VERIFIES before it counts, and it files the signature under the identity
// that verification authenticated. Counting a caller-supplied name instead made
// the quorum a count of strings: three fabricated validator ids finalized a
// block, and five spellings of one name — case, a trailing space, a NUL, the
// fullwidth forms — counted as five signers of one signature. Verification
// looks the claimed id up in the committee and checks the signature against
// THAT key over THIS block's hash, so a name nobody holds a key for fails, a
// respelling resolves to no validator and fails, and a signature made for
// another block fails against this one.
func (q *Quasar) record(p *PendingBlock, sig *quasar.QuasarSig) error {
if sig == nil {
return errUnverifiedSigner
}
if !q.core.VerifyQuasarSig(p.BlockHash, sig) {
return fmt.Errorf("%w: %q on block %s", errUnverifiedSigner, sig.ValidatorID, p.BlockID)
}
if p.by(sig.ValidatorID) != nil {
return fmt.Errorf("%w: %s", errDuplicateSigner, sig.ValidatorID)
}
p.Signatures = append(p.Signatures, sig)
return nil
}
// AddSignature admits a peer's signature for a block this node is tracking.
func (q *Quasar) AddSignature(blockID ids.ID, sig *quasar.QuasarSig) error {
q.mu.Lock()
defer q.mu.Unlock()
pending := q.pendingBlocks[blockID]
if pending == nil {
return fmt.Errorf("pending block not found: %s", blockID)
return fmt.Errorf("%w: %s", errUnknownBlock, blockID)
}
if err := q.record(pending, sig); err != nil {
return err
}
if !pending.addCorona(sig) {
return fmt.Errorf("%w: %s (Corona)", errDuplicateSigner, sig.ValidatorID)
}
q.log.Debug("Added Corona signature",
q.log.Debug("signature added",
"blockID", blockID,
"coronaSigCount", len(pending.CoronaSignatures),
"signatures", len(pending.Signatures),
"threshold", q.threshold,
)
return nil
}
// TryFinalize attempts to finalize a block if BOTH threshold signatures are collected.
// Quantum finality requires both BLS AND Corona thresholds to be met.
// VerifySignature reports whether one validator signature checks out over a
// message.
func (q *Quasar) VerifySignature(message []byte, sig *quasar.QuasarSig) bool {
if sig == nil {
return false
}
q.mu.RLock()
defer q.mu.RUnlock()
return q.core.VerifyQuasarSig(message, sig)
}
// VerifyAggregate reports whether an aggregated signature checks out over a
// message: the aggregate itself against the aggregated keys of the DISTINCT
// registered validators it names, at or above the threshold.
func (q *Quasar) VerifyAggregate(ctx context.Context, message []byte, agg *quasar.AggregatedSignature) bool {
if agg == nil {
return false
}
q.mu.RLock()
defer q.mu.RUnlock()
return q.core.VerifyAggregatedSignatureWithContext(ctx, message, agg)
}
// TryFinalize finalizes a block once the quorum's signatures aggregate into a
// signature that verifies over the block.
//
// Reaching the count is necessary and not sufficient: the aggregate is built
// and checked, and only a check that passes finalizes anything.
func (q *Quasar) TryFinalize(ctx context.Context, blockID ids.ID) (*quasar.AggregatedSignature, bool, error) {
q.mu.Lock()
defer q.mu.Unlock()
pending := q.pendingBlocks[blockID]
if pending == nil {
return nil, false, fmt.Errorf("block %s not found", blockID)
}
qcore := q.quasar
// Check BLS threshold
if !pending.BLSFinalized {
if len(pending.BLSSignatures) >= q.threshold {
// Convert BLSSignatures to QuasarSigs for aggregation
quasarSigs := make([]*quasar.QuasarSig, len(pending.BLSSignatures))
for i, blsSig := range pending.BLSSignatures {
quasarSigs[i] = &quasar.QuasarSig{
BLS: blsSig.Signature,
ValidatorID: blsSig.ValidatorID,
IsThreshold: blsSig.IsThreshold,
SignerIndex: blsSig.SignerIndex,
}
}
aggSig, err := qcore.AggregateSignaturesWithContext(ctx, pending.BlockHash, quasarSigs)
if err != nil {
return nil, false, fmt.Errorf("failed to aggregate BLS signatures: %w", err)
}
if qcore.VerifyAggregatedSignatureWithContext(ctx, pending.BlockHash, aggSig) {
pending.BLSFinalized = true
q.log.Debug("BLS threshold reached",
"blockID", blockID,
"count", len(pending.BLSSignatures),
)
}
}
return nil, false, fmt.Errorf("%w: %s", errUnknownBlock, blockID)
}
// Check Corona threshold
if !pending.CoronaFinalized {
if len(pending.CoronaSignatures) >= q.threshold {
// Corona finalized when threshold reached
pending.CoronaFinalized = true
q.log.Debug("Corona threshold reached",
"blockID", blockID,
"count", len(pending.CoronaSignatures),
)
}
if len(pending.Signatures) < q.threshold {
q.log.Debug("insufficient signatures to finalize",
"blockID", blockID,
"have", len(pending.Signatures),
"need", q.threshold,
)
return nil, false, nil
}
// Both must be finalized for quantum finality
if pending.BLSFinalized && pending.CoronaFinalized {
pending.Finalized = true
q.finalizedBlocks[blockID] = true
// Create aggregated signature with both components
quasarSigs := make([]*quasar.QuasarSig, len(pending.BLSSignatures))
for i, blsSig := range pending.BLSSignatures {
quasarSigs[i] = &quasar.QuasarSig{
BLS: blsSig.Signature,
ValidatorID: blsSig.ValidatorID,
IsThreshold: blsSig.IsThreshold,
SignerIndex: blsSig.SignerIndex,
}
}
aggSig, _ := qcore.AggregateSignaturesWithContext(ctx, pending.BlockHash, quasarSigs)
q.log.Info("═══════════════════════════════════════════════════════════════════")
q.log.Info("║ Q-BLOCK FINALIZED with Quasar PQ-BFT ║")
q.log.Info("║ Block ID:", log.Stringer("blockID", blockID))
q.log.Info("║ Height:", log.Uint64("height", pending.Height))
q.log.Info("║ BLS Signatures:", log.Int("count", len(pending.BLSSignatures)))
q.log.Info("║ Corona Signatures:", log.Int("count", len(pending.CoronaSignatures)))
q.log.Info("║ Quantum Finality:", log.Bool("complete", true))
q.log.Info("═══════════════════════════════════════════════════════════════════")
return aggSig, true, nil
agg, err := q.core.AggregateSignaturesWithContext(ctx, pending.BlockHash, pending.Signatures)
if err != nil {
return nil, false, fmt.Errorf("failed to aggregate signatures: %w", err)
}
if !q.core.VerifyAggregatedSignatureWithContext(ctx, pending.BlockHash, agg) {
return nil, false, fmt.Errorf("%w: %s", errAggregateRefused, blockID)
}
q.log.Debug("Insufficient signatures for quantum finalization",
pending.Finalized = true
q.finalizedBlocks[blockID] = true
q.log.Info("Q-block finalized",
"blockID", blockID,
"blsHave", len(pending.BLSSignatures),
"coronaHave", len(pending.CoronaSignatures),
"blsFinalized", pending.BLSFinalized,
"coronaFinalized", pending.CoronaFinalized,
"need", q.threshold,
"height", pending.Height,
"signatures", len(pending.Signatures),
"threshold", q.threshold,
)
return nil, false, nil
return agg, true, nil
}
// IsFinalized checks if a block has been finalized with BOTH signature types
// IsFinalized checks if a block has been finalized
func (q *Quasar) IsFinalized(blockID ids.ID) bool {
q.mu.RLock()
defer q.mu.RUnlock()
@@ -416,37 +324,60 @@ func (q *Quasar) IsFinalized(blockID ids.ID) bool {
func (q *Quasar) GetQuasar() *quasar.Quasar {
q.mu.RLock()
defer q.mu.RUnlock()
return q.quasar
return q.core
}
// GetThreshold returns the consensus threshold
// GetThreshold returns how many validators must sign a block.
func (q *Quasar) GetThreshold() int {
return q.threshold
}
// GetActiveValidators returns the count of active validators
// Committee returns the committee size the threshold was derived from.
func (q *Quasar) Committee() int {
return q.committee
}
// GetActiveValidators returns the count of registered validators.
func (q *Quasar) GetActiveValidators() int {
q.mu.RLock()
defer q.mu.RUnlock()
return q.quasar.GetActiveValidatorCount()
return len(q.validators)
}
// AddValidator adds a new validator to the Quasar consensus
// AddValidator registers a validator with the consensus core. Only a registered
// validator's signature can verify, so this is what decides whose statements
// count toward the quorum.
//
// The committee is a SET, and it is the set the threshold was derived from.
// Registering an id twice hands the core a fresh key for it, which silently
// invalidates every signature that validator has already contributed;
// registering more validators than the committee declares makes the threshold a
// quorum of a committee that no longer exists.
func (q *Quasar) AddValidator(validatorID string, weight uint64) error {
q.mu.Lock()
defer q.mu.Unlock()
_, err := q.quasar.AddValidator(validatorID, weight)
if err != nil {
return fmt.Errorf("failed to add validator: %w", err)
if validatorID == "" {
return errNoValidatorID
}
if _, have := q.validators[validatorID]; have {
return fmt.Errorf("%w: %s", errAlreadyRegistered, validatorID)
}
if len(q.validators) >= q.committee {
return fmt.Errorf("%w: %d of %d", errCommitteeFull, len(q.validators), q.committee)
}
activeCount := q.quasar.GetActiveValidatorCount()
if _, err := q.core.AddValidator(validatorID, weight); err != nil {
return fmt.Errorf("failed to add validator: %w", err)
}
q.validators[validatorID] = struct{}{}
q.log.Info("Validator added to Quasar PQ-BFT",
q.log.Info("validator registered",
"validatorID", validatorID,
"weight", weight,
"activeCount", activeCount,
"registered", len(q.validators),
"committee", q.committee,
"threshold", q.threshold,
)
return nil
@@ -471,8 +402,7 @@ func (q *Quasar) Cleanup(minHeight uint64) {
}
}
// QuasarBridge is an alias for Quasar - the hybrid P/Q consensus bridge
// that connects P-Chain BLS + Q-Chain Corona for dual signature finality
// QuasarBridge is the Q-Chain finality bridge.
type QuasarBridge = Quasar
// QuasarBridgeConfig is an alias for QuasarConfig
+238 -235
View File
@@ -8,137 +8,236 @@ import (
"sync"
"testing"
"github.com/luxfi/chains/quantumvm/config"
"github.com/luxfi/consensus/protocol/quasar"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/stretchr/testify/require"
)
// engine returns a real Quasar with one block awaiting signatures.
func engine(t *testing.T, threshold int) (*Quasar, ids.ID) {
// committee returns a bridge whose core knows every member, plus the block they
// are all signing. Only a registered validator's signature can verify, so the
// roster is what decides whose statement counts.
func committee(t *testing.T, self string, peers ...string) (*Quasar, ids.ID, []byte) {
t.Helper()
q, err := NewQuasar(QuasarConfig{
ValidatorID: "validator-self",
Threshold: threshold,
TotalNodes: 3,
ValidatorID: self,
Committee: 1 + len(peers),
Logger: log.NewNoOpLogger(),
})
require.NoError(t, err)
for _, peer := range peers {
require.NoError(t, q.AddValidator(peer, 100))
}
blockID := ids.GenerateTestID()
q.pendingBlocks[blockID] = &PendingBlock{
BlockID: blockID,
BlockHash: blockID[:],
Height: 1,
BLSSignatures: make([]*quasar.BLSSignature, 0),
CoronaSignatures: make([]*quasar.CoronaSignature, 0),
}
return q, blockID
hash := make([]byte, 64)
copy(hash, blockID[:])
q.pendingBlocks[blockID] = &PendingBlock{BlockID: blockID, BlockHash: hash, Height: 1}
return q, blockID, hash
}
// TestThresholdIsDerivedFromTheCommitteeItGuards.
// signAs produces a real signature from a committee member over a message.
func signAs(t *testing.T, q *Quasar, validatorID string, message []byte) *quasar.QuasarSig {
t.Helper()
sig, err := q.GetQuasar().SignMessageWithContext(context.Background(), validatorID, message)
require.NoError(t, err, "a validator the core knows could not sign")
return sig
}
// TestThresholdFollowsTheCommittee.
//
// The default threshold was computed from TotalNodes before TotalNodes had been
// defaulted, so a config that named neither got a threshold of one — and then a
// committee of three. One validator would have finalized a block alone, on a
// network built to need three.
func TestThresholdIsDerivedFromTheCommitteeItGuards(t *testing.T) {
q, err := NewQuasar(QuasarConfig{Logger: log.NewNoOpLogger()})
require.NoError(t, err)
require.Equal(t, 3, q.totalNodes)
require.Equal(t, 3, q.GetThreshold(),
"a validator could finalize alone on the three-node network this same config assumes")
// The threshold is derived, never configured, so the two cannot disagree — and
// the derivation has to land in the range that is actually a quorum. ⌊2n/3⌋+1
// equals n for every n below four, which is unanimity: zero Byzantine
// tolerance, one absent validator is a halt, and the consensus core refuses it
// outright ("threshold must be less than validator count"). So a committee that
// small is refused where it is named, rather than producing a bridge that
// cannot rotate a key.
func TestThresholdFollowsTheCommittee(t *testing.T) {
for _, n := range []int{1, 2, 3} {
_, err := NewQuasar(QuasarConfig{ValidatorID: "self", Committee: n, Logger: log.NewNoOpLogger()})
require.Error(t, err, "a committee of %d tolerates no fault and was accepted", n)
}
// An explicit threshold is honoured, and an explicit committee is what the
// default is taken from.
q, err = NewQuasar(QuasarConfig{Threshold: 2, TotalNodes: 3, Logger: log.NewNoOpLogger()})
require.NoError(t, err)
require.Equal(t, 2, q.GetThreshold())
for _, tc := range []struct{ committee, threshold int }{
{4, 3}, {5, 4}, {7, 5}, {10, 7}, {100, 67},
} {
q, err := NewQuasar(QuasarConfig{
ValidatorID: "self", Committee: tc.committee, Logger: log.NewNoOpLogger(),
})
require.NoError(t, err)
require.Equal(t, tc.threshold, q.GetThreshold(), "⌊2n/3⌋+1 of %d", tc.committee)
require.Equal(t, tc.committee, q.Committee())
require.Less(t, q.GetThreshold(), q.Committee(), "a quorum of everyone is not a quorum")
require.GreaterOrEqual(t, q.GetThreshold(), 2)
}
q, err = NewQuasar(QuasarConfig{TotalNodes: 10, Logger: log.NewNoOpLogger()})
// An unset committee settles on the smallest that survives a fault.
q, err := NewQuasar(QuasarConfig{ValidatorID: "self"})
require.NoError(t, err)
require.Equal(t, 7, q.GetThreshold(), "⌊2n/3⌋+1 of ten")
require.Equal(t, config.CommitteeMin, q.Committee())
require.Equal(t, config.Quorum(config.CommitteeMin), q.GetThreshold())
// A nil logger is not a reason to crash on the first Debug call.
q, err = NewQuasar(QuasarConfig{TotalNodes: 3})
require.NoError(t, err)
require.NotPanics(t, func() { _ = q.AddValidator("v1", 1) })
}
// TestOneValidatorCannotReachCoronaThreshold proves the post-quantum finality
// leg counts distinct validators.
//
// TryFinalize finalizes the Corona leg on count alone and verifies none of
// those signatures — unlike the BLS leg, which aggregates and verifies. A
// validator resending its signature would drive the count to the threshold on
// its own and satisfy the post-quantum half of quantum finality alone.
func TestOneValidatorCannotReachCoronaThreshold(t *testing.T) {
q, blockID := engine(t, 3)
sig := &quasar.CoronaSignature{Signature: []byte{1}, ValidatorID: "validator-1", Round: 1}
require.NoError(t, q.AddCoronaSignature(blockID, sig))
require.ErrorIs(t, q.AddCoronaSignature(blockID, sig), errDuplicateSigner)
require.ErrorIs(t, q.AddCoronaSignature(blockID, sig), errDuplicateSigner)
// Different bytes under the same name is still one vote.
require.ErrorIs(t, q.AddCoronaSignature(blockID, &quasar.CoronaSignature{
Signature: []byte{9}, ValidatorID: "validator-1", Round: 2,
}), errDuplicateSigner)
pending := q.pendingBlocks[blockID]
require.Len(t, pending.CoronaSignatures, 1)
require.Less(t, len(pending.CoronaSignatures), q.threshold)
_, finalized, err := q.TryFinalize(context.Background(), blockID)
require.NoError(t, err)
require.False(t, finalized)
require.False(t, pending.CoronaFinalized, "the Corona leg was finalized by one validator")
// TestABridgeWithNoIdentityIsRefused: a signer with no name cannot be one of a
// number of distinct signers.
func TestABridgeWithNoIdentityIsRefused(t *testing.T) {
_, err := NewQuasar(QuasarConfig{Committee: 4, Logger: log.NewNoOpLogger()})
require.ErrorIs(t, err, errNoValidatorID)
}
// TestOneValidatorCannotReachBLSThreshold is the same rule on the classical leg.
func TestOneValidatorCannotReachBLSThreshold(t *testing.T) {
q, blockID := engine(t, 3)
// TestTheBridgeSignsForItself.
//
// The node was never registered with its own consensus core, so signing
// answered "validator not found" on every call — and everything downstream
// followed: the failure path deleted the pending entry, so a peer's signature
// arriving afterwards found no block to attach to, so the quorum was never
// reached and finality was unreachable. One missing registration; the whole
// bridge dead, and quiet about it.
func TestTheBridgeSignsForItself(t *testing.T) {
q, err := NewQuasar(QuasarConfig{ValidatorID: "self", Committee: 4, Logger: log.NewNoOpLogger()})
require.NoError(t, err)
sig := &quasar.BLSSignature{Signature: []byte{1}, ValidatorID: "validator-1"}
require.NoError(t, q.AddBLSSignature(blockID, sig))
require.ErrorIs(t, q.AddBLSSignature(blockID, sig), errDuplicateSigner)
blockID := ids.GenerateTestID()
sig, err := q.SignBlock(context.Background(), blockID, blockID[:], 1)
require.NoError(t, err, "the node cannot sign for its own identity")
require.Equal(t, "self", sig.ValidatorID)
require.Len(t, q.pendingBlocks[blockID].BLSSignatures, 1)
pending := q.pendingBlocks[blockID]
require.NotNil(t, pending, "a signed block is not being tracked, so no peer signature can join it")
require.Len(t, pending.Signatures, 1)
// Signing it again is the same statement, not a second signer.
again, err := q.SignBlock(context.Background(), blockID, blockID[:], 1)
require.NoError(t, err)
require.Equal(t, sig, again)
require.Len(t, q.pendingBlocks[blockID].Signatures, 1)
// And a peer's signature now finds the block and joins it.
require.NoError(t, q.AddValidator("peer", 1))
require.NoError(t, q.AddSignature(blockID, signAs(t, q, "peer", pending.BlockHash)))
require.Len(t, q.pendingBlocks[blockID].Signatures, 2)
}
// TestOnlyAVerifiedSignatureCounts.
//
// The quorum was a count of STRINGS: a caller supplied a ValidatorID and it was
// counted, unverified, against the threshold. So three fabricated ids finalized
// a block; five spellings of one name — case, a trailing space, an embedded NUL,
// the fullwidth forms — counted as five distinct signers of one signature; and a
// signature made for block A was accepted onto block B, because nothing bound a
// signature to its message.
//
// Verification fixes all three at once, because the name is looked up in the
// committee and the signature is checked against THAT key over THIS block.
func TestOnlyAVerifiedSignatureCounts(t *testing.T) {
q, blockID, hash := committee(t, "validator-a", "validator-b", "validator-c", "validator-d")
// Names nobody holds a key for.
for _, name := range []string{"ghost-1", "ghost-2", "ghost-3"} {
require.ErrorIs(t, q.AddSignature(blockID, &quasar.QuasarSig{
BLS: []byte("not a signature"), ValidatorID: name,
}), errUnverifiedSigner, "a fabricated validator %q was counted", name)
}
require.Empty(t, q.pendingBlocks[blockID].Signatures)
// One real signature, re-labelled every way a string can be respelled.
real := signAs(t, q, "validator-a", hash)
require.NoError(t, q.AddSignature(blockID, real))
for _, respelling := range []string{
"validator-a ", " validator-a", "Validator-A", "VALIDATOR-A",
"validator-a\x00", "validator-a", "validator-a\n", "validator‑a",
} {
require.ErrorIs(t, q.AddSignature(blockID, &quasar.QuasarSig{
BLS: real.BLS, MLDSA: real.MLDSA, ValidatorID: respelling,
IsThreshold: real.IsThreshold, SignerIndex: real.SignerIndex,
}), errUnverifiedSigner, "%q counted as a second signer of one signature", respelling)
}
// The same name twice is one signer, however the bytes differ.
require.ErrorIs(t, q.AddSignature(blockID, real), errDuplicateSigner)
require.ErrorIs(t, q.AddSignature(blockID, signAs(t, q, "validator-a", hash)), errDuplicateSigner)
require.Len(t, q.pendingBlocks[blockID].Signatures, 1)
// A signature for another block does not count for this one.
other := ids.GenerateTestID()
require.ErrorIs(t, q.AddSignature(blockID, signAs(t, q, "validator-b", other[:])),
errUnverifiedSigner, "a signature made for another block was accepted onto this one")
// Nor does no signature at all.
require.ErrorIs(t, q.AddSignature(blockID, nil), errUnverifiedSigner)
require.Len(t, q.pendingBlocks[blockID].Signatures, 1)
}
// TestFinalityNeedsAnAggregateThatVerifies.
//
// Reaching the count is necessary and not sufficient. The signatures are
// aggregated and the aggregate is checked against the committee's keys, so a
// block finalizes on cryptography rather than on arithmetic.
func TestFinalityNeedsAnAggregateThatVerifies(t *testing.T) {
ctx := context.Background()
q, blockID, hash := committee(t, "validator-a", "validator-b", "validator-c", "validator-d")
require.Equal(t, 3, q.GetThreshold())
// Below the threshold nothing finalizes and nothing errors: it is simply
// not time yet.
for _, name := range []string{"validator-a", "validator-b"} {
require.NoError(t, q.AddSignature(blockID, signAs(t, q, name, hash)))
}
agg, finalized, err := q.TryFinalize(ctx, blockID)
require.NoError(t, err)
require.False(t, finalized, "two of three finalized a block")
require.Nil(t, agg)
require.False(t, q.IsFinalized(blockID))
// The third carries it.
require.NoError(t, q.AddSignature(blockID, signAs(t, q, "validator-c", hash)))
agg, finalized, err = q.TryFinalize(ctx, blockID)
require.NoError(t, err)
require.True(t, finalized, "a verified quorum did not finalize the block")
require.NotNil(t, agg)
require.True(t, q.IsFinalized(blockID))
require.True(t, q.VerifyAggregate(ctx, hash, agg))
require.False(t, q.VerifyAggregate(ctx, []byte("another block"), agg),
"the aggregate verified against a message it does not sign")
// Cleanup below the finalized frontier releases it from both maps.
q.Cleanup(2)
require.NotContains(t, q.pendingBlocks, blockID)
require.False(t, q.IsFinalized(blockID))
}
// TestDistinctValidatorsReachTheCoronaThreshold is the other half: three
// different validators do reach a threshold of three, and the leg finalizes.
func TestDistinctValidatorsReachTheCoronaThreshold(t *testing.T) {
q, blockID := engine(t, 3)
// TestSignaturesThatAreNotSignaturesFinalizeNothing. The set is verified on the
// way in, so bytes that never came from a signer never reach the threshold.
func TestSignaturesThatAreNotSignaturesFinalizeNothing(t *testing.T) {
q, blockID, _ := committee(t, "validator-a", "validator-b", "validator-c", "validator-d")
for _, id := range []string{"validator-1", "validator-2", "validator-3"} {
require.NoError(t, q.AddCoronaSignature(blockID, &quasar.CoronaSignature{
Signature: []byte{1}, ValidatorID: id, Round: 1,
}))
for _, name := range []string{"validator-b", "validator-c", "validator-d"} {
require.ErrorIs(t, q.AddSignature(blockID, &quasar.QuasarSig{
BLS: []byte("not a signature"), ValidatorID: name,
}), errUnverifiedSigner)
}
require.Len(t, q.pendingBlocks[blockID].CoronaSignatures, 3)
require.Empty(t, q.pendingBlocks[blockID].Signatures)
_, finalized, err := q.TryFinalize(context.Background(), blockID)
require.NoError(t, err)
require.True(t, q.pendingBlocks[blockID].CoronaFinalized,
"three distinct validators did not carry the Corona leg")
// Quantum finality needs BOTH legs, and the BLS aggregate over these
// placeholder signatures does not verify, so the block is not final.
require.False(t, finalized, "one leg alone must not be quantum finality")
require.False(t, finalized, "three forged signatures reached the threshold and finalized")
require.False(t, q.IsFinalized(blockID))
}
// TestSignaturesForAnUnknownBlockAreRefused: an id nobody proposed is not a
// place to accumulate signatures a peer chose the id of.
func TestSignaturesForAnUnknownBlockAreRefused(t *testing.T) {
q, _ := engine(t, 3)
q, _, _ := committee(t, "validator-a", "validator-b", "validator-c", "validator-d")
stranger := ids.GenerateTestID()
require.Error(t, q.AddBLSSignature(stranger, &quasar.BLSSignature{ValidatorID: "v1"}))
require.Error(t, q.AddCoronaSignature(stranger, &quasar.CoronaSignature{ValidatorID: "v1"}))
require.ErrorIs(t, q.AddSignature(stranger, &quasar.QuasarSig{ValidatorID: "validator-b"}), errUnknownBlock)
_, _, err := q.TryFinalize(context.Background(), stranger)
require.Error(t, err)
require.ErrorIs(t, err, errUnknownBlock)
}
// TestCleanupReleasesWhatWillNeverFinalize.
@@ -148,7 +247,7 @@ func TestSignaturesForAnUnknownBlockAreRefused(t *testing.T) {
// that accumulate — every proposal that lost, timed out or failed to sign — and
// the two maps grew for the life of the process.
func TestCleanupReleasesWhatWillNeverFinalize(t *testing.T) {
q, err := NewQuasar(QuasarConfig{ValidatorID: "self", TotalNodes: 3, Logger: log.NewNoOpLogger()})
q, err := NewQuasar(QuasarConfig{ValidatorID: "self", Committee: 4, Logger: log.NewNoOpLogger()})
require.NoError(t, err)
for h := uint64(1); h <= 100; h++ {
@@ -172,25 +271,25 @@ func TestCleanupReleasesWhatWillNeverFinalize(t *testing.T) {
// TestSignBlockLeavesNothingBehindWhenItFails: the tracking entry is created
// before signing, so a signing failure must take it away again.
func TestSignBlockLeavesNothingBehindWhenItFails(t *testing.T) {
q, err := NewQuasar(QuasarConfig{ValidatorID: "self", TotalNodes: 3, Logger: log.NewNoOpLogger()})
q, err := NewQuasar(QuasarConfig{ValidatorID: "self", Committee: 4, Logger: log.NewNoOpLogger()})
require.NoError(t, err)
// A cancelled context is the cheapest way to make the signing lanes fail.
// A cancelled context is the cheapest way to make signing fail.
ctx, cancel := context.WithCancel(context.Background())
cancel()
blockID := ids.GenerateTestID()
if _, err := q.SignBlock(ctx, blockID, blockID[:], 1); err != nil {
require.NotContains(t, q.pendingBlocks, blockID,
"a block that failed to sign is tracked forever: it can never finalize, so Cleanup never sees it")
}
_, err = q.SignBlock(ctx, blockID, blockID[:], 1)
require.Error(t, err)
require.NotContains(t, q.pendingBlocks, blockID,
"a block that failed to sign is tracked forever: it can never finalize, so Cleanup never sees it")
}
// TestSignBlockDoesNotRaceIncomingSignatures. Signing appends to the same slice
// AddBLSSignature appends to, and the log line afterwards read its length.
// Under -race that read is a data race against every arriving peer signature.
// AddSignature appends to, and the log line afterwards read its length. Under
// -race that read is a data race against every arriving peer signature.
func TestSignBlockDoesNotRaceIncomingSignatures(t *testing.T) {
q, err := NewQuasar(QuasarConfig{ValidatorID: "self", TotalNodes: 3, Logger: log.NewNoOpLogger()})
q, err := NewQuasar(QuasarConfig{ValidatorID: "self", Committee: 20, Logger: log.NewNoOpLogger()})
require.NoError(t, err)
blockID := ids.GenerateTestID()
@@ -204,21 +303,21 @@ func TestSignBlockDoesNotRaceIncomingSignatures(t *testing.T) {
wg.Add(1)
go func(i int) {
defer wg.Done()
_ = q.AddBLSSignature(blockID, &quasar.BLSSignature{
Signature: []byte{byte(i)}, ValidatorID: string(rune('a' + i)),
_ = q.AddSignature(blockID, &quasar.QuasarSig{
BLS: []byte{byte(i)}, ValidatorID: string(rune('a' + i)),
})
_ = q.IsFinalized(blockID)
q.Cleanup(0)
_ = q.GetActiveValidators()
}(i)
}
wg.Wait()
}
// TestSignBlockAcceptsAnyMessageLength. The Corona lane derives its PRF key
// from the message; taking a 32-byte prefix instead crashed the process on
// anything shorter, and StampBlock hands it whatever the bridge supplies.
// TestSignBlockAcceptsAnyMessageLength. The signer takes whatever the finality
// bridge supplies, which is not always block-hash shaped.
func TestSignBlockAcceptsAnyMessageLength(t *testing.T) {
q, err := NewQuasar(QuasarConfig{ValidatorID: "self", TotalNodes: 3, Logger: log.NewNoOpLogger()})
q, err := NewQuasar(QuasarConfig{ValidatorID: "self", Committee: 4, Logger: log.NewNoOpLogger()})
require.NoError(t, err)
for _, n := range []int{0, 1, 31, 32, 64} {
@@ -229,144 +328,48 @@ func TestSignBlockAcceptsAnyMessageLength(t *testing.T) {
}
}
// TestValidatorsJoinTheCommittee: the active count is what the threshold is
// measured against, so joining has to move it.
func TestValidatorsJoinTheCommittee(t *testing.T) {
q, err := NewQuasar(QuasarConfig{ValidatorID: "self", TotalNodes: 3, Logger: log.NewNoOpLogger()})
// TestTheCommitteeIsASetOfADeclaredSize.
//
// Registering an id twice hands the core a FRESH key for it, which silently
// invalidates every signature that validator already contributed; registering
// more members than the committee declares makes the threshold a quorum of a
// committee that no longer exists.
func TestTheCommitteeIsASetOfADeclaredSize(t *testing.T) {
q, err := NewQuasar(QuasarConfig{ValidatorID: "self", Committee: 4, Logger: log.NewNoOpLogger()})
require.NoError(t, err)
require.Equal(t, 1, q.GetActiveValidators(), "the node is a member of its own committee")
before := q.GetActiveValidators()
require.NoError(t, q.AddValidator("validator-1", 100))
require.Equal(t, before+1, q.GetActiveValidators())
require.Equal(t, 2, q.GetActiveValidators())
require.ErrorIs(t, q.AddValidator("validator-1", 100), errAlreadyRegistered)
require.ErrorIs(t, q.AddValidator("self", 1), errAlreadyRegistered)
require.Equal(t, 2, q.GetActiveValidators(),
"one validator joining twice moved the count the threshold is measured against")
require.NoError(t, q.AddValidator("validator-2", 100))
require.NoError(t, q.AddValidator("validator-3", 100))
require.ErrorIs(t, q.AddValidator("validator-4", 100), errCommitteeFull)
require.ErrorIs(t, q.AddValidator("", 100), errNoValidatorID)
require.Equal(t, 4, q.GetActiveValidators())
require.NotNil(t, q.GetQuasar())
}
// TestTheCoreRefusesAThresholdOfOne. Quantum finality is a quorum's statement;
// a "threshold" one validator meets alone is a single point of compromise, and
// the consensus core will not build one.
func TestTheCoreRefusesAThresholdOfOne(t *testing.T) {
_, err := NewQuasar(QuasarConfig{
ValidatorID: "self", Threshold: 1, TotalNodes: 1, Logger: log.NewNoOpLogger(),
})
require.Error(t, err, "a one-of-one quorum was accepted")
}
// committee returns an engine at threshold 2 whose core knows both validators
// for BLS, and the block they are all signing.
func committee(t *testing.T, self string) (*Quasar, ids.ID, []byte) {
t.Helper()
q, err := NewQuasar(QuasarConfig{
ValidatorID: self, Threshold: 2, TotalNodes: 2, Logger: log.NewNoOpLogger(),
})
require.NoError(t, err)
require.NoError(t, q.AddValidator("validator-a", 100))
require.NoError(t, q.AddValidator("validator-b", 100))
blockID := ids.GenerateTestID()
hash := make([]byte, 64)
copy(hash, blockID[:])
q.pendingBlocks[blockID] = &PendingBlock{BlockID: blockID, BlockHash: hash, Height: 1}
return q, blockID, hash
}
// TestQuantumFinalityNeedsBothLegs is the happy path, with real BLS signatures
// from two validators the core knows.
//
// The classical leg aggregates and VERIFIES; the post-quantum leg is counted.
// Neither alone is quantum finality — the whole point of running two is that a
// break in one is not a break in the chain.
func TestQuantumFinalityNeedsBothLegs(t *testing.T) {
q, blockID, hash := committee(t, "validator-a")
core := q.GetQuasar()
ctx := context.Background()
// Two peers' BLS signatures, as they arrive over the wire.
for _, name := range []string{"validator-a", "validator-b"} {
sig, err := core.SignMessageWithContext(ctx, name, hash)
require.NoError(t, err, "a validator the core knows could not sign")
require.NoError(t, q.AddBLSSignature(blockID, &quasar.BLSSignature{
Signature: sig.BLS,
ValidatorID: sig.ValidatorID,
SignerIndex: sig.SignerIndex,
IsThreshold: sig.IsThreshold,
}))
}
pending := q.pendingBlocks[blockID]
_, finalized, err := q.TryFinalize(ctx, blockID)
require.NoError(t, err)
require.True(t, pending.BLSFinalized, "two real signatures did not carry the classical leg")
require.False(t, finalized, "the classical leg alone was taken for quantum finality")
require.False(t, q.IsFinalized(blockID))
// Now the post-quantum leg, from the same two validators.
for _, name := range []string{"validator-a", "validator-b"} {
require.NoError(t, q.AddCoronaSignature(blockID, &quasar.CoronaSignature{
Signature: []byte{1}, ValidatorID: name, Round: 1,
}))
}
agg, finalized, err := q.TryFinalize(ctx, blockID)
require.NoError(t, err)
require.True(t, finalized, "both legs reached their threshold and the block did not finalize")
require.NotNil(t, agg)
require.True(t, q.IsFinalized(blockID))
// Cleanup below the finalized frontier releases it from both maps.
q.Cleanup(2)
require.NotContains(t, q.pendingBlocks, blockID)
require.False(t, q.IsFinalized(blockID))
}
// TestSignBlockReportsAMissingCoronaShare.
//
// A validator joins through AddValidator, which gives it a BLS key and no
// Corona share — the shares must come from a dealerless DKG, and until one
// lands there is nothing to sign the post-quantum leg with. What matters is
// that SignBlock SAYS so: a node that reported success here would be claiming
// a post-quantum signature it never made.
func TestSignBlockReportsAMissingCoronaShare(t *testing.T) {
q, _, hash := committee(t, "validator-a")
blockID := ids.GenerateTestID()
_, err := q.SignBlock(context.Background(), blockID, hash, 1)
require.ErrorContains(t, err, "Corona sign failed",
"the post-quantum leg reported success without a share to sign with")
require.NotContains(t, q.pendingBlocks, blockID, "the failed block is tracked forever")
}
// TestAggregationOverSignaturesThatAreNotSignatures fails closed. The BLS leg
// aggregates and VERIFIES, so bytes that never came from a signer must not
// carry the classical half of finality.
func TestAggregationOverSignaturesThatAreNotSignatures(t *testing.T) {
q, blockID := engine(t, 3)
for _, id := range []string{"validator-1", "validator-2", "validator-3"} {
require.NoError(t, q.AddBLSSignature(blockID, &quasar.BLSSignature{
Signature: []byte("not a signature"), ValidatorID: id,
}))
}
require.Len(t, q.pendingBlocks[blockID].BLSSignatures, 3)
_, finalized, _ := q.TryFinalize(context.Background(), blockID)
require.False(t, finalized, "three forged signatures reached the threshold and finalized")
require.False(t, q.pendingBlocks[blockID].BLSFinalized)
require.False(t, q.IsFinalized(blockID))
}
// TestAddValidatorRefusesADuplicate: the active count is what the threshold is
// measured against, so one validator joining twice would count twice.
func TestAddValidatorRefusesADuplicate(t *testing.T) {
q, err := NewQuasar(QuasarConfig{ValidatorID: "self", TotalNodes: 3, Logger: log.NewNoOpLogger()})
require.NoError(t, err)
require.NoError(t, q.AddValidator("validator-1", 100))
after := q.GetActiveValidators()
if err := q.AddValidator("validator-1", 100); err != nil {
require.Equal(t, after, q.GetActiveValidators())
return
}
require.Equal(t, after, q.GetActiveValidators(),
"one validator joining twice moved the count the threshold is measured against")
// TestARegisteredKeyIsWhatVerifies: the bridge's own verification helpers agree
// with what AddSignature admits, so nothing can be verified one way in one place
// and another way in another.
func TestARegisteredKeyIsWhatVerifies(t *testing.T) {
q, _, hash := committee(t, "validator-a", "validator-b", "validator-c", "validator-d")
sig := signAs(t, q, "validator-b", hash)
require.True(t, q.VerifySignature(hash, sig))
require.False(t, q.VerifySignature([]byte("another message"), sig))
require.False(t, q.VerifySignature(hash, nil))
require.False(t, q.VerifyAggregate(context.Background(), hash, nil))
relabelled := *sig
relabelled.ValidatorID = "validator-c"
require.False(t, q.VerifySignature(hash, &relabelled),
"a signature relabelled to another committee member verified")
}
+10 -10
View File
@@ -220,10 +220,15 @@ type TransactionWorker struct {
quantumSigner *quantum.QuantumSigner
}
// ProcessBatch verifies and executes a batch, reporting what survived and what
// did not. Both halves matter: the survivors go in the block, and the rest have
// to leave the pool — a transaction whose quantum stamp has aged out will never
// verify again, and left in place it holds its slot for good.
// ProcessBatch VERIFIES a batch, reporting what survived and what did not. Both
// halves matter: the survivors go in the block, and the rest have to leave the
// pool — a transaction whose quantum stamp has aged out will never verify again,
// and left in place it holds its slot for good.
//
// It does not execute anything. Effects belong to Accept, on every node, once:
// running them here ran them on a block the network may never accept, ran them
// twice when the builder rebuilt, and ran them nowhere at all on a node that
// received the block rather than building it.
//
// Uses GPU batch ML-DSA verification when available and the batch is large enough.
func (w *TransactionWorker) ProcessBatch(txs []Transaction) (valid, rejected []Transaction) {
@@ -274,18 +279,13 @@ func (w *TransactionWorker) ProcessBatch(txs []Transaction) (valid, rejected []T
}
}
// Phase 3: execute valid transactions
// Phase 3: separate what verified from what did not
valid = make([]Transaction, 0, len(verified))
for i, tx := range verified {
if !sigValid[i] {
rejected = append(rejected, tx)
continue
}
if err := tx.Execute(); err != nil {
w.vm.log.Debug("transaction execution failed", "txID", tx.ID(), "error", err)
rejected = append(rejected, tx)
continue
}
valid = append(valid, tx)
}
+26 -12
View File
@@ -7,6 +7,7 @@ import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
@@ -206,23 +207,35 @@ func TestProcessBatchWithStampsOffSkipsCrypto(t *testing.T) {
require.Len(t, rejected, 1)
}
// TestProcessBatchDropsWhatCannotExecute.
func TestProcessBatchDropsWhatCannotExecute(t *testing.T) {
// TestProcessBatchAppliesNothing.
//
// Selecting transactions for a block is not applying them. It ran them here, so
// a block the network never accepted had already applied its effects, a rebuild
// applied them twice, and a node that received the block rather than building it
// applied them never. Selection asks whether a signature checks out; nothing
// more.
func TestProcessBatchAppliesNothing(t *testing.T) {
vm, _ := bootVM(t, quietConfig())
worker := &TransactionWorker{vm: vm, quantumSigner: vm.quantumSigner}
valid, rejected := worker.ProcessBatch([]Transaction{
stampedTx(1, "runs"),
&failingTx{BaseTransaction: *stampedTx(2, "throws")},
})
require.Len(t, valid, 1)
require.Len(t, rejected, 1)
var runs int32
refusing := countingTx{
BaseTransaction: stampedTx(2, "would refuse"),
runs: &runs,
fail: errors.New("execution refused"),
}
valid, rejected := worker.ProcessBatch([]Transaction{stampedTx(1, "runs"), refusing})
require.Len(t, valid, 2, "a transaction was dropped for a reason selection cannot know")
require.Empty(t, rejected)
require.Zero(t, atomic.LoadInt32(&runs), "selecting a transaction applied it")
// The same transaction is refused where it is applied — in Accept.
blk := buildWith(t, vm, refusing)
require.ErrorIs(t, blk.Accept(context.Background()), errExecute)
require.Equal(t, int32(1), atomic.LoadInt32(&runs))
}
type failingTx struct{ BaseTransaction }
func (t *failingTx) Execute() error { return errors.New("execution refused") }
// TestBuildBlockSpansSeveralBatches: the batch size divides the selection, and
// every batch's survivors reach the block.
func TestBuildBlockSpansSeveralBatches(t *testing.T) {
@@ -256,4 +269,5 @@ func TestTransactionIdentityIsItsContent(t *testing.T) {
require.Equal(t, chainTime, base.Timestamp())
require.NotNil(t, base.GetQuantumSignature())
require.NoError(t, base.Execute())
require.NoError(t, base.Verify())
}
+190 -73
View File
@@ -37,6 +37,11 @@ var (
errVMShutdown = errors.New("quantumvm: VM is shutting down")
errParallelProcessingFailed = errors.New("quantumvm: no pending transaction survived verification")
errNoBlockAtHeight = errors.New("quantumvm: no block at height")
errNoIdentity = errors.New("quantumvm: the node has no identity to sign under")
errTipUnreadable = errors.New("quantumvm: the chain tip cannot be read")
errNotTheTip = errors.New("quantumvm: block does not extend the tip")
errClockBehindTip = errors.New("quantumvm: the node's clock trails its own tip beyond the skew allowance")
errNoStamp = errors.New("quantumvm: no stamp to check")
// Compile-time check that *VM satisfies chain.ChainVM (= block.ChainVM)
// AND the consensus engine's BlockBuilder. Together these prove Q-Chain
@@ -124,20 +129,25 @@ func (vm *VM) Initialize(ctx context.Context, init luxvm.Init) error {
}
// The node's own identity, which is what a validator signature is
// attributed to. Without it every node on the chain signs under the same
// name — see the ValidatorID note below.
nodeID := ids.EmptyNodeID
if init.Runtime != nil {
vm.NetworkID = init.Runtime.NetworkID
vm.blockchainID = init.Runtime.ChainID
nodeID = init.Runtime.NodeID
// attributed to, and the chain it is serving, which is what its blocks
// belong to. A VM with neither runs, signs as nobody and produces blocks
// every other Q-Chain will also accept — so it does not run.
if init.Runtime == nil || init.Runtime.NodeID == ids.EmptyNodeID {
return errNoIdentity
}
vm.NetworkID = init.Runtime.NetworkID
vm.blockchainID = init.Runtime.ChainID
nodeID := init.Runtime.NodeID
vm.quantumSigner = quantum.NewQuantumSigner(
signer, err := quantum.NewQuantumSigner(
vm.log,
vm.Config.QuantumAlgorithmVersion,
vm.Config.QuantumStampWindow,
)
if err != nil {
return fmt.Errorf("quantumvm: signer: %w", err)
}
vm.quantumSigner = signer
vm.txPool = NewTransactionPool(vm.Config.MaxParallelTxs, vm.log)
@@ -170,14 +180,14 @@ func (vm *VM) Initialize(ctx context.Context, init luxvm.Init) error {
return fmt.Errorf("failed to initialize HTTP handlers: %w", err)
}
// ValidatorID is the NODE's id, not the chain's. Both signature legs count
// DISTINCT ValidatorIDs against the threshold, so naming the chain here
// gave every node on Q-Chain the same signer identity: each peer's
// signature arrived as a duplicate of the first, the count never passed
// one, and quantum finality was unreachable on any threshold above 1.
// ValidatorID is the NODE's id, not the chain's. The threshold counts
// DISTINCT ValidatorIDs, so naming the chain here gave every node on
// Q-Chain the same signer identity: each peer's signature arrived as a
// duplicate of the first, the count never passed one, and quantum finality
// was unreachable on any threshold above 1.
quasarBridge, err := NewQuasarBridge(QuasarBridgeConfig{
ValidatorID: nodeID.String(),
TotalNodes: 3, // Default 3-node network, can be updated
Committee: vm.Config.Committee,
Logger: vm.log,
})
if err != nil {
@@ -188,6 +198,7 @@ func (vm *VM) Initialize(ctx context.Context, init luxvm.Init) error {
vm.log.Info("QVM initialized",
"quantumStamps", vm.Config.QuantumStampEnabled,
"corona", vm.Config.CoronaEnabled,
"committee", quasarBridge.Committee(),
"threshold", quasarBridge.GetThreshold(),
"maxParallel", vm.Config.MaxParallelTxs,
)
@@ -240,16 +251,27 @@ func (vm *VM) buildBlock() (*Block, error) {
return nil, errParallelProcessingFailed
}
parentID := vm.getLastAcceptedID()
parentID, err := vm.tip()
if err != nil {
return nil, err
}
parent, err := vm.blockAt(parentID)
if err != nil {
return nil, fmt.Errorf("quantumvm: read tip %s: %w", parentID, err)
}
// Never stamp behind the parent. Verify rejects a block that moves chain
// time backwards, so a node whose clock trails its own tip would otherwise
// build blocks that it — and every peer — refuses.
timestamp := vm.clock.Time()
// Never stamp behind the parent, and never stamp where Verify will refuse
// it. A clock that trails the tip by less than the skew allowance is a peer
// that ran fast, and clamping forward covers it. A clock that trails by
// MORE is this node's clock being wrong: every block it could build now
// carries a timestamp its own Verify rejects for exceeding now+skew, so it
// says so rather than producing blocks nobody — itself included — accepts.
now := vm.clock.Time()
if parent.timestamp.After(now.Add(MaxFutureSkew)) {
return nil, fmt.Errorf("%w: tip is stamped %s, this node reads %s",
errClockBehindTip, parent.timestamp, now)
}
timestamp := now
if timestamp.Before(parent.timestamp) {
timestamp = parent.timestamp
}
@@ -261,9 +283,14 @@ func (vm *VM) buildBlock() (*Block, error) {
timestamp: timestamp,
height: parent.height + 1,
parentID: parentID,
chainID: vm.blockchainID,
networkID: vm.NetworkID,
transactions: validTxs,
vm: vm,
}
if len(block.Bytes()) > MaxBlockSize {
return nil, fmt.Errorf("%w: %d bytes over %d", errBlockTooLarge, len(block.Bytes()), MaxBlockSize)
}
block.id = block.computeID()
vm.log.Debug("built block",
@@ -341,9 +368,9 @@ func (vm *VM) Shutdown(ctx context.Context) error {
return nil
}
// processTransactionsParallel verifies and executes the batch, reporting both
// what survived and what did not. The caller needs both: the survivors go in
// the block, and the rest have to leave the pool.
// processTransactionsParallel verifies the batch, reporting both what survived
// and what did not. The caller needs both: the survivors go in the block, and
// the rest have to leave the pool.
func (vm *VM) processTransactionsParallel(txs []Transaction) (valid, rejected []Transaction) {
batchSize := vm.Config.ParallelBatchSize
@@ -395,17 +422,43 @@ func (vm *VM) signBlockWithQuasar(block *Block) {
vm.log.Warn("quasar block signing failed", "blockID", block.ID(), "error", err)
return
}
vm.log.Debug("block signed with quasar BLS threshold", "blockID", block.ID(), "height", block.Height())
vm.log.Debug("block signed with quasar threshold signature", "blockID", block.ID(), "height", block.Height())
}
// commitBlock stores the block, indexes it by height and moves the tip — as one
// versiondb commit. A failure anywhere aborts the whole set, so the pointers can
// never name a block the store does not hold. Caller holds vm.lock.
// commitBlock makes a block the tip: it admits the block, applies it, stores
// it, indexes it by height and moves the tip pointer. Caller holds vm.lock.
//
// The commit is what makes the write durable. Buffered in the version layer and
// never committed, an accepted block is gone at the next start and the node
// rewinds to genesis, having told its peers it held a tip it cannot serve.
// One order, one place, so a block cannot be applied without being admitted or
// stored without being applied. Everything that advances this chain — Accept and
// the genesis seed — comes through here.
//
// A commit EXTENDS the tip: the block names the last-accepted block as its
// parent and sits one height above it. Without that, any block that merely
// verified could be committed — a verified sibling of an old block rewound the
// chain to its height, left every height above indexed to an abandoned branch,
// and served those to bootstrapping peers as canonical. Re-accepting a block
// already accepted did the same, and two siblings both verified, both committed,
// the second silently replacing the first.
//
// The writes then land as ONE versiondb commit: block, height index and tip
// pointer move together or not at all, so a node never restarts holding a tip
// pointer to a block it did not store. And the version layer holds nothing
// across this call: staged writes not discarded here are not discarded at all,
// they are flushed by the next commit that succeeds — an orphan block and a live
// height index arriving with an unrelated block.
func (vm *VM) commitBlock(b *Block) error {
defer vm.state.Abort()
if err := b.onThisChain(); err != nil {
return err
}
if err := vm.extendsTip(b); err != nil {
return err
}
if err := b.apply(); err != nil {
return err
}
heightBytes := make([]byte, 8)
binary.BigEndian.PutUint64(heightBytes, b.height)
@@ -418,17 +471,41 @@ func (vm *VM) commitBlock(b *Block) error {
{tipHeightKey, heightBytes},
} {
if err := vm.state.Put(w.key, w.value); err != nil {
vm.state.Abort()
return fmt.Errorf("quantumvm: stage block %s: %w", b.id, err)
}
}
if err := vm.state.Commit(); err != nil {
vm.state.Abort()
return fmt.Errorf("quantumvm: commit block %s: %w", b.id, err)
}
return nil
}
// extendsTip is the linear-chain invariant: exactly one block may follow the
// one this node last accepted. An empty chain's tip is no block at no height,
// and the only block that follows it is genesis.
func (vm *VM) extendsTip(b *Block) error {
tip, err := vm.tip()
if err != nil {
return err
}
if b.parentID != tip {
return fmt.Errorf("%w: parent %s, tip %s", errNotTheTip, b.parentID, tip)
}
next := uint64(0)
if tip != ids.Empty {
h, err := vm.tipHeight()
if err != nil {
return err
}
next = h + 1
}
if b.height != next {
return fmt.Errorf("%w: height %d, tip expects %d", errNotTheTip, b.height, next)
}
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.
//
@@ -441,11 +518,16 @@ func (vm *VM) commitBlock(b *Block) error {
// 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.
// The block is a constant of the chain it belongs to, so every node on that
// chain computes one id alone: fixed timestamp, height 0, empty parent, no
// transactions, this chain and this network. 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 {
tip, err := vm.tip()
if err != nil {
return err
}
if tip != ids.Empty {
return nil
}
@@ -453,6 +535,8 @@ func (vm *VM) seedGenesis() error {
timestamp: time.Unix(0, 0).UTC(),
height: 0,
parentID: ids.Empty,
chainID: vm.blockchainID,
networkID: vm.NetworkID,
vm: vm,
}
block.id = block.computeID()
@@ -484,24 +568,49 @@ func (vm *VM) isShuttingDown() bool {
return vm.shuttingDown
}
// getHeight returns current blockchain height
func (vm *VM) getHeight() uint64 {
heightBytes, err := vm.state.Get(tipHeightKey)
if err != nil || len(heightBytes) != 8 {
return 0
// tipHeight is the height of the last accepted block, or 0 on a chain that
// holds only genesis.
//
// A read that failed and a height of zero are different facts and this reports
// them differently. Answering 0 for both is what let one transient failure look
// like a chain that had not started: the next block was built at height 1 on a
// chain a thousand blocks long.
func (vm *VM) tipHeight() (uint64, error) {
raw, err := vm.state.Get(tipHeightKey)
if errors.Is(err, database.ErrNotFound) {
return 0, nil
}
return binary.BigEndian.Uint64(heightBytes)
if err != nil {
return 0, fmt.Errorf("%w: height: %w", errTipUnreadable, err)
}
if len(raw) != 8 {
return 0, fmt.Errorf("%w: height holds %d bytes", errTipUnreadable, len(raw))
}
return binary.BigEndian.Uint64(raw), nil
}
// getLastAcceptedID returns the last accepted block ID
func (vm *VM) getLastAcceptedID() ids.ID {
lastAcceptedBytes, err := vm.state.Get(lastAcceptedKey)
if err != nil || len(lastAcceptedBytes) != 32 {
return ids.Empty
// tip is the id of the last accepted block. It answers ids.Empty with no error
// for exactly one reason — the chain holds no block at all — and an error for
// every other reason it could not read one.
//
// Collapsing those was how a single failed read destroyed a chain: seedGenesis
// reads ids.Empty as "fresh chain" and writes genesis, so one transient error at
// boot committed genesis over a live tip and Initialize returned success. A
// short read did it identically.
func (vm *VM) tip() (ids.ID, error) {
raw, err := vm.state.Get(lastAcceptedKey)
if errors.Is(err, database.ErrNotFound) {
return ids.Empty, nil
}
if err != nil {
return ids.Empty, fmt.Errorf("%w: last accepted: %w", errTipUnreadable, err)
}
if len(raw) != 32 {
return ids.Empty, fmt.Errorf("%w: last accepted holds %d bytes", errTipUnreadable, len(raw))
}
var id ids.ID
copy(id[:], lastAcceptedBytes)
return id
copy(id[:], raw)
return id, nil
}
// Version returns the version of the VM
@@ -558,7 +667,7 @@ func (vm *VM) NewHTTPHandler(ctx context.Context) (http.Handler, error) {
}
// SetPreference sets the preferred block. Implements chain.ChainVM.
// Q-Chain uses BLS+Corona threshold finality rather than preference,
// Q-Chain finalizes on a verified threshold signature rather than preference,
// so this is a no-op until preference-based fork choice is wired in.
func (vm *VM) SetPreference(ctx context.Context, blockID ids.ID) error {
return nil
@@ -568,7 +677,7 @@ func (vm *VM) SetPreference(ctx context.Context, blockID ids.ID) error {
func (vm *VM) LastAccepted(ctx context.Context) (ids.ID, error) {
vm.lock.RLock()
defer vm.lock.RUnlock()
return vm.getLastAcceptedID(), nil
return vm.tip()
}
// GetBlockIDAtHeight answers what block this node accepted at a height, from
@@ -599,30 +708,29 @@ func (vm *VM) WaitForEvent(ctx context.Context) (luxvm.Message, error) {
return vm.txPool.WaitForEvent(ctx)
}
// GetQuasarBridge returns the Quasar hybrid consensus bridge
// This provides BLS + Corona dual threshold signatures for PQ finality
// GetQuasarBridge returns the Quasar threshold-signature bridge.
func (vm *VM) GetQuasarBridge() *QuasarBridge {
vm.lock.RLock()
defer vm.lock.RUnlock()
return vm.quasarBridge
}
// StampBlock implements QChainStamper for hybrid finality: a Quasar BLS+Corona
// threshold signature when the bridge is up, an ML-DSA signature otherwise.
// StampBlock attests to a message for the finality bridge: a Quasar validator
// signature when the bridge is up, an ML-DSA signature otherwise.
func (vm *VM) StampBlock(blockID ids.ID, pChainHeight uint64, message []byte) (interface{}, error) {
ctx := context.Background()
if bridge := vm.GetQuasarBridge(); bridge != nil && blockID != ids.Empty {
hybridSig, err := bridge.SignBlock(ctx, blockID, message, pChainHeight)
sig, err := bridge.SignBlock(ctx, blockID, message, pChainHeight)
if err == nil {
vm.log.Info("Quasar BLS stamp created",
vm.log.Info("Quasar stamp created",
"blockID", blockID,
"pChainHeight", pChainHeight,
"threshold", bridge.GetThreshold(),
)
return hybridSig, nil
return sig, nil
}
vm.log.Warn("Quasar BLS stamp failed, using ML-DSA fallback", "error", err)
vm.log.Warn("Quasar stamp failed, using ML-DSA fallback", "error", err)
}
key, err := vm.quantumSigner.GenerateCoronaKey()
@@ -641,33 +749,42 @@ func (vm *VM) StampBlock(blockID ids.ID, pChainHeight uint64, message []byte) (i
return sig, nil
}
// VerifyStamp implements QChainStamper for quasar finality
// Supports both Quasar QuasarSignature and ML-DSA QuantumSignature
func (vm *VM) VerifyStamp(stamp interface{}) error {
// VerifyStamp checks a stamp against the message it claims to attest.
//
// The message is the argument that makes this a verification. Without it there
// was nothing to check a signature against, so each arm could only look at the
// stamp's own shape — and shape is what the sender chose: a two-byte aggregate
// declaring three signers passed, and so did a one-byte BLS signature. A
// self-declared signer count is not evidence of anything.
func (vm *VM) VerifyStamp(message []byte, stamp interface{}) error {
bridge := vm.GetQuasarBridge()
switch s := stamp.(type) {
case *quasar.QuasarSignature:
if s.BLS == nil || len(s.BLS.Signature) == 0 {
return errors.New("quantumvm: invalid Quasar BLS signature")
case *quasar.QuasarSig:
if bridge == nil {
return errors.New("quantumvm: no Quasar bridge to check the signature against")
}
if !bridge.VerifySignature(message, s) {
return fmt.Errorf("quantumvm: Quasar signature from %s does not check out", s.ValidatorID)
}
return nil
case *quasar.AggregatedSignature:
bridge := vm.GetQuasarBridge()
if bridge == nil {
return errors.New("quantumvm: no Quasar bridge to check the threshold against")
return errors.New("quantumvm: no Quasar bridge to check the aggregate against")
}
if len(s.BLSAggregated) == 0 || s.SignerCount < bridge.GetThreshold() {
return errors.New("quantumvm: insufficient aggregated signature")
if !bridge.VerifyAggregate(context.Background(), message, s) {
return errors.New("quantumvm: aggregated signature does not check out")
}
return nil
case *quantum.QuantumSignature:
if len(s.Signature) == 0 || len(s.QuantumStamp) == 0 {
return errors.New("quantumvm: invalid quantum stamp structure")
}
return nil
return vm.quantumSigner.Verify(message, s)
case nil:
return errNoStamp
default:
return errors.New("quantumvm: unsupported stamp type")
return fmt.Errorf("quantumvm: unsupported stamp type %T", stamp)
}
}
+210 -50
View File
@@ -17,10 +17,18 @@ import (
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/node/vms/types/fee"
"github.com/luxfi/runtime"
luxvm "github.com/luxfi/vm"
"github.com/stretchr/testify/require"
)
// testRuntime is the identity the node hands the VM: one chain, one node.
func testRuntime() *runtime.Runtime {
return &runtime.Runtime{
NetworkID: testNetwork, ChainID: testChain, NodeID: ids.GenerateTestNodeID(),
}
}
// TestAnEmptyConfigStartsAUsableVM. The config is normalised in exactly one
// place — Initialize — so a VM built by the factory and one built by hand start
// from the same rules, and neither comes up batching nothing.
@@ -31,17 +39,23 @@ func TestAnEmptyConfigStartsAUsableVM(t *testing.T) {
require.True(t, ok)
require.NoError(t, vm.Initialize(context.Background(), luxvm.Init{
DB: memdb.New(),
Log: log.NewNoOpLogger(),
DB: memdb.New(),
Log: log.NewNoOpLogger(),
Runtime: testRuntime(),
}))
t.Cleanup(func() { _ = vm.Shutdown(context.Background()) })
require.Positive(t, vm.Config.MaxParallelTxs)
require.Positive(t, vm.Config.ParallelBatchSize)
require.Equal(t, config.AlgorithmDefault, vm.Config.QuantumAlgorithmVersion)
require.Equal(t, config.CommitteeMin, vm.Config.Committee)
// No Runtime was supplied, and the VM still starts — with a tip.
require.NotEqual(t, ids.Empty, vm.getLastAcceptedID())
// The settled config and the stock one agree on the parameter set. Two
// spellings of the default is a chain whose algorithm depends on which door
// its operator came through.
require.Equal(t, config.DefaultConfig().QuantumAlgorithmVersion, vm.Config.QuantumAlgorithmVersion)
require.NotEqual(t, ids.Empty, tipOf(t, vm))
// And it builds, verifies and accepts on those settled values.
vm.clock.Set(chainTime)
@@ -60,44 +74,82 @@ func TestInitializeRefusesAnAlgorithmThatDoesNotExist(t *testing.T) {
cfg.QuantumAlgorithmVersion = 42
vm := &VM{Config: cfg}
require.Error(t, vm.Initialize(context.Background(), luxvm.Init{
DB: memdb.New(), Log: log.NewNoOpLogger(),
DB: memdb.New(), Log: log.NewNoOpLogger(), Runtime: testRuntime(),
}))
}
// TestInitializeRefusesACommitteeThatSurvivesNoFault. The threshold is derived
// from the committee, and ⌊2n/3⌋+1 is unanimity for every n below four: one
// absent validator halts the chain, one dishonest validator decides it, and the
// consensus core will not even build the key set for it.
func TestInitializeRefusesACommitteeThatSurvivesNoFault(t *testing.T) {
for _, n := range []int{1, 2, 3} {
cfg := config.DefaultConfig()
cfg.Committee = n
vm := &VM{Config: cfg}
require.Error(t, vm.Initialize(context.Background(), luxvm.Init{
DB: memdb.New(), Log: log.NewNoOpLogger(), Runtime: testRuntime(),
}), "a committee of %d was accepted", n)
}
}
// TestInitializeWithoutALoggerDoesNotPanic: Initialize accepts a nil Log, so it
// has to survive one. Every path out of Initialize logs.
func TestInitializeWithoutALoggerDoesNotPanic(t *testing.T) {
vm := &VM{Config: config.DefaultConfig()}
require.NoError(t, vm.Initialize(context.Background(), luxvm.Init{DB: memdb.New()}))
require.NoError(t, vm.Initialize(context.Background(), luxvm.Init{
DB: memdb.New(), Runtime: testRuntime(),
}))
require.NoError(t, vm.Shutdown(context.Background()))
}
// TestInitializeRefusesANodeWithNoIdentity.
//
// Without a runtime the VM took the EMPTY node id and started anyway, so every
// node that came up that way signed under one shared name: their signatures
// arrived as duplicates of each other and no threshold above one could be met.
// A node that cannot say who it is cannot be one of a number of distinct
// signers, so it does not start.
func TestInitializeRefusesANodeWithNoIdentity(t *testing.T) {
for _, rt := range []*runtime.Runtime{
nil,
{NetworkID: testNetwork, ChainID: testChain},
{NetworkID: testNetwork, ChainID: testChain, NodeID: ids.EmptyNodeID},
} {
vm := &VM{Config: config.DefaultConfig()}
require.ErrorIs(t, vm.Initialize(context.Background(), luxvm.Init{
DB: memdb.New(), Log: log.NewNoOpLogger(), Runtime: rt,
}), errNoIdentity)
}
}
// TestEveryNodeSignsUnderItsOwnName.
//
// Both finality legs count DISTINCT validator ids against the threshold, so the
// id has to name the node. It named the CHAIN — a field that was never even
// assigned, so every Q-Chain node in the world signed as the empty id. Each
// peer's signature arrived as a duplicate of the first, the count never passed
// one, and no threshold above one could ever be met.
// The threshold counts DISTINCT validator ids, so the id has to name the NODE.
// It named the CHAIN — which every node of a chain shares, by definition — so
// each peer's signature arrived as a duplicate of the first, the count never
// passed one, and no threshold above one could ever be met.
//
// The three VMs below share ONE chain id and hold three node ids, which is the
// only shape production ever has. Giving each test VM its own chain id varied
// the wrong field: under that shape the broken code prints three different
// names too, and the test passes while the fleet cannot finalize anything.
func TestEveryNodeSignsUnderItsOwnName(t *testing.T) {
a, _ := bootVM(t, quietConfig())
b, _ := bootVM(t, quietConfig())
c, _ := bootVM(t, quietConfig())
idA := a.GetQuasarBridge().validatorID
idB := b.GetQuasarBridge().validatorID
require.Equal(t, a.blockchainID, b.blockchainID, "precondition: one chain")
require.Equal(t, a.blockchainID, c.blockchainID, "precondition: one chain")
require.NotEqual(t, ids.EmptyNodeID.String(), idA, "the node signs as nobody")
require.NotEqual(t, idA, idB, "two nodes sign under one name, so their signatures cancel out")
// What that costs, concretely: their two signatures must count as two.
blockID := ids.GenerateTestID()
q, err := NewQuasar(QuasarConfig{ValidatorID: idA, TotalNodes: 3, Logger: log.NewNoOpLogger()})
require.NoError(t, err)
q.pendingBlocks[blockID] = &PendingBlock{BlockID: blockID, BlockHash: blockID[:], Height: 1}
require.NoError(t, q.AddBLSSignature(blockID, &quasar.BLSSignature{Signature: []byte{1}, ValidatorID: idA}))
require.NoError(t, q.AddBLSSignature(blockID, &quasar.BLSSignature{Signature: []byte{2}, ValidatorID: idB}))
require.Len(t, q.pendingBlocks[blockID].BLSSignatures, 2)
names := map[string]bool{}
for _, vm := range []*VM{a, b, c} {
id := vm.GetQuasarBridge().validatorID
require.NotEqual(t, ids.EmptyNodeID.String(), id, "the node signs as nobody")
require.NotEqual(t, vm.blockchainID.String(), id, "the node signs under the chain's name")
names[id] = true
}
require.Len(t, names, 3, "three nodes of one chain signed under fewer than three names")
}
// TestBuildBlockRefusesAnEmptyMempool: a block with nothing in it costs a round
@@ -166,6 +218,55 @@ func TestBuildBlockStampsNoEarlierThanItsParent(t *testing.T) {
"a node built a block it will not itself verify")
}
// TestBuildBlockRefusesWhenItsClockTrailsTheTipTooFar.
//
// The two halves of the clock rule contradicted each other. Clamping the
// timestamp forward to the parent's keeps a slightly slow node building; but a
// node whose clock trails the tip by MORE than the skew allowance clamps to a
// value its own Verify then rejects for exceeding now+skew. The VM built a block
// and immediately refused it, over and over. The node's clock is what is wrong,
// so the node says so.
func TestBuildBlockRefusesWhenItsClockTrailsTheTipTooFar(t *testing.T) {
vm, _ := bootVM(t, quietConfig())
parent := advance(t, vm, 1)
// Exactly at the allowance still builds, and still verifies.
vm.clock.Set(parent.timestamp.Add(-MaxFutureSkew))
blk := buildOn(t, vm)
require.NoError(t, blk.Verify(context.Background()))
require.NoError(t, blk.Accept(context.Background()))
// One second past it, the node refuses rather than producing a block it
// will not itself accept.
vm.clock.Set(blk.timestamp.Add(-MaxFutureSkew - time.Second))
require.NoError(t, vm.txPool.AddTransaction(stampedTx(77, "op")))
_, err := vm.BuildBlock(context.Background())
require.ErrorIs(t, err, errClockBehindTip)
}
// TestABlockTooLargeToHoldIsRefusedEverywhere.
//
// Nothing bounded a block anywhere: parse, verify and commit each walked
// whatever arrived, so a peer decided how much memory this node allocated and
// how much its store held.
func TestABlockTooLargeToHoldIsRefusedEverywhere(t *testing.T) {
vm, _ := bootVM(t, quietConfig())
genesis, err := vm.blockAt(tipOf(t, vm))
require.NoError(t, err)
huge := blockOn(vm, genesis, stampedTx(1, string(make([]byte, MaxBlockSize))))
require.Greater(t, len(huge.Bytes()), MaxBlockSize, "precondition: over the bound")
_, err = vm.ParseBlock(context.Background(), huge.Bytes())
require.ErrorIs(t, err, errBlockTooLarge)
require.ErrorIs(t, huge.Verify(context.Background()), errBlockTooLarge)
// And the builder will not produce one either.
require.NoError(t, vm.txPool.AddTransaction(stampedTx(2, string(make([]byte, MaxBlockSize)))))
_, err = vm.BuildBlock(context.Background())
require.ErrorIs(t, err, errBlockTooLarge)
}
// TestBuildBlockRefusesDuringShutdown.
func TestBuildBlockRefusesDuringShutdown(t *testing.T) {
vm, _ := bootVM(t, quietConfig())
@@ -189,7 +290,7 @@ func TestShutdownIsIdempotent(t *testing.T) {
}
// TestAcceptOnAClosedStoreFailsRatherThanClaimingSuccess: after Shutdown the
// version layer is closed, so staging a write cannot succeed — and Accept must
// version layer is closed, so nothing can be read or staged — and Accept must
// say so instead of reporting a block it did not store.
func TestAcceptOnAClosedStoreFailsRatherThanClaimingSuccess(t *testing.T) {
vm, _ := bootVM(t, quietConfig())
@@ -204,7 +305,7 @@ func TestAcceptOnAClosedStoreFailsRatherThanClaimingSuccess(t *testing.T) {
// disagree.
func TestHeightIndexAnswersEveryAcceptedHeight(t *testing.T) {
vm, _ := bootVM(t, quietConfig())
accepted := []ids.ID{vm.getLastAcceptedID()}
accepted := []ids.ID{tipOf(t, vm)}
for i := 0; i < 4; i++ {
accepted = append(accepted, advance(t, vm, 1).id)
}
@@ -259,6 +360,7 @@ func TestParseBlockRoundTripsWhatBuildBlockProduced(t *testing.T) {
require.NoError(t, err)
require.Equal(t, blk.id, parsed.ID())
require.Equal(t, blk.height, parsed.Height())
require.Len(t, parsed.(*Block).transactions, len(blk.transactions))
_, err = vm.ParseBlock(context.Background(), []byte("garbage"))
require.Error(t, err)
@@ -331,7 +433,7 @@ func TestVMLifecycleCallbacks(t *testing.T) {
// TestStampBlockSurvivesAShortMessage.
//
// The Corona leg used the first 32 bytes of whatever it was handed as a PRF
// The signing lane used the first 32 bytes of whatever it was handed as a PRF
// key. StampBlock takes an arbitrary message from the finality bridge, so any
// message under 32 bytes took the slice out of range and killed the process.
func TestStampBlockSurvivesAShortMessage(t *testing.T) {
@@ -344,6 +446,22 @@ func TestStampBlockSurvivesAShortMessage(t *testing.T) {
}
}
// TestStampBlockSignsWithTheNodesOwnKey, and the stamp verifies against the
// message it attests — which it could not do while the node was not registered
// with its own consensus core.
func TestStampBlockSignsWithTheNodesOwnKey(t *testing.T) {
vm, _ := bootVM(t, quietConfig())
msg := []byte("a round digest to attest")
stamp, err := vm.StampBlock(ids.GenerateTestID(), 1, msg)
require.NoError(t, err)
sig, ok := stamp.(*quasar.QuasarSig)
require.True(t, ok, "the bridge is up and produced no Quasar signature")
require.NoError(t, vm.VerifyStamp(msg, sig))
require.Error(t, vm.VerifyStamp([]byte("a different digest"), sig))
}
// TestStampBlockFallsBackToMLDSA: with no bridge and no block id, the stamp is
// an ML-DSA signature over the message, and it verifies.
func TestStampBlockFallsBackToMLDSA(t *testing.T) {
@@ -357,41 +475,83 @@ func TestStampBlockFallsBackToMLDSA(t *testing.T) {
sig, ok := stamp.(*quantum.QuantumSignature)
require.True(t, ok)
require.NoError(t, vm.quantumSigner.Verify(msg, sig))
require.NoError(t, vm.VerifyStamp(sig))
require.NoError(t, vm.VerifyStamp(msg, sig))
require.Error(t, vm.VerifyStamp([]byte("another message"), sig))
}
// TestVerifyStampRefusesWhatItCannotCheck. Each arm is a shape a peer can send,
// and an empty one must not read as verified.
func TestVerifyStampRefusesWhatItCannotCheck(t *testing.T) {
// TestVerifyStampChecksTheSignatureAgainstTheMessage.
//
// It took no message, so no arm could check a signature against anything: each
// looked at the stamp's own SHAPE, and shape is what the sender chose. A
// two-byte aggregate declaring three signers passed; so did a one-byte BLS
// signature. A self-declared signer count is not evidence of anything.
func TestVerifyStampChecksTheSignatureAgainstTheMessage(t *testing.T) {
ctx := context.Background()
vm, _ := bootVM(t, quietConfig())
threshold := vm.GetQuasarBridge().GetThreshold()
bridge := vm.GetQuasarBridge()
threshold := bridge.GetThreshold()
msg := []byte("the message under attestation")
require.NoError(t, vm.VerifyStamp(&quasar.QuasarSignature{
BLS: &quasar.BLSSignature{Signature: []byte{1}},
}))
require.Error(t, vm.VerifyStamp(&quasar.QuasarSignature{}), "a Quasar stamp with no BLS half")
require.Error(t, vm.VerifyStamp(&quasar.QuasarSignature{BLS: &quasar.BLSSignature{}}), "an empty BLS signature")
// A validator signature: the real one verifies, a forged shape does not.
sig, err := bridge.SignBlock(ctx, ids.GenerateTestID(), msg, 1)
require.NoError(t, err)
require.NoError(t, vm.VerifyStamp(msg, sig))
require.Error(t, vm.VerifyStamp(msg, &quasar.QuasarSig{
BLS: []byte{1}, ValidatorID: bridge.validatorID,
}), "a one-byte BLS signature was accepted")
require.Error(t, vm.VerifyStamp([]byte("another message"), sig),
"a signature was accepted for a message it does not sign")
require.NoError(t, vm.VerifyStamp(&quasar.AggregatedSignature{
BLSAggregated: []byte{1}, SignerCount: threshold,
}))
require.Error(t, vm.VerifyStamp(&quasar.AggregatedSignature{
BLSAggregated: []byte{1}, SignerCount: threshold - 1,
}), "an aggregate one signer short of the threshold")
require.Error(t, vm.VerifyStamp(&quasar.AggregatedSignature{SignerCount: threshold}),
// An aggregate: a self-declared signer count buys nothing.
require.Error(t, vm.VerifyStamp(msg, &quasar.AggregatedSignature{
BLSAggregated: []byte{1, 2}, SignerCount: threshold,
}), "two bytes declaring a quorum were accepted as an aggregate")
require.Error(t, vm.VerifyStamp(msg, &quasar.AggregatedSignature{
BLSAggregated: []byte{1}, SignerCount: threshold + 100,
}), "declaring more signers made a forgery verify")
require.Error(t, vm.VerifyStamp(msg, &quasar.AggregatedSignature{SignerCount: threshold}),
"an aggregate carrying no signature bytes")
require.Error(t, vm.VerifyStamp(&quantum.QuantumSignature{}), "an empty ML-DSA stamp")
require.Error(t, vm.VerifyStamp("a string"), "an unknown stamp type")
// An ML-DSA stamp is checked the same way.
require.Error(t, vm.VerifyStamp(msg, &quantum.QuantumSignature{}), "an empty ML-DSA stamp")
// With no bridge there is no threshold to check an aggregate against, so
// the aggregate is refused rather than waved through.
require.ErrorIs(t, vm.VerifyStamp(msg, nil), errNoStamp)
require.Error(t, vm.VerifyStamp(msg, "a string"), "an unknown stamp type")
// With no bridge there is nothing to check a Quasar stamp against, so it is
// refused rather than waved through.
vm.quasarBridge = nil
require.Error(t, vm.VerifyStamp(&quasar.AggregatedSignature{
require.Error(t, vm.VerifyStamp(msg, sig))
require.Error(t, vm.VerifyStamp(msg, &quasar.AggregatedSignature{
BLSAggregated: []byte{1}, SignerCount: threshold,
}))
}
// TestVerifyStampAcceptsAQuorumAggregate is the other half: an aggregate built
// from a verified quorum verifies, over the message it was built on and no
// other.
func TestVerifyStampAcceptsAQuorumAggregate(t *testing.T) {
ctx := context.Background()
vm, _ := bootVM(t, quietConfig())
bridge := vm.GetQuasarBridge()
blockID := ids.GenerateTestID()
msg := blockID[:]
_, err := bridge.SignBlock(ctx, blockID, msg, 1)
require.NoError(t, err)
for _, peer := range []string{"peer-1", "peer-2"} {
require.NoError(t, bridge.AddValidator(peer, 1))
require.NoError(t, bridge.AddSignature(blockID, signAs(t, bridge, peer, msg)))
}
agg, finalized, err := bridge.TryFinalize(ctx, blockID)
require.NoError(t, err)
require.True(t, finalized)
require.NoError(t, vm.VerifyStamp(msg, agg))
require.Error(t, vm.VerifyStamp([]byte("another block"), agg))
}
// TestFeePolicyRefusesEveryUserTransaction. Q-Chain sells no blockspace: cert
// inclusion is a validator obligation, so no amount buys it (LP-0130 §6).
func TestFeePolicyRefusesEveryUserTransaction(t *testing.T) {
+208 -31
View File
@@ -4,48 +4,69 @@
package quantumvm
import (
"bytes"
"crypto/sha256"
"errors"
"fmt"
"time"
"github.com/luxfi/chains/quantumvm/quantum"
"github.com/luxfi/ids"
"github.com/luxfi/zap"
)
// Native-ZAP struct-is-wire for Q-Chain (quantumvm). No hand-rolled big-endian —
// the Block and BaseTransaction own their serialization over zap objects at
// FIXED field offsets. Re-genesis authorized, so the on-wire format is exactly
// these offsets (canonical: parse rejects trailing bytes).
// Native-ZAP struct-is-wire for Q-Chain (quantumvm). The Block and its
// transactions own their serialization over zap objects at FIXED field offsets.
//
// The Block wire is the quantum-signature preimage: BuildBlock/Verify sign and
// re-check quantumSigner over Block.Bytes(), so the encoding must be
// deterministic (it is: fixed offsets, ordered tx list). It carries the
// structural fields and the committed transaction bytes, so the block signature
// commits to every transaction. The block id is NOT stored in the wire: it is
// the content hash sha256(Bytes()) (see computeID), matching keyvm/dexvm/aivm —
// one and only one id derivation across every VM in this repo, and the clean
// invariant id == sha256(Bytes()) holds.
// The wire is CANONICAL, and canonical means one byte string per block, not
// "the encoder emits one shape". Parse re-serializes what it decoded and refuses
// anything that does not come back byte-identical. Nothing weaker holds: a zap
// message declares its own size and its own root offset, so padding after the
// content, a relocated root struct and a root pointed at the wire header all
// decode to the same logical block under different sha256s. The block id is
// sha256(Bytes()), so each of those is a distinct id for one block — a fork the
// network builds by itself.
//
// parseBlock restores the structural fields (timestamp/height/parentID), derives
// the id from the bytes, and leaves transactions unpopulated: the concrete
// Transaction is an interface with no on-chain deserializer, so the tx blob rides
// the wire for signature coverage only — this preserves the prior parseBlock
// contract for the tx set.
// Parse reconstructs the FULL transaction set, signature included. Verify checks
// every transaction's ML-DSA signature over that set, so binding the check to a
// field the parser skipped is what made it run on locally built blocks and never
// on received ones. A signature check a parser can switch off is not a check.
//
// The block id is NOT stored in the wire: it is the content hash sha256(Bytes())
// (see computeID), matching keyvm/dexvm/aivm — one and only one id derivation
// across every VM in this repo, and the clean invariant id == sha256(Bytes())
// holds.
var (
errBlockTooLarge = errors.New("quantumvm: block exceeds the wire bound")
errNonCanonical = errors.New("quantumvm: block wire is not the canonical encoding of its content")
errTxCountAbsurd = errors.New("quantumvm: block declares more transactions than its bytes can hold")
errTxBlobMismatch = errors.New("quantumvm: transaction lengths do not partition the transaction blob")
)
// MaxBlockSize bounds a block on the wire. Without it a peer decides how much
// memory this node allocates and how much its store holds: parse, verify and
// commit each walked whatever arrived.
const MaxBlockSize = 2 << 20 // 2 MiB
// ---- Block ----
//
// Timestamp i64 @ 0 (Unix seconds — Q-Chain block-time resolution)
// Height u64 @ 8
// ParentID 32B @ 16
// TxLens list @ 48 (u32 per Transaction wire length)
// TxBlob bytes @ 56 (concatenated Transaction wire bytes)
// ChainID 32B @ 48 (the chain this block belongs to)
// NetworkID u32 @ 80 (the network that chain belongs to)
// TxLens list @ 88 (u32 per transaction wire length)
// TxBlob bytes @ 96 (concatenated transaction wire bytes)
const (
blkTime = 0
blkHeight = 8
blkParent = 16
blkTxLens = 48
blkTxBlob = 56
blkSize = 64
blkTime = 0
blkHeight = 8
blkParent = 16
blkChain = 48
blkNetwork = 80
blkTxLens = 88
blkTxBlob = 96
blkSize = 104
)
// Bytes returns the block's canonical ZAP wire (cached).
@@ -57,7 +78,7 @@ func (b *Block) Bytes() []byte {
txLens := make([]uint32, len(b.transactions))
var txBlob []byte
for i, tx := range b.transactions {
txb := tx.Bytes()
txb := marshalTx(tx)
txLens[i] = uint32(len(txb))
txBlob = append(txBlob, txb...)
}
@@ -69,6 +90,8 @@ func (b *Block) Bytes() []byte {
ob.SetInt64(blkTime, b.timestamp.Unix())
ob.SetUint64(blkHeight, b.height)
ob.SetBytesFixed(blkParent, b.parentID[:])
ob.SetBytesFixed(blkChain, b.chainID[:])
ob.SetUint32(blkNetwork, b.networkID)
ob.SetList(blkTxLens, txLensOff, len(txLens))
ob.SetBytes(blkTxBlob, txBlob)
ob.FinishAsRoot()
@@ -84,11 +107,12 @@ func (b *Block) computeID() ids.ID {
return ids.ID(sha256.Sum256(b.Bytes()))
}
// parseBlockBytes decodes a block's structural fields from its ZAP wire. The
// transaction set is intentionally not reconstructed (see file header); the raw
// wire is retained in b.bytes so the quantum signature can be re-verified over
// the exact bytes.
// parseBlockBytes decodes a block, transaction set included, and accepts the
// bytes only if they are the canonical encoding of what came out.
func parseBlockBytes(vm *VM, data []byte) (*Block, error) {
if len(data) > MaxBlockSize {
return nil, fmt.Errorf("%w: %d bytes over %d", errBlockTooLarge, len(data), MaxBlockSize)
}
msg, err := zap.Parse(data)
if err != nil {
return nil, err
@@ -106,16 +130,71 @@ func parseBlockBytes(vm *VM, data []byte) (*Block, error) {
return nil, fmt.Errorf("quantumvm: block wire ends %d bytes into a %d-byte header",
msg.Size()-o.Offset(), blkSize)
}
b := &Block{vm: vm, bytes: data}
b := &Block{vm: vm}
b.timestamp = time.Unix(o.Int64(blkTime), 0)
b.height = o.Uint64(blkHeight)
copy(b.parentID[:], o.BytesFixedSlice(blkParent, 32))
copy(b.chainID[:], o.BytesFixedSlice(blkChain, 32))
b.networkID = o.Uint32(blkNetwork)
b.transactions, err = parseTxSet(o.List(blkTxLens), o.Bytes(blkTxBlob))
if err != nil {
return nil, err
}
// Canonical or nothing. Re-serializing what was decoded and comparing is
// the only check that covers every degree of freedom the container has —
// the declared size, the root offset, and anything appended past the
// content — rather than the handful anyone thought to enumerate.
if !bytes.Equal(b.Bytes(), data) {
return nil, errNonCanonical
}
b.id = ids.ID(sha256.Sum256(data))
return b, nil
}
// parseTxSet rebuilds the transactions from the length list and the blob the
// lengths partition. The lengths must cover the blob exactly: bytes no length
// names are bytes the block commits to and nothing reads.
func parseTxSet(lens zap.List, blob []byte) ([]Transaction, error) {
n := lens.Len()
if n == 0 {
return nil, nil
}
// A list length is attacker-chosen and only clamped to the message size, so
// bound it by what the blob can actually hold before allocating for it.
if n > len(blob)/minTxWire {
return nil, fmt.Errorf("%w: %d in %d bytes", errTxCountAbsurd, n, len(blob))
}
txs := make([]Transaction, n)
off := 0
for i := 0; i < n; i++ {
size := int(lens.Uint32(i))
if size < minTxWire || off+size > len(blob) {
return nil, fmt.Errorf("%w: entry %d claims %d of %d remaining",
errTxBlobMismatch, i, size, len(blob)-off)
}
tx, err := unmarshalTx(blob[off : off+size])
if err != nil {
return nil, fmt.Errorf("quantumvm: transaction %d: %w", i, err)
}
txs[i] = tx
off += size
}
if off != len(blob) {
return nil, fmt.Errorf("%w: %d bytes past the last transaction", errTxBlobMismatch, len(blob)-off)
}
return txs, nil
}
// ---- BaseTransaction ----
//
// Two wires, because a transaction is two things. Bytes() is the SIGNATURE
// PREIMAGE — what the ML-DSA signature covers, and therefore what may never
// include the signature. The envelope below is what rides in a block: the
// preimage plus the signature over it.
//
// Timestamp i64 @ 0 (Unix seconds)
// Nonce u64 @ 8
// Data bytes @ 16
@@ -138,6 +217,104 @@ func (tx *BaseTransaction) Bytes() []byte {
return b.Finish()
}
// ---- transaction envelope ----
//
// Body bytes @ 0 (the preimage above)
// Algorithm u32 @ 8
// Stamped i64 @ 16 (signature time, Unix nanoseconds)
// PublicKey bytes @ 24 (ML-DSA public key)
// Signature bytes @ 32 (ML-DSA signature over body || stamp || stamped)
// Stamp bytes @ 40 (quantum stamp)
const (
envBody = 0
envAlg = 8
envTime = 16
envKey = 24
envSig = 32
envStamp = 40
envSize = 48
)
// minTxWire is the smallest an envelope can be: the zap header plus the fixed
// section, with every variable field null.
const minTxWire = zap.HeaderSize + envSize
// marshalTx writes a transaction and the signature over it. The interface hands
// over exactly the two, so any Transaction serializes the same way.
func marshalTx(tx Transaction) []byte {
body := tx.Bytes()
sig := tx.GetQuantumSignature()
if sig == nil {
sig = &quantum.QuantumSignature{}
}
b := zap.NewBuilder(zap.HeaderSize + envSize +
len(body) + len(sig.PublicKey) + len(sig.Signature) + len(sig.QuantumStamp) + 64)
ob := b.StartObject(envSize)
ob.SetBytes(envBody, body)
ob.SetUint32(envAlg, sig.Algorithm)
ob.SetInt64(envTime, sig.Timestamp.UnixNano())
ob.SetBytes(envKey, sig.PublicKey)
ob.SetBytes(envSig, sig.Signature)
ob.SetBytes(envStamp, sig.QuantumStamp)
ob.FinishAsRoot()
return b.Finish()
}
// unmarshalTx is marshalTx's inverse. CoronaKey is the public key by
// construction (Sign sets both from one key), so it is derived rather than
// carried — a second copy on the wire is a second thing to disagree.
func unmarshalTx(data []byte) (*BaseTransaction, error) {
msg, err := zap.Parse(data)
if err != nil {
return nil, err
}
if msg.Size() != len(data) {
return nil, fmt.Errorf("quantumvm: transaction trailing bytes")
}
o := msg.Root()
if o.Offset()+envSize > msg.Size() {
return nil, fmt.Errorf("quantumvm: transaction wire ends %d bytes into a %d-byte header",
msg.Size()-o.Offset(), envSize)
}
tx, err := parseTxBody(o.Bytes(envBody))
if err != nil {
return nil, err
}
key := o.Bytes(envKey)
tx.quantumSignature = &quantum.QuantumSignature{
Algorithm: o.Uint32(envAlg),
Timestamp: time.Unix(0, o.Int64(envTime)),
PublicKey: key,
Signature: o.Bytes(envSig),
CoronaKey: key,
QuantumStamp: o.Bytes(envStamp),
}
return tx, nil
}
// parseTxBody decodes the signature preimage.
func parseTxBody(body []byte) (*BaseTransaction, error) {
msg, err := zap.Parse(body)
if err != nil {
return nil, err
}
if msg.Size() != len(body) {
return nil, fmt.Errorf("quantumvm: transaction body trailing bytes")
}
o := msg.Root()
if o.Offset()+txSize > msg.Size() {
return nil, fmt.Errorf("quantumvm: transaction body ends %d bytes into a %d-byte header",
msg.Size()-o.Offset(), txSize)
}
return &BaseTransaction{
timestamp: time.Unix(o.Int64(txTime), 0),
nonce: o.Uint64(txNonce),
data: o.Bytes(txData),
}, nil
}
// ---- shared helpers ----
func writeU32List(b *zap.Builder, xs []uint32) int {
+182 -25
View File
@@ -5,10 +5,12 @@ package quantumvm
import (
"crypto/sha256"
"encoding/binary"
"testing"
"time"
"github.com/luxfi/ids"
"github.com/luxfi/zap"
"github.com/stretchr/testify/require"
)
@@ -17,20 +19,25 @@ func sampleBlock() *Block {
timestamp: time.Unix(1_700_000_500, 0),
height: 9,
parentID: ids.GenerateTestID(),
chainID: testChain,
networkID: testNetwork,
transactions: []Transaction{
&BaseTransaction{timestamp: time.Unix(1_700_000_400, 0), nonce: 1, data: []byte("op-a")},
&BaseTransaction{timestamp: time.Unix(1_700_000_450, 0), nonce: 2, data: []byte("op-bb")},
stampedTx(1, "op-a"),
stampedTx(2, "op-bb"),
},
}
blk.id = blk.computeID()
return blk
}
// TestBlockWireRoundTrip: everything the block committed to comes back, the
// transaction set above all. Leaving it out was what made a received block hold
// zero transactions — and a signature check over zero transactions checks
// nothing.
func TestBlockWireRoundTrip(t *testing.T) {
require := require.New(t)
blk := sampleBlock()
wire := blk.Bytes()
require.NotEmpty(wire)
@@ -40,21 +47,27 @@ func TestBlockWireRoundTrip(t *testing.T) {
require.Equal(blk.timestamp.Unix(), got.timestamp.Unix())
require.Equal(blk.height, got.height)
require.Equal(blk.parentID, got.parentID)
require.Equal(blk.chainID, got.chainID)
require.Equal(blk.networkID, got.networkID)
// The transaction bytes are committed to the wire (block signature covers
// them), even though parse does not reconstruct the concrete txs.
require.Nil(got.transactions, "parse preserves the tx-lossy contract")
empty := (&Block{timestamp: blk.timestamp, height: blk.height, parentID: blk.parentID}).Bytes()
require.Greater(len(wire), len(empty), "committed tx bytes are present in the block wire")
require.Len(got.transactions, len(blk.transactions), "the parser dropped the transaction set")
for i, want := range blk.transactions {
require.Equal(want.ID(), got.transactions[i].ID(), "transaction %d changed identity", i)
require.Equal(want.Bytes(), got.transactions[i].Bytes(), "transaction %d changed its signed bytes", i)
require.Equal(want.GetQuantumSignature().Signature,
got.transactions[i].GetQuantumSignature().Signature,
"transaction %d lost its signature", i)
}
// re-parse of the retained bytes is stable (signature re-verification path)
// re-serialization of a parsed block is byte-identical, which is what makes
// the id a function of the block rather than of its encoder.
require.Equal(wire, got.Bytes())
}
// TestBlockIDIsContentHash is the M2 regression guard: the block id is the
// content hash sha256(Bytes()) and is NOT stored in the wire — so it is never
// ids.Empty (the prior ids.ToID(parent‖height) over 40 bytes silently yielded
// Empty for every block, collapsing the block store to one slot).
// TestBlockIDIsContentHash: the block id is the content hash sha256(Bytes())
// and is NOT stored in the wire — so it is never ids.Empty (the prior
// ids.ToID(parent‖height) over 40 bytes silently yielded Empty for every block,
// collapsing the block store to one slot).
func TestBlockIDIsContentHash(t *testing.T) {
require := require.New(t)
@@ -67,13 +80,23 @@ func TestBlockIDIsContentHash(t *testing.T) {
require.NoError(err)
require.Equal(blk.id, got.id)
// Distinct content ⇒ distinct ids (height, parent, and tx set each move it).
byHeight := &Block{timestamp: blk.timestamp, height: blk.height + 1, parentID: blk.parentID, transactions: blk.transactions}
byParent := &Block{timestamp: blk.timestamp, height: blk.height, parentID: ids.GenerateTestID(), transactions: blk.transactions}
byTx := &Block{timestamp: blk.timestamp, height: blk.height, parentID: blk.parentID,
transactions: []Transaction{&BaseTransaction{timestamp: blk.timestamp, nonce: 99, data: []byte("different")}}}
// Distinct content ⇒ distinct ids: every field moves the id.
vary := func(f func(*Block)) *Block {
c := &Block{timestamp: blk.timestamp, height: blk.height, parentID: blk.parentID,
chainID: blk.chainID, networkID: blk.networkID, transactions: blk.transactions}
f(c)
return c
}
variants := []*Block{
vary(func(c *Block) { c.height++ }),
vary(func(c *Block) { c.parentID = ids.GenerateTestID() }),
vary(func(c *Block) { c.chainID = otherChain }),
vary(func(c *Block) { c.networkID++ }),
vary(func(c *Block) { c.timestamp = blk.timestamp.Add(time.Second) }),
vary(func(c *Block) { c.transactions = []Transaction{stampedTx(99, "different")} }),
}
seen := map[ids.ID]string{blk.id: "base"}
for _, c := range []*Block{byHeight, byParent, byTx} {
for _, c := range variants {
id := c.computeID()
require.NotEqual(ids.Empty, id)
require.NotContains(seen, id, "distinct block content produced a colliding id")
@@ -81,13 +104,115 @@ func TestBlockIDIsContentHash(t *testing.T) {
}
}
func TestBlockWireRejectsTrailing(t *testing.T) {
blk := &Block{timestamp: time.Unix(1, 0), height: 1, parentID: ids.GenerateTestID()}
// TestOneBlockHasOneEncoding.
//
// A zap message declares its own SIZE and its own ROOT OFFSET, and both are the
// sender's to choose — so the container has degrees of freedom the content does
// not. Rejecting bytes past the DECLARED size rejected nothing: append a byte,
// bump the size field, and one logical block has a second id. Two hundred and
// fifty-six of them, from one byte of padding. Relocating the root struct is the
// same trick on the other field.
//
// Parse re-serializes what it decoded and compares. That covers every degree of
// freedom the container has, rather than the ones anyone thought to enumerate.
func TestOneBlockHasOneEncoding(t *testing.T) {
blk := sampleBlock()
wire := blk.Bytes()
// One byte of padding, with the declared size moved to cover it.
for pad := 0; pad < 256; pad++ {
padded := append(append([]byte(nil), wire...), byte(pad))
binary.LittleEndian.PutUint32(padded[12:16], uint32(len(padded)))
msg, err := zap.Parse(padded)
require.NoError(t, err, "precondition: padding 0x%02x still parses as zap", pad)
require.Equal(t, len(padded), msg.Size(), "precondition: the declared size covers the padding")
require.NotEqual(t, blk.id, ids.ID(sha256.Sum256(padded)),
"precondition: padding changes the content hash")
_, err = parseBlockBytes(nil, padded)
require.ErrorIs(t, err, errNonCanonical, "padding 0x%02x produced a second id for one block", pad)
}
// The root struct, relocated. Every pointer in the object is relative to its
// own field, so shifting the whole body leaves the content identical and the
// bytes different.
shifted := make([]byte, 0, len(wire)+8)
shifted = append(shifted, wire[:zap.HeaderSize]...)
shifted = append(shifted, make([]byte, 8)...)
shifted = append(shifted, wire[zap.HeaderSize:]...)
binary.LittleEndian.PutUint32(shifted[8:12], binary.LittleEndian.Uint32(wire[8:12])+8)
binary.LittleEndian.PutUint32(shifted[12:16], uint32(len(shifted)))
relocated, err := parseBlockBytes(nil, shifted)
if err == nil {
t.Fatalf("a relocated root parsed as the same block under a different id: %s vs %s",
relocated.id, blk.id)
}
require.ErrorIs(t, err, errNonCanonical)
// A root offset pointing into the wire header, where the fields are read out
// of the magic and the size.
intoHeader := append([]byte(nil), wire...)
binary.LittleEndian.PutUint32(intoHeader[8:12], 0)
_, err = parseBlockBytes(nil, intoHeader)
require.Error(t, err, "the block's fields were read out of the zap header")
}
// TestBlockWireRejectsTrailing bytes past the declared size.
func TestBlockWireRejectsTrailing(t *testing.T) {
wire := sampleBlock().Bytes()
_, err := parseBlockBytes(nil, append(append([]byte(nil), wire...), 0x00))
require.Error(t, err, "trailing bytes must be rejected")
}
// TestTheTransactionListMustPartitionTheBlob. The lengths say where each
// transaction ends; bytes no length names are bytes the block commits to and
// nothing reads, and a count larger than the blob can hold is an allocation a
// peer chose.
func TestTheTransactionListMustPartitionTheBlob(t *testing.T) {
blk := sampleBlock()
wire := blk.Bytes()
msg, err := zap.Parse(wire)
require.NoError(t, err)
root := msg.Root()
lens := root.List(blkTxLens)
require.Equal(t, 2, lens.Len(), "precondition: two transactions on the wire")
// The list lives at a known offset; rewrite the first entry's length.
first := int(lens.Uint32(0))
lensAt := findU32(t, wire, uint32(first))
for _, bad := range []uint32{0, 1, uint32(first - 1), uint32(first + 1), 1 << 30} {
tampered := append([]byte(nil), wire...)
binary.LittleEndian.PutUint32(tampered[lensAt:], bad)
_, err := parseBlockBytes(nil, tampered)
require.Error(t, err, "a transaction length of %d partitioned the blob", bad)
}
// And an absurd count is refused before it is allocated for.
countAt := root.Offset() + blkTxLens + 4
tampered := append([]byte(nil), wire...)
binary.LittleEndian.PutUint32(tampered[countAt:], uint32(len(wire)))
_, err = parseBlockBytes(nil, tampered)
require.ErrorIs(t, err, errTxCountAbsurd)
}
// findU32 locates the first little-endian occurrence of v in b.
func findU32(t *testing.T, b []byte, v uint32) int {
t.Helper()
var want [4]byte
binary.LittleEndian.PutUint32(want[:], v)
for i := 0; i+4 <= len(b); i += 4 {
if [4]byte(b[i:i+4]) == want {
return i
}
}
t.Fatalf("could not locate %d in the wire", v)
return 0
}
func TestBaseTransactionWireDeterministic(t *testing.T) {
require := require.New(t)
@@ -100,12 +225,44 @@ func TestBaseTransactionWireDeterministic(t *testing.T) {
require.NotEmpty(a)
// The signature is NOT part of the signed bytes.
signed := &BaseTransaction{timestamp: time.Unix(1_700_000_000, 0), nonce: 42, data: []byte("payload")}
signed.quantumSignature = nil
require.Equal(a, signed.Bytes(), "quantum signature is excluded from tx wire")
signed := mk()
signed.quantumSignature = stampedTx(42, "payload").quantumSignature
require.Equal(a, signed.Bytes(), "quantum signature is excluded from the signature preimage")
}
// TestBaseTransactionIDNonEmpty is the M2 regression guard for the tx pool: the
// TestTheEnvelopeCarriesTheSignatureAndTheBodyCarriesNone. Two wires because a
// transaction is two things: what was signed, and what was signed plus the
// signature. Folding them into one would put the signature inside its own
// preimage.
func TestTheEnvelopeCarriesTheSignatureAndTheBodyCarriesNone(t *testing.T) {
tx := stampedTx(7, "payload")
env := marshalTx(tx)
got, err := unmarshalTx(env)
require.NoError(t, err)
require.Equal(t, tx.Bytes(), got.Bytes(), "the signature preimage changed in flight")
require.Equal(t, tx.ID(), got.ID())
require.Equal(t, tx.quantumSignature.Signature, got.quantumSignature.Signature)
require.Equal(t, tx.quantumSignature.QuantumStamp, got.quantumSignature.QuantumStamp)
require.Equal(t, tx.quantumSignature.Timestamp.UnixNano(), got.quantumSignature.Timestamp.UnixNano())
require.Equal(t, got.quantumSignature.PublicKey, got.quantumSignature.CoronaKey)
require.Equal(t, env, marshalTx(got), "the envelope is not canonical")
// A transaction with no signature still rides, and comes back with none.
bare := &BaseTransaction{timestamp: chainTime, nonce: 3, data: []byte("bare")}
back, err := unmarshalTx(marshalTx(bare))
require.NoError(t, err)
require.Equal(t, bare.Bytes(), back.Bytes())
require.Empty(t, back.quantumSignature.Signature)
// Bytes that are not an envelope are refused rather than decoded to zeros.
for _, bad := range [][]byte{nil, {}, []byte("garbage"), env[:len(env)-1], append(append([]byte(nil), env...), 0)} {
_, err := unmarshalTx(bad)
require.Error(t, err, "a %d-byte non-envelope decoded", len(bad))
}
}
// TestBaseTransactionIDNonEmpty is the regression guard for the tx pool: the
// tx id is sha256(Bytes()) and never ids.Empty, so distinct txs occupy distinct
// pool slots (the prior ids.ToID over the always-≥32-byte wire yielded Empty for
// every tx, collapsing the pool to a single entry).