chain: a block lands whole or not at all

Nine chains each write their own Accept, and five of them write it with
individual Puts that return early. A later write failing leaves the earlier
ones on disk and the chain already believing the block was accepted: state is
half-applied and there is no path back.

The discipline two chains already have becomes the only one there is. A block
declares what it Writes and what it Publishes; the store stages the writes
through a versiondb, commits them in ONE batch with the block, its height entry
and the tip pointer, and on any failure rolls the view back and rebuilds the
caches from committed state. Nothing the block claimed survives and the tip
does not move. Publish runs after the commit, so there is no window in which
the chain has advanced past state that is not on disk.

Beside it: the blocks in flight, under the store's one lock, pruned at or below
the accepted height because the engine may abandon a block without ever
deciding it; the pool, whose claim set is derived from its queue so a claim
cannot outlive the entry that made it; the fee a chain declares, which admits
nothing until it is declared; and the factory, whose VM holds nothing until
Initialize.

Seven mutations of the store and three of the pool each fail at least one test.
This commit is contained in:
2026-08-28 08:15:44 -07:00
parent af188362b1
commit 6220024728
15 changed files with 1195 additions and 3049 deletions
+20
View File
@@ -0,0 +1,20 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package chain
import (
"github.com/luxfi/log"
"github.com/luxfi/node/vms"
)
// Factory builds one chain's VM. The zero VM is the whole of it: a VM gets its
// database, its configuration and its caches in Initialize, and nothing else
// may hand it state — a factory that half-populates a VM leaves two places
// that decide what a fresh VM holds, and they drift.
type Factory[T any] struct{}
var _ vms.Factory = Factory[struct{}]{}
// New returns a VM with nothing set. Initialize does the rest.
func (Factory[T]) New(log.Logger) (interface{}, error) { return new(T), nil }
+59
View File
@@ -0,0 +1,59 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package chain
import (
"github.com/luxfi/constants"
"github.com/luxfi/ids"
nodefee "github.com/luxfi/node/vms/types/fee"
)
// Fee is what a chain charges to admit a user transaction, and the check that
// refuses one paying less.
//
// It is a DECLARATION at the boundary, orthogonal to settlement: this says
// what the chain costs to submit to, while the actual per-operation debit and
// burn happen inside consensus against the payer's on-chain balance
// (github.com/luxfi/chains/fee). A chain declares one of these at Initialize;
// the node's boot-time nodefee.Validate reads it.
//
// The zero Fee admits nothing, so a chain that forgets to declare one refuses
// every caller rather than admitting every caller.
type Fee struct {
policy nodefee.Policy
asset ids.ID
}
// Floor is the canonical declaration for a chain that accepts user-submitted
// work: the network's UTXO asset at the minimum transaction fee. A chain with
// a user-facing entry MUST charge at least this, or nodefee.Validate flags it
// at boot as a zero-fee user-facing chain.
func Floor(networkID uint32) Fee {
asset := constants.UTXOAssetIDFor(networkID)
return Fee{
policy: nodefee.FlatPolicy{Fee: nodefee.MinTxFeeFloor, AssetID: asset},
asset: asset,
}
}
// Closed is the declaration for a chain that accepts no user transactions at
// all — one driven by validators through consensus, or one that only reads.
// Every caller is refused, so an entry that exposes itself as user-callable
// still refuses explicitly instead of by omission.
func Closed() Fee {
return Fee{policy: nodefee.NoUserTxPolicy{}, asset: nodefee.NoUserTxPolicy{}.FeeAssetID()}
}
// Admit refuses a payment the declaration does not cover. Every user-facing
// entry that produces an on-chain effect passes through here before the effect
// is reachable.
func (f Fee) Admit(paid uint64) error {
if f.policy == nil {
return nodefee.ErrChainAcceptsNoUserTxs
}
return f.policy.ValidateFee(paid, f.asset)
}
// Policy is the declaration itself, for diagnostics and the boot-time gate.
func (f Fee) Policy() nodefee.Policy { return f.policy }
+46
View File
@@ -0,0 +1,46 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package chain
import (
"testing"
"github.com/stretchr/testify/require"
nodefee "github.com/luxfi/node/vms/types/fee"
)
func TestAChainThatChargesRefusesLessThanTheFloor(t *testing.T) {
f := Floor(1)
require.Error(t, f.Admit(0))
require.Error(t, f.Admit(nodefee.MinTxFeeFloor-1))
require.NoError(t, f.Admit(nodefee.MinTxFeeFloor))
require.NoError(t, f.Admit(nodefee.MinTxFeeFloor+1))
require.NoError(t, nodefee.Validate(f.Policy()),
"the node's boot-time check must accept what a user-facing chain declares")
}
func TestAClosedChainRefusesEveryCaller(t *testing.T) {
f := Closed()
require.ErrorIs(t, f.Admit(0), nodefee.ErrChainAcceptsNoUserTxs)
require.ErrorIs(t, f.Admit(nodefee.MinTxFeeFloor), nodefee.ErrChainAcceptsNoUserTxs)
require.ErrorIs(t, f.Admit(1<<62), nodefee.ErrChainAcceptsNoUserTxs)
require.NoError(t, nodefee.Validate(f.Policy()))
}
// TestAnUndeclaredFeeAdmitsNothing is the fail-closed shape: a chain that
// forgets to declare what it charges refuses every caller, rather than
// admitting every caller.
func TestAnUndeclaredFeeAdmitsNothing(t *testing.T) {
var f Fee
require.ErrorIs(t, f.Admit(0), nodefee.ErrChainAcceptsNoUserTxs)
require.ErrorIs(t, f.Admit(1<<62), nodefee.ErrChainAcceptsNoUserTxs)
require.Nil(t, f.Policy())
}
func TestTheFloorIsDenominatedInTheNetworksOwnAsset(t *testing.T) {
one, two := Floor(1), Floor(2)
require.NotEqual(t, one.asset, two.asset,
"two networks do not share an asset, so they do not share a floor")
}
+138
View File
@@ -0,0 +1,138 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package chain
import (
"context"
"errors"
"sync"
"github.com/luxfi/ids"
vmcore "github.com/luxfi/vm"
)
// Pool is what a chain has waiting for a block: the queue, in the order it
// arrived, and the set of effects those entries claim.
//
// The claim set is DERIVED from the queue — rebuilt from what is left whenever
// the queue shrinks — so a claim cannot outlive the entry that made it. A set
// maintained alongside the queue instead of from it drifts the moment one
// removal path forgets to touch it, and what it then refuses is work nothing
// is going to do.
//
// LOCK ORDER: a chain takes its Store's lock first and a Pool's second, never
// the other way round. Accept holds the store lock and drops from the pool
// inside it, so the reverse order deadlocks.
type Pool[T any] struct {
mu sync.Mutex
claim func(T) ids.ID
max int
queue []T
held map[ids.ID]struct{}
work vmcore.Latch
}
var (
// ErrFull reports a pool at its bound. Admission is open to anyone who can
// pay, so without a bound the queue is whatever an adversary makes it.
ErrFull = errors.New("chain: pool is full")
// ErrHeld reports a second entry claiming what a queued entry already
// claims. Both would pass every check alone and together spend a block on
// work only one of them can do.
ErrHeld = errors.New("chain: claimed by a queued entry")
)
// NewPool returns a pool bounded at max entries, where claim says what an
// entry takes — which is also how it is found again.
func NewPool[T any](max int, claim func(T) ids.ID) *Pool[T] {
return &Pool[T]{
claim: claim,
max: max,
held: make(map[ids.ID]struct{}),
}
}
// Add queues an entry and tells consensus there is something to build. A chain
// builds nothing until it is told.
func (p *Pool[T]) Add(entry T) error {
p.mu.Lock()
if len(p.queue) >= p.max {
p.mu.Unlock()
return ErrFull
}
c := p.claim(entry)
if _, dup := p.held[c]; dup {
p.mu.Unlock()
return ErrHeld
}
p.held[c] = struct{}{}
p.queue = append(p.queue, entry)
p.mu.Unlock()
p.work.Signal()
return nil
}
// Take returns up to n entries in arrival order, oldest first, and leaves them
// queued. A block SELECTS from the pool rather than draining it, so an engine
// that discards a proposal — which it may do without ever rejecting it —
// cannot take the queue with it. n of zero or less takes everything.
func (p *Pool[T]) Take(n int) []T {
p.mu.Lock()
defer p.mu.Unlock()
if n <= 0 || n > len(p.queue) {
n = len(p.queue)
}
return append([]T(nil), p.queue[:n]...)
}
// Drop removes accepted entries and rebuilds the claim set from what remains.
func (p *Pool[T]) Drop(entries []T) {
if len(entries) == 0 {
return
}
gone := make(map[ids.ID]struct{}, len(entries))
for _, e := range entries {
gone[p.claim(e)] = struct{}{}
}
p.mu.Lock()
defer p.mu.Unlock()
kept := p.queue[:0]
for _, e := range p.queue {
if _, ok := gone[p.claim(e)]; !ok {
kept = append(kept, e)
}
}
p.queue = kept
p.held = make(map[ids.ID]struct{}, len(kept))
for _, e := range kept {
p.held[p.claim(e)] = struct{}{}
}
}
// Holds reports whether a queued entry already claims c.
func (p *Pool[T]) Holds(c ids.ID) bool {
p.mu.Lock()
defer p.mu.Unlock()
_, ok := p.held[c]
return ok
}
// Len is how many entries are waiting.
func (p *Pool[T]) Len() int {
p.mu.Lock()
defer p.mu.Unlock()
return len(p.queue)
}
// Wait blocks until there is something to build a block from, or the caller
// gives up. Waiting on the context alone would mean a chain never leaves
// genesis however much it is offered.
func (p *Pool[T]) Wait(ctx context.Context) (vmcore.Message, error) {
return p.work.WaitForEvent(ctx)
}
+144
View File
@@ -0,0 +1,144 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package chain
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/luxfi/ids"
)
type entry struct {
id ids.ID
}
func at(n byte) entry { return entry{id: ids.ID{n}} }
func claimOf(e entry) ids.ID { return e.id }
func newTestPool(max int) *Pool[entry] { return NewPool(max, claimOf) }
func TestAPoolHandsBackWhatItWasGivenInOrder(t *testing.T) {
p := newTestPool(8)
require.NoError(t, p.Add(at(1)))
require.NoError(t, p.Add(at(2)))
require.NoError(t, p.Add(at(3)))
require.Equal(t, []entry{at(1), at(2), at(3)}, p.Take(0))
require.Equal(t, []entry{at(1), at(2)}, p.Take(2))
require.Equal(t, 3, p.Len())
}
func TestTakingDoesNotDrain(t *testing.T) {
p := newTestPool(8)
require.NoError(t, p.Add(at(1)))
// A block SELECTS from the pool. An engine may discard a proposal without
// ever rejecting it, and a pool that drained would go with it.
require.Len(t, p.Take(0), 1)
require.Len(t, p.Take(0), 1)
require.Equal(t, 1, p.Len())
}
func TestASecondClaimOnQueuedWorkIsRefused(t *testing.T) {
p := newTestPool(8)
require.NoError(t, p.Add(at(1)))
require.ErrorIs(t, p.Add(at(1)), ErrHeld)
require.Equal(t, 1, p.Len())
require.True(t, p.Holds(ids.ID{1}))
require.False(t, p.Holds(ids.ID{2}))
}
func TestAFullPoolRefusesRatherThanGrows(t *testing.T) {
p := newTestPool(2)
require.NoError(t, p.Add(at(1)))
require.NoError(t, p.Add(at(2)))
require.ErrorIs(t, p.Add(at(3)), ErrFull)
require.Equal(t, 2, p.Len())
}
// TestAClaimDoesNotOutliveItsEntry is the property the derived claim set
// exists for: once the work is done, what it held is free again. A set kept
// alongside the queue rather than derived from it leaks exactly here, and what
// it then refuses is work nothing is going to do.
func TestAClaimDoesNotOutliveItsEntry(t *testing.T) {
p := newTestPool(8)
require.NoError(t, p.Add(at(1)))
require.NoError(t, p.Add(at(2)))
p.Drop([]entry{at(1)})
require.Equal(t, []entry{at(2)}, p.Take(0))
require.False(t, p.Holds(ids.ID{1}), "the dropped entry holds nothing")
require.True(t, p.Holds(ids.ID{2}), "the one still queued does")
require.NoError(t, p.Add(at(1)), "and its claim can be made again")
}
func TestDroppingNothingChangesNothing(t *testing.T) {
p := newTestPool(8)
require.NoError(t, p.Add(at(1)))
p.Drop(nil)
require.Equal(t, 1, p.Len())
require.True(t, p.Holds(ids.ID{1}))
}
func TestDroppingWhatWasNeverQueuedLeavesTheRestAlone(t *testing.T) {
p := newTestPool(8)
require.NoError(t, p.Add(at(1)))
p.Drop([]entry{at(9)})
require.Equal(t, []entry{at(1)}, p.Take(0))
require.True(t, p.Holds(ids.ID{1}))
}
func TestAddingTellsConsensusThereIsSomethingToBuild(t *testing.T) {
p := newTestPool(8)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
woke := make(chan error, 1)
go func() {
_, err := p.Wait(ctx)
woke <- err
}()
require.NoError(t, p.Add(at(1)))
select {
case err := <-woke:
require.NoError(t, err)
case <-ctx.Done():
t.Fatal("a chain builds nothing until it is told; nothing told it")
}
}
func TestConcurrentUseOfAPoolDoesNotRace(t *testing.T) {
p := newTestPool(256)
done := make(chan struct{})
for i := 0; i < 4; i++ {
go func() {
for {
select {
case <-done:
return
default:
p.Take(4)
p.Len()
p.Holds(ids.ID{1})
}
}
}()
}
for n := 0; n < 200; n++ {
e := at(byte(n % 200))
_ = p.Add(e)
p.Drop([]entry{e})
}
close(done)
require.Zero(t, p.Len())
}
+318
View File
@@ -0,0 +1,318 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package chain holds what every chain in this module is made of: a block's
// place in the sequence, the store that advances the chain one block at a
// time, the queue of entries waiting for a block, and the fee floor that
// admits them.
//
// A chain package declares what it IS — its transactions, its records, its
// authorization rules. It does not restate how a block becomes fact. That is
// here, once, because every chain does it identically and the one way to get
// it wrong is to write it again.
package chain
import (
"encoding/binary"
"errors"
"fmt"
"sync"
"github.com/luxfi/database"
"github.com/luxfi/database/versiondb"
"github.com/luxfi/ids"
vmchain "github.com/luxfi/vm/chain"
)
// Block is a block a Store can accept: the engine's own block, plus the two
// halves of applying one.
//
// Splitting them is what makes a half-applied block unwritable. Write can fail
// and is discarded whole; Publish cannot fail and runs only once the writes
// are durable. A chain that writes and publishes in one pass has no such
// boundary, and the first failed write leaves the chain believing something
// that is not on disk.
type Block interface {
vmchain.Block
// Write stages every durable change the block makes, through the view it
// is given and nothing else. Writing anywhere else escapes the rollback.
Write(database.Database) error
// Publish makes the block's effects visible in memory. It runs after the
// commit, under the store's lock. It cannot fail: anything that can fail
// belongs in Write.
Publish()
}
// Store is a chain's durable state, the blocks in flight above it, and the tip
// it has reached.
//
// ONE lock covers all three, and whatever caches the chain keeps beside them —
// take it with Lock or RLock. A second mutex over any part of this gives one
// map two owners, which in Go is a fatal throw rather than a bug you get to
// debug.
type Store struct {
sync.RWMutex
base database.Database // committed state
view *versiondb.Database // what a block writes through until it commits
reload func() error
flight map[ids.ID]Block
tip ids.ID
height uint64
}
// Key namespaces. Blocks, the height index and the tip pointer are the store's
// own; a chain's records live under prefixes of its choosing and cannot
// collide with these.
var (
tipKey = []byte("chain/tip")
blockPrefix = []byte("chain/block/")
heightPrefix = []byte("chain/height/")
)
// ErrNoBlock reports that no block is known by that id or at that height.
var ErrNoBlock = errors.New("chain: no such block")
// New opens a store over db.
//
// reload rebuilds whatever the chain caches in memory from committed state. It
// is called after a failed apply, so the caches say what the database says
// rather than what the abandoned block said. A chain that mutates nothing in
// memory before the commit has nothing to rebuild and passes nil.
func New(db database.Database, reload func() error) *Store {
return &Store{
base: db,
view: versiondb.New(db),
reload: reload,
flight: make(map[ids.ID]Block),
}
}
// View is what a block writes through, and what every read sees: committed
// state plus whatever the block in progress has staged.
func (s *Store) View() database.Database { return s.view }
// Base is committed state, for the writes a chain makes outside any block. A
// write here is durable at once and belongs to no block, so no rollback takes
// it back — which is right for a record no block claims and wrong for one a
// block does.
func (s *Store) Base() database.Database { return s.base }
// Accept applies b and commits it.
//
// Every write b makes goes through the view and lands in ONE commit, together
// with the block itself, its height entry and the tip pointer. So the chain
// has the whole block or none of it. Any failure rolls the view back, rebuilds
// the caches from committed state, and leaves the tip where it was: nothing
// the block claimed survives, and the chain does not believe it happened.
//
// The block's own effects become visible last, after the commit, so there is
// no window in which the chain has advanced past state that is not on disk.
func (s *Store) Accept(b Block) error {
s.Lock()
defer s.Unlock()
id := b.ID()
raw := b.Bytes()
if len(raw) == 0 {
return s.undo(fmt.Errorf("chain: block %s has no encoding", id))
}
if err := b.Write(s.view); err != nil {
return s.undo(err)
}
if err := s.view.Put(blockKey(id), raw); err != nil {
return s.undo(err)
}
if err := s.view.Put(heightKey(b.Height()), id[:]); err != nil {
return s.undo(err)
}
if err := s.view.Put(tipKey, id[:]); err != nil {
return s.undo(err)
}
if err := s.view.Commit(); err != nil {
return s.undo(fmt.Errorf("chain: commit block %s: %w", id, err))
}
s.tip, s.height = id, b.Height()
delete(s.flight, id)
s.prune()
b.Publish()
return nil
}
// undo discards everything the block staged and puts the caches back to what
// committed state says. The caller gets the block's own error; a reload that
// also fails is joined to it, because a chain that cannot re-read its own
// state has a second problem and reporting only the first hides it.
func (s *Store) undo(cause error) error {
s.view.Abort()
if s.reload != nil {
if err := s.reload(); err != nil {
return errors.Join(cause, err)
}
}
return cause
}
// Propose hands the caller the tip to build on and tracks whatever it builds,
// in one step. Reading the tip and registering the child as two steps leaves a
// window in which a block is accepted between them, and the proposal is then
// built on a parent that is no longer the tip.
//
// A build that has nothing to propose returns a nil block, which is not an
// error and is not tracked.
func (s *Store) Propose(build func(parent ids.ID, height uint64) (Block, error)) (Block, error) {
s.Lock()
defer s.Unlock()
b, err := build(s.tip, s.height)
if err != nil || b == nil {
return nil, err
}
s.track(b)
return b, nil
}
// Track makes a block findable by id while it is in flight, so a child can
// resolve it as a parent — whether this node built the block or parsed it from
// a peer. Tracking only what a node builds leaves a follower able to verify
// the first block of a run and unable to verify the second.
func (s *Store) Track(b Block) {
s.Lock()
defer s.Unlock()
s.track(b)
}
func (s *Store) track(b Block) {
if b.Height() <= s.height {
return
}
s.prune()
s.flight[b.ID()] = b
}
// prune drops every block in flight at or below the accepted height. Nothing
// else will: the engine may abandon a block without ever accepting or
// rejecting it, so a set that only grew would leak. Anything at or below the
// tip is already decided or orphaned, which bounds this to the blocks actually
// in flight above it. Caller holds the lock.
func (s *Store) prune() {
for id, b := range s.flight {
if b.Height() <= s.height {
delete(s.flight, id)
}
}
}
// Drop forgets a block in flight. A rejected block is one the chain will not
// build on, and it never wrote anything, so there is nothing else to undo.
func (s *Store) Drop(id ids.ID) {
s.Lock()
defer s.Unlock()
delete(s.flight, id)
}
// Tip is the accepted block's id and height.
func (s *Store) Tip() (ids.ID, uint64) {
s.RLock()
defer s.RUnlock()
return s.tip, s.height
}
// Accepted reports whether id is the accepted tip or a block committed beneath
// it. A block in flight is neither.
func (s *Store) Accepted(id ids.ID) bool {
s.RLock()
defer s.RUnlock()
if id == s.tip {
return true
}
ok, _ := s.view.Has(blockKey(id))
return ok
}
// Block returns a block by id: one in flight, or one read back from committed
// state and decoded by parse.
func (s *Store) Block(id ids.ID, parse func([]byte) (Block, error)) (Block, error) {
s.RLock()
defer s.RUnlock()
if b, ok := s.flight[id]; ok {
return b, nil
}
raw, err := s.view.Get(blockKey(id))
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrNoBlock, id)
}
return parse(raw)
}
// IDAtHeight names the block accepted at height, from the index written in the
// same commit as the block itself — so the index can never name a block the
// chain did not accept.
func (s *Store) IDAtHeight(height uint64) (ids.ID, error) {
s.RLock()
defer s.RUnlock()
raw, err := s.view.Get(heightKey(height))
if err != nil {
return ids.Empty, fmt.Errorf("%w: height %d", ErrNoBlock, height)
}
return ids.ToID(raw)
}
// Open sets the chain's starting point: the tip recorded in committed state,
// or genesis if nothing is recorded. It reports which, so a chain that seeds
// state on its first run can tell its first run from every later one.
func (s *Store) Open(genesis Block, parse func([]byte) (Block, error)) (Block, bool, error) {
s.Lock()
defer s.Unlock()
raw, err := s.view.Get(tipKey)
if err != nil || len(raw) != ids.IDLen {
s.tip, s.height = genesis.ID(), genesis.Height()
return genesis, true, nil
}
id, err := ids.ToID(raw)
if err != nil {
return nil, false, err
}
if id == genesis.ID() {
s.tip, s.height = id, genesis.Height()
return genesis, false, nil
}
block, err := s.view.Get(blockKey(id))
if err != nil {
return nil, false, fmt.Errorf("%w: tip %s", ErrNoBlock, id)
}
b, err := parse(block)
if err != nil {
return nil, false, err
}
s.tip, s.height = id, b.Height()
return b, false, nil
}
// Close releases the store. The view is closed rather than committed: anything
// still staged belongs to a block that was never accepted.
func (s *Store) Close() error {
s.Lock()
defer s.Unlock()
return s.view.Close()
}
func blockKey(id ids.ID) []byte {
return append(append([]byte(nil), blockPrefix...), id[:]...)
}
func heightKey(h uint64) []byte {
var u [8]byte
binary.BigEndian.PutUint64(u[:], h)
return append(append([]byte(nil), heightPrefix...), u[:]...)
}
+470
View File
@@ -0,0 +1,470 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package chain
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/luxfi/database"
"github.com/luxfi/database/memdb"
"github.com/luxfi/ids"
)
// ---- a block that records what was asked of it ----
// testBlock writes one record per name it carries and counts its own Publish.
// Splitting Write from Publish is the property under test, so the test block
// keeps them honestly separate: Write only writes, Publish only publishes.
type testBlock struct {
id ids.ID
parent ids.ID
height uint64
records [][]byte
failAfter int // records to write before failing; -1 never fails
published int
raw []byte
}
func newBlock(id byte, parent ids.ID, height uint64, records ...string) *testBlock {
b := &testBlock{
id: ids.ID{id},
parent: parent,
height: height,
failAfter: -1,
raw: []byte{id, byte(height)},
}
for _, r := range records {
b.records = append(b.records, []byte(r))
}
return b
}
var errWriteRefused = errors.New("write refused")
func (b *testBlock) ID() ids.ID { return b.id }
func (b *testBlock) Parent() ids.ID { return b.parent }
func (b *testBlock) ParentID() ids.ID { return b.parent }
func (b *testBlock) Height() uint64 { return b.height }
func (b *testBlock) Timestamp() time.Time { return time.Unix(int64(b.height), 0) }
func (b *testBlock) Status() uint8 { return 0 }
func (b *testBlock) Bytes() []byte { return b.raw }
func (b *testBlock) Verify(context.Context) error { return nil }
func (b *testBlock) Accept(context.Context) error { return nil }
func (b *testBlock) Reject(context.Context) error { return nil }
func (b *testBlock) Write(db database.Database) error {
for i, r := range b.records {
if b.failAfter >= 0 && i == b.failAfter {
return errWriteRefused
}
if err := db.Put(r, []byte{byte(b.height)}); err != nil {
return err
}
}
return nil
}
func (b *testBlock) Publish() { b.published++ }
// ---- a database that can refuse ----
// refusingDB is a real database that fails its batch write once armed, which
// is how a commit fails in the field: the staged writes are complete and the
// flush to disk is what does not happen.
type refusingDB struct {
database.Database
refuseCommit bool
}
func (d *refusingDB) NewBatch() database.Batch {
return &refusingBatch{Batch: d.Database.NewBatch(), db: d}
}
type refusingBatch struct {
database.Batch
db *refusingDB
}
var errCommitRefused = errors.New("commit refused")
func (b *refusingBatch) Write() error {
if b.db.refuseCommit {
return errCommitRefused
}
return b.Batch.Write()
}
// ---- fixture ----
type fixture struct {
base *refusingDB
store *Store
reloads int
genesis *testBlock
}
func newFixture(t *testing.T) *fixture {
t.Helper()
f := &fixture{base: &refusingDB{Database: memdb.New()}}
f.store = New(f.base, func() error { f.reloads++; return nil })
f.genesis = newBlock(0, ids.Empty, 0)
_, fresh, err := f.store.Open(f.genesis, f.parse)
require.NoError(t, err)
require.True(t, fresh)
return f
}
func (f *fixture) parse(raw []byte) (Block, error) {
if len(raw) != 2 {
return nil, errors.New("bad block")
}
return newBlock(raw[0], ids.Empty, uint64(raw[1])), nil
}
// committed reads straight from the base database, behind the view, so it says
// what actually landed rather than what is merely staged.
func (f *fixture) committed(t *testing.T, key []byte) bool {
t.Helper()
ok, err := f.base.Database.Has(key)
require.NoError(t, err)
return ok
}
// ---- the discipline ----
func TestAcceptCommitsTheBlockAndItsWritesTogether(t *testing.T) {
f := newFixture(t)
b := newBlock(1, f.genesis.ID(), 1, "alpha", "beta")
require.NoError(t, f.store.Accept(b))
// Everything the block touched is in committed state, not merely staged:
// the records it wrote, the block itself, its height entry and the tip.
require.True(t, f.committed(t, []byte("alpha")))
require.True(t, f.committed(t, []byte("beta")))
require.True(t, f.committed(t, blockKey(b.ID())))
require.True(t, f.committed(t, heightKey(1)))
require.True(t, f.committed(t, tipKey))
tip, height := f.store.Tip()
require.Equal(t, b.ID(), tip)
require.Equal(t, uint64(1), height)
require.Equal(t, 1, b.published)
require.Zero(t, f.reloads, "nothing failed, so nothing was reloaded")
}
func TestAFailedWriteLeavesNothingAndDoesNotAdvance(t *testing.T) {
f := newFixture(t)
before, beforeHeight := f.store.Tip()
b := newBlock(1, f.genesis.ID(), 1, "alpha", "beta", "gamma")
b.failAfter = 2 // alpha and beta are staged, gamma refuses
require.ErrorIs(t, f.store.Accept(b), errWriteRefused)
// The two writes that succeeded are the whole point: they were staged, and
// staged is not landed.
require.False(t, f.committed(t, []byte("alpha")), "a discarded block leaves no record")
require.False(t, f.committed(t, []byte("beta")))
require.False(t, f.committed(t, []byte("gamma")))
require.False(t, f.committed(t, blockKey(b.ID())))
require.False(t, f.committed(t, heightKey(1)))
require.False(t, f.committed(t, tipKey))
// And the chain does not believe the block happened.
tip, height := f.store.Tip()
require.Equal(t, before, tip)
require.Equal(t, beforeHeight, height)
require.Zero(t, b.published, "a block that did not commit publishes nothing")
require.Equal(t, 1, f.reloads, "the caches are rebuilt from what committed")
// The writes that succeeded are still only staged, and staged writes that
// are not discarded ride along on whatever commits next. That is the shape
// the "landed nothing" check above cannot see, because a staged write and a
// discarded one look identical until something commits.
next := newBlock(2, f.genesis.ID(), 1, "delta")
require.NoError(t, f.store.Accept(next))
require.False(t, f.committed(t, []byte("alpha")), "the abandoned block's writes did not ride along")
require.False(t, f.committed(t, []byte("beta")))
require.True(t, f.committed(t, []byte("delta")))
}
func TestAFailedCommitLeavesNothingAndDoesNotAdvance(t *testing.T) {
f := newFixture(t)
before, beforeHeight := f.store.Tip()
b := newBlock(1, f.genesis.ID(), 1, "alpha", "beta")
f.base.refuseCommit = true
require.ErrorIs(t, f.store.Accept(b), errCommitRefused)
require.False(t, f.committed(t, []byte("alpha")))
require.False(t, f.committed(t, []byte("beta")))
require.False(t, f.committed(t, blockKey(b.ID())))
require.False(t, f.committed(t, tipKey))
tip, height := f.store.Tip()
require.Equal(t, before, tip)
require.Equal(t, beforeHeight, height)
require.Zero(t, b.published)
require.Equal(t, 1, f.reloads)
// The staged writes are gone from the view too, so the next block does not
// inherit them.
f.base.refuseCommit = false
next := newBlock(2, f.genesis.ID(), 1, "delta")
require.NoError(t, f.store.Accept(next))
require.False(t, f.committed(t, []byte("alpha")), "the abandoned block's writes did not ride along")
require.True(t, f.committed(t, []byte("delta")))
}
func TestABlockWithNoEncodingIsRefusedWhole(t *testing.T) {
f := newFixture(t)
b := newBlock(1, f.genesis.ID(), 1, "alpha")
b.raw = nil
require.Error(t, f.store.Accept(b))
require.False(t, f.committed(t, []byte("alpha")))
tip, _ := f.store.Tip()
require.Equal(t, f.genesis.ID(), tip)
}
func TestAReloadFailureIsReportedBesideItsCause(t *testing.T) {
base := &refusingDB{Database: memdb.New()}
broken := errors.New("caches unreadable")
store := New(base, func() error { return broken })
genesis := newBlock(0, ids.Empty, 0)
_, _, err := store.Open(genesis, func([]byte) (Block, error) { return nil, nil })
require.NoError(t, err)
b := newBlock(1, genesis.ID(), 1, "alpha")
b.failAfter = 0
err = store.Accept(b)
require.ErrorIs(t, err, errWriteRefused, "the cause is still reported")
require.ErrorIs(t, err, broken, "and so is the failure to recover from it")
}
func TestTheChainStillWorksAfterAFailedBlock(t *testing.T) {
f := newFixture(t)
doomed := newBlock(1, f.genesis.ID(), 1, "alpha")
doomed.failAfter = 0
require.ErrorIs(t, f.store.Accept(doomed), errWriteRefused)
good := newBlock(2, f.genesis.ID(), 1, "beta")
require.NoError(t, f.store.Accept(good))
tip, height := f.store.Tip()
require.Equal(t, good.ID(), tip)
require.Equal(t, uint64(1), height)
require.True(t, f.committed(t, []byte("beta")))
}
// ---- blocks in flight ----
func TestTrackMakesABlockFindableAndPrunesWhatIsDecided(t *testing.T) {
f := newFixture(t)
one := newBlock(1, f.genesis.ID(), 1, "alpha")
two := newBlock(2, one.ID(), 2, "beta")
f.store.Track(one)
f.store.Track(two)
// A child resolves its parent while the parent is still in flight, which is
// what lets a follower verify a run of blocks rather than only the first.
got, err := f.store.Block(one.ID(), f.parse)
require.NoError(t, err)
require.Equal(t, one.ID(), got.ID())
// A competitor at the same height that the engine simply abandons — it is
// never accepted and never rejected, so nothing but pruning will ever
// remove it.
orphan := newBlock(7, f.genesis.ID(), 1, "orphan")
f.store.Track(orphan)
require.NoError(t, f.store.Accept(one))
// Accepting height 1 decides everything at or below it.
f.store.RLock()
_, stillOne := f.store.flight[one.ID()]
_, stillOrphan := f.store.flight[orphan.ID()]
_, stillTwo := f.store.flight[two.ID()]
f.store.RUnlock()
require.False(t, stillOne, "an accepted block is no longer in flight")
require.False(t, stillOrphan, "and neither is one the engine abandoned at that height")
require.True(t, stillTwo, "a block above the tip is still in flight")
// The accepted one is now found in committed state instead.
got, err = f.store.Block(one.ID(), f.parse)
require.NoError(t, err)
require.Equal(t, one.ID(), got.ID())
}
func TestABlockAtOrBelowTheTipIsNeverTracked(t *testing.T) {
f := newFixture(t)
one := newBlock(1, f.genesis.ID(), 1)
require.NoError(t, f.store.Accept(one))
f.store.Track(newBlock(9, ids.Empty, 1))
f.store.Track(newBlock(8, ids.Empty, 0))
f.store.RLock()
n := len(f.store.flight)
f.store.RUnlock()
require.Zero(t, n, "a block the chain has already passed is not in flight")
}
func TestDropForgetsABlockInFlight(t *testing.T) {
f := newFixture(t)
b := newBlock(1, f.genesis.ID(), 1)
f.store.Track(b)
f.store.Drop(b.ID())
_, err := f.store.Block(b.ID(), f.parse)
require.ErrorIs(t, err, ErrNoBlock)
}
func TestProposeBuildsOnTheTipAndTracksWhatItBuilt(t *testing.T) {
f := newFixture(t)
first := newBlock(1, f.genesis.ID(), 1)
require.NoError(t, f.store.Accept(first))
var sawParent ids.ID
var sawHeight uint64
built, err := f.store.Propose(func(parent ids.ID, height uint64) (Block, error) {
sawParent, sawHeight = parent, height
return newBlock(2, parent, height+1), nil
})
require.NoError(t, err)
require.Equal(t, first.ID(), sawParent)
require.Equal(t, uint64(1), sawHeight)
got, err := f.store.Block(built.ID(), f.parse)
require.NoError(t, err)
require.Equal(t, built.ID(), got.ID())
}
func TestProposingNothingIsNotAnErrorAndTracksNothing(t *testing.T) {
f := newFixture(t)
built, err := f.store.Propose(func(ids.ID, uint64) (Block, error) { return nil, nil })
require.NoError(t, err)
require.Nil(t, built)
f.store.RLock()
n := len(f.store.flight)
f.store.RUnlock()
require.Zero(t, n)
}
// ---- what committed state answers ----
func TestIDAtHeightNamesOnlyAcceptedBlocks(t *testing.T) {
f := newFixture(t)
one := newBlock(1, f.genesis.ID(), 1)
require.NoError(t, f.store.Accept(one))
got, err := f.store.IDAtHeight(1)
require.NoError(t, err)
require.Equal(t, one.ID(), got)
// A block merely in flight has no height entry: the index is written in the
// same commit as the block, so it cannot name one the chain did not accept.
f.store.Track(newBlock(2, one.ID(), 2))
_, err = f.store.IDAtHeight(2)
require.ErrorIs(t, err, ErrNoBlock)
}
func TestAcceptedIsTrueOfTheTipAndOfWhatIsBeneathIt(t *testing.T) {
f := newFixture(t)
one := newBlock(1, f.genesis.ID(), 1)
two := newBlock(2, one.ID(), 2)
require.NoError(t, f.store.Accept(one))
require.NoError(t, f.store.Accept(two))
require.True(t, f.store.Accepted(two.ID()), "the tip")
require.True(t, f.store.Accepted(one.ID()), "and everything under it")
inFlight := newBlock(3, two.ID(), 3)
f.store.Track(inFlight)
require.False(t, f.store.Accepted(inFlight.ID()), "but not one still in flight")
}
func TestOpenResumesFromTheRecordedTip(t *testing.T) {
f := newFixture(t)
one := newBlock(1, f.genesis.ID(), 1)
require.NoError(t, f.store.Accept(one))
// A second store over the same database is a restart.
restarted := New(f.base, nil)
at, fresh, err := restarted.Open(f.genesis, f.parse)
require.NoError(t, err)
require.False(t, fresh, "this chain has run before")
require.Equal(t, one.ID(), at.ID())
tip, height := restarted.Tip()
require.Equal(t, one.ID(), tip)
require.Equal(t, uint64(1), height)
}
func TestOpenOnAnEmptyStoreStartsAtGenesisAndSaysSo(t *testing.T) {
store := New(memdb.New(), nil)
genesis := newBlock(0, ids.Empty, 0)
at, fresh, err := store.Open(genesis, func([]byte) (Block, error) { return nil, errors.New("unreachable") })
require.NoError(t, err)
require.True(t, fresh)
require.Equal(t, genesis.ID(), at.ID())
tip, height := store.Tip()
require.Equal(t, genesis.ID(), tip)
require.Zero(t, height)
}
func TestAnUnknownBlockIsReportedAsMissing(t *testing.T) {
f := newFixture(t)
_, err := f.store.Block(ids.ID{99}, f.parse)
require.ErrorIs(t, err, ErrNoBlock)
}
// TestConcurrentReadersDoNotRaceAnAccept runs under -race, where a second
// mutex over any of the store's state shows up as a data race rather than as a
// rare wrong answer.
func TestConcurrentReadersDoNotRaceAnAccept(t *testing.T) {
f := newFixture(t)
done := make(chan struct{})
for i := 0; i < 4; i++ {
go func() {
for {
select {
case <-done:
return
default:
f.store.Tip()
f.store.Accepted(ids.ID{1})
_, _ = f.store.IDAtHeight(1)
_, _ = f.store.Block(ids.ID{1}, f.parse)
}
}
}()
}
parent := f.genesis.ID()
for h := uint64(1); h <= 50; h++ {
b := newBlock(byte(h), parent, h)
f.store.Track(b)
require.NoError(t, f.store.Accept(b))
parent = b.ID()
}
close(done)
_, height := f.store.Tip()
require.Equal(t, uint64(50), height)
}
-343
View File
@@ -1,343 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package quantumvm
// Q-Chain GPU substrate — host-side ABI v14 plugin bridge for batched
// post-quantum signature verification + signing.
//
// The kernels live in the GPU plugin install tree under
// ops/crypto/{mldsa,slhdsa}/<backend>/ and are shipped inside the
// libluxgpu_backend_<x>.{so,dylib} plugin shared libraries. The Go side
// dlopens whichever plugin is on disk at process start, populates the
// ABI v14 backend descriptor via the `lux_gpu_backend_init` entry point
// (see the lux GPU plugin include/lux/gpu/backend_plugin.h), and
// invokes the op_mldsa_verify_batch / op_mldsa_sign_batch /
// op_slhdsa_verify_batch slots from the resulting vtbl.
//
// Decomplecting from cevm/cevm_cgo.go: cevm links its kernel bundle via
// pkg-config (lux-cevm.pc → -levm -levm-gpu …) and reaches the C ABI
// through extern "C" gpu_* helpers. Q-Chain takes the opposite tack —
// no pkg-config, no static link, no header-bound C ABI helpers. The
// plugin is OPTIONAL by design: when no libluxgpu_backend_*.{so,dylib}
// is on the dlopen search path, AutoBackend() returns BackendNone and
// every GPUBackend method returns ErrGPUNotAvailable so the existing
// CPU verify path stays unchanged.
//
// Pattern (mirrors evm/cevm + evm/backend_cgo.go split):
//
// backend.go — shared types: Backend enum, GPUBackend
// interface, ErrGPUNotAvailable, active
// backend atomic + AutoBackend(). Build-
// tag-free; the runtime probe lives in
// the cgo/nocgo files below.
// quantumvm_gpu.go cgo — dlopen + dlsym of lux_gpu_backend_init,
// vtbl slot invocations.
// quantumvm_gpu_nocgo.go !cgo — stub returning ErrGPUNotAvailable.
//
// This file is build-tag-free so vm.go can reference Backend, GPUBackend,
// AutoBackend(), and ErrGPUNotAvailable regardless of CGO_ENABLED. The
// implementation behind ActiveGPUBackend() switches based on the build
// tag (real plugin handle vs. stub returning ErrGPUNotAvailable).
import (
"errors"
"fmt"
"sync/atomic"
)
// Backend identifies which GPU plugin satisfied the runtime dlopen probe.
//
// Probe order is fixed by the spec: cuda → hip → metal → vulkan → webgpu.
// The first plugin that resolves the `lux_gpu_backend_init` entry point and
// reports a v14 vtbl with the three Q-Chain slots (op_mldsa_verify_batch,
// op_mldsa_sign_batch, op_slhdsa_verify_batch) wins; remaining probes are
// skipped. Each plugin is fully self-contained — there is no fallback chain
// once one is chosen (this keeps backend selection deterministic across
// reboots, matching consensus-safety expectations).
type Backend uint8
const (
// BackendNone means no GPU plugin is loaded — every GPUBackend method
// returns ErrGPUNotAvailable. This is the value reported by
// AutoBackend() under !cgo, and under cgo when no
// libluxgpu_backend_*.{so,dylib} was findable.
BackendNone Backend = 0
// BackendCUDA selects libluxgpu_backend_cuda.so (NVIDIA, Linux/Windows).
BackendCUDA Backend = 1
// BackendHIP selects libluxgpu_backend_hip.so (AMD ROCm, Linux/Windows).
BackendHIP Backend = 2
// BackendMetal selects libluxgpu_backend_metal.dylib (Apple, darwin).
BackendMetal Backend = 3
// BackendVulkan selects libluxgpu_backend_vulkan.{so,dylib} (portable).
BackendVulkan Backend = 4
// BackendWebGPU selects libluxgpu_backend_webgpu.{so,dylib} (portable).
BackendWebGPU Backend = 5
)
// String returns the canonical lowercase name of the backend. The same
// name appears in the plugin DSO filename (libluxgpu_backend_<name>.*)
// and in the lux_gpu_backend_desc.backend_name field reported by the
// loaded plugin.
func (b Backend) String() string {
switch b {
case BackendNone:
return "none"
case BackendCUDA:
return "cuda"
case BackendHIP:
return "hip"
case BackendMetal:
return "metal"
case BackendVulkan:
return "vulkan"
case BackendWebGPU:
return "webgpu"
default:
return fmt.Sprintf("unknown(%d)", uint8(b))
}
}
// ErrGPUNotAvailable is returned by every GPUBackend method when no plugin
// is loaded — either because the binary was built without CGo, no
// libluxgpu_backend_*.{so,dylib} was findable on the dlopen search path,
// the loaded plugin reported a non-v14 ABI version, or its vtbl was
// missing one of the three Q-Chain slots.
//
// The error is sentinel-comparable via errors.Is so callers can route
// the CPU verify path cleanly:
//
// if errors.Is(err, quantumvm.ErrGPUNotAvailable) {
// return cpuVerify(pk, msg, sig)
// }
var ErrGPUNotAvailable = errors.New("quantumvm: GPU backend not available")
// MLDSAMode selects which ML-DSA security parameter set the batch
// targets. The ABI v14 vtbl currently exposes only ML-DSA-65 (mode 3,
// FIPS 204 Dilithium3) — modes 44 and 87 will land at their own vtbl
// slots in a future ABI bump (see backend_plugin.h v14 block). Callers
// pass the mode through so the wrapper can validate batch byte widths
// at the boundary, even though only MLDSA65 round-trips through the
// plugin today.
type MLDSAMode uint8
const (
// MLDSAMode44 is FIPS 204 Dilithium2. Not yet wired through the v14
// vtbl — passing this to a batch method returns ErrGPUNotAvailable
// (caller falls through to the CPU path).
MLDSAMode44 MLDSAMode = 1
// MLDSAMode65 is FIPS 204 Dilithium3 — the canonical Q-Chain mode.
// pk=1952 B, sig=3309 B (max), sk=4032 B.
MLDSAMode65 MLDSAMode = 2
// MLDSAMode87 is FIPS 204 Dilithium5. Not yet wired through the v14
// vtbl — passing this returns ErrGPUNotAvailable.
MLDSAMode87 MLDSAMode = 3
)
// ML-DSA-65 byte sizes per FIPS 204 §4. Pinned constants — the plugin
// rejects mismatched sk_stride at the boundary, so we surface them up
// front to keep the call sites unambiguous.
const (
MLDSA65PublicKeySize = 1952
MLDSA65SecretKeySize = 4032
MLDSA65SignatureSize = 3309
)
// SLHDSAVariant enumerates the 12 FIPS 205 SLH-DSA parameter sets. Each
// variant has its own (pk, sig) byte width — surfaced via PublicKeySize()
// and SignatureSize() so callers can build the batch arrays correctly.
//
// At ABI v14 only the SHAKE-128f and SHAKE-192f verify slots are wired
// (op_slhdsa_verify_batch and op_slhdsa_verify_batch_shake192f). The
// remaining 10 variants land at their own vtbl slots in future ABI
// bumps; passing them today returns ErrGPUNotAvailable.
type SLHDSAVariant uint8
const (
SLHDSAShake128f SLHDSAVariant = iota
SLHDSAShake128s
SLHDSAShake192f
SLHDSAShake192s
SLHDSAShake256f
SLHDSAShake256s
SLHDSASha2128f
SLHDSASha2128s
SLHDSASha2192f
SLHDSASha2192s
SLHDSASha2256f
SLHDSASha2256s
)
// String returns the FIPS 205 canonical name of the variant.
func (v SLHDSAVariant) String() string {
switch v {
case SLHDSAShake128f:
return "shake-128f"
case SLHDSAShake128s:
return "shake-128s"
case SLHDSAShake192f:
return "shake-192f"
case SLHDSAShake192s:
return "shake-192s"
case SLHDSAShake256f:
return "shake-256f"
case SLHDSAShake256s:
return "shake-256s"
case SLHDSASha2128f:
return "sha2-128f"
case SLHDSASha2128s:
return "sha2-128s"
case SLHDSASha2192f:
return "sha2-192f"
case SLHDSASha2192s:
return "sha2-192s"
case SLHDSASha2256f:
return "sha2-256f"
case SLHDSASha2256s:
return "sha2-256s"
default:
return fmt.Sprintf("slhdsa(%d)", uint8(v))
}
}
// PublicKeySize returns the FIPS 205 byte width of an SLH-DSA public key
// for this variant. {128,192,256}{f,s} all share the same width per
// security level (32 / 48 / 64 bytes).
func (v SLHDSAVariant) PublicKeySize() int {
switch v {
case SLHDSAShake128f, SLHDSAShake128s, SLHDSASha2128f, SLHDSASha2128s:
return 32
case SLHDSAShake192f, SLHDSAShake192s, SLHDSASha2192f, SLHDSASha2192s:
return 48
case SLHDSAShake256f, SLHDSAShake256s, SLHDSASha2256f, SLHDSASha2256s:
return 64
default:
return 0
}
}
// SignatureSize returns the FIPS 205 byte width of an SLH-DSA signature
// for this variant. The 12 widths are fixed by parameter set; see the
// FIPS 205 standard §11 width table.
func (v SLHDSAVariant) SignatureSize() int {
switch v {
case SLHDSAShake128f, SLHDSASha2128f:
return 17088
case SLHDSAShake128s, SLHDSASha2128s:
return 7856
case SLHDSAShake192f, SLHDSASha2192f:
return 35664
case SLHDSAShake192s, SLHDSASha2192s:
return 16224
case SLHDSAShake256f, SLHDSASha2256f:
return 49856
case SLHDSAShake256s, SLHDSASha2256s:
return 29792
default:
return 0
}
}
// GPUBackend is the narrow Q-Chain surface that vm.go's batch verify /
// sign paths can opt into. Three 1:1 mappings to the ABI v14 vtbl slots,
// plus Backend() / Close() lifecycle.
//
// Buffer ownership: callers own every slice / struct passed in. The vtbl
// slot does H2D / D2H internally. On return every output slice has been
// overwritten with the kernel's result; the caller can read immediately,
// no further sync needed.
//
// Composition: the three batch methods are orthogonal — a "round" that
// verifies a mixed batch of ML-DSA + SLH-DSA stamps composes them in
// the round applier. We do NOT try to express a "verify all PQ sigs"
// supercall here; that would couple the round-level policy to the
// substrate, and the substrate intentionally only exposes primitives.
type GPUBackend interface {
// MLDSAVerifyBatch verifies a batch of ML-DSA signatures. mode pins
// the FIPS 204 parameter set; only MLDSAMode65 is wired through the
// v14 vtbl (modes 44/87 return ErrGPUNotAvailable). pubkeys[i],
// messages[i], and signatures[i] supply the per-element inputs;
// msgLens[i] is the byte length of messages[i] (NULL slice means
// every message is exactly msgWidthHint bytes — see the
// op_mldsa_verify_batch comment in backend_plugin.h for the
// uniform-batch convenience contract). results[i] is set to true
// iff verification succeeds.
//
// The slices must all have the same length; mismatched lengths are
// caught at the boundary and returned as an error (no UB into C).
MLDSAVerifyBatch(
mode MLDSAMode,
pubkeys [][]byte,
messages [][]byte,
msgLens []int,
msgWidthHint uint32,
signatures [][]byte,
results []bool,
) error
// MLDSASignBatch signs a batch of messages with packed ML-DSA secret
// keys. mode pins the FIPS 204 parameter set; only MLDSAMode65 is
// wired through the v14 vtbl. skeys is the contiguous pool of
// per-element packed secret keys (each MLDSA65SecretKeySize = 4032
// bytes); the wrapper feeds the pool through cudaMemcpy2D directly
// in one shot. msgs is the contiguous pool of per-element message
// payloads (msgLens[i] bytes from offset sum(msgLens[0..i-1])).
// sigsOut receives count × MLDSA65SignatureSize bytes; sigLensOut
// returns the per-element actual signature length (3309 on accept,
// 0 on kappa-cap reject — see backend_plugin.h v14 block).
MLDSASignBatch(
mode MLDSAMode,
skeys []byte,
msgs []byte,
msgLens []int,
msgWidthHint uint32,
count int,
sigsOut []byte,
sigLensOut []uint32,
) error
// SLHDSAVerifyBatch verifies a batch of SLH-DSA signatures. variant
// pins the FIPS 205 parameter set; only SHAKE-128f and SHAKE-192f
// are wired through the v14 vtbl (the other 10 variants return
// ErrGPUNotAvailable). pubkeys[i], messages[i], and signatures[i]
// supply the per-element inputs at the FIPS 205 widths reported by
// variant.PublicKeySize() / variant.SignatureSize(); msgLens[i] is
// the byte length of messages[i] (any value in [0, INT32_MAX-2]).
// results[i] is set to true iff verification succeeds.
SLHDSAVerifyBatch(
variant SLHDSAVariant,
pubkeys [][]byte,
messages [][]byte,
msgLens []int,
signatures [][]byte,
results []bool,
) error
// Backend reports which plugin is currently loaded.
Backend() Backend
// Close releases the dlopen handle and the LuxBackendContext. Safe
// on a nil receiver and idempotent.
Close() error
}
// activeBackend is set by the init() in quantumvm_gpu.go (cgo) or
// quantumvm_gpu_nocgo.go (!cgo). Read via AutoBackend(). Stored as an
// atomic uint32 so concurrent reads from vm.go's batch paths don't race
// with the init() store.
var activeBackend atomic.Uint32
// AutoBackend returns the GPU plugin chosen by the dlopen probe at process
// start. BackendNone means no plugin is loaded — callers should route to
// the CPU verify path. The probe runs exactly once at init time; this
// getter is one atomic load and safe to call from any goroutine.
func AutoBackend() Backend {
return Backend(uint8(activeBackend.Load()))
}
// setActiveBackend records the probe result. Called by init() in the
// cgo / nocgo files. Not exported — there is one and only one way to
// load the plugin (the init probe at process start).
func setActiveBackend(b Backend) {
activeBackend.Store(uint32(uint8(b)))
}
-918
View File
@@ -1,918 +0,0 @@
//go:build cgo
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package quantumvm GPU backend — runtime-loaded plugin bridge via the
// ABI v14 vtbl entry point.
//
// Unlike cevm/cevm_cgo.go (which links libevm + libevm-gpu via pkg-config
// and reaches the C ABI through extern "C" gpu_* helpers), Q-Chain
// resolves its GPU substrate at PROCESS START via dlopen / dlsym against
// the lux-gpu-kernels plugin DSO and reaches the per-op kernel through
// function pointers inside a vtbl populated by the plugin's
// `lux_gpu_backend_init` entry point. This keeps the chains module
// compilable without the lux GPU plugin present in the build tree —
// the plugin is fully optional. When no libluxgpu_backend_*.{so,dylib}
// is findable on the dlopen search path, AutoBackend() returns
// BackendNone, every GPUBackend method returns ErrGPUNotAvailable, and
// the existing CPU verify path stays unchanged.
//
// Probe order (init()): cuda → hip → metal → vulkan → webgpu. First
// plugin that resolves the entry point AND reports a v14 ABI matching
// the header we compiled against AND exposes the three Q-Chain slots
// (op_mldsa_verify_batch, op_mldsa_sign_batch,
// op_slhdsa_verify_batch[_shake192f]) wins; remaining probes are
// skipped. The cookie checks (abi_version + vtbl_size) defend against
// a plugin that lies about its ABI version while shipping a truncated
// vtable (see backend_plugin.h §"Backend Descriptor" comment).
//
// Search path: the dlopen library name is the bare basename
// (libluxgpu_backend_<x>.{so,dylib}); the loader's standard
// LD_LIBRARY_PATH / DYLD_LIBRARY_PATH / rpath resolution finds it.
// LUX_GPU_PLUGIN_DIR overrides — if set, every probe joins it to the
// basename before calling dlopen.
package quantumvm
/*
#cgo darwin LDFLAGS: -ldl
#cgo linux LDFLAGS: -ldl
// The plugin DSO is dlopen'd at runtime; the only compile-time
// dependency is the C struct layout in backend_plugin.h, which is
// vendored under chains/internal/luxgpu/include/lux/gpu/ as the public
// ABI surface. No link against any external library.
#cgo CFLAGS: -I${SRCDIR}/../internal/luxgpu/include
#include <dlfcn.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include "lux/gpu/backend_plugin.h"
// ABI v14 vtbl entry-point thunk. dlsym returns void*; this struct lets
// Go pass the function pointer back through the bridge without losing
// type information at the cgo boundary.
typedef bool (*qvm_backend_init_fn)(lux_gpu_backend_desc* out);
// qvm_backend_init invokes the plugin's `lux_gpu_backend_init` symbol
// and returns 0 on success, non-zero (1..5) on a specific cookie /
// slot failure. The cookies (abi_version + vtbl_size) detect plugins
// built against a different header — fail fast at process start
// instead of UB at the first vtbl slot call (a stale plugin's
// op_mldsa_verify_batch at the wrong offset would be a junk function
// pointer dispatch otherwise).
//
// ret == 0 — desc populated AND all three Q-Chain slots non-NULL.
// ret == 1 — init function returned false (no runtime available on
// this host: missing driver, ICD, etc.). Caller treats
// as SKIP, not FAIL.
// ret == 2 — abi_version mismatch (plugin is from a different ABI
// than the Go bindings were compiled against).
// ret == 3 — vtbl_size mismatch (plugin shipped a truncated vtbl).
// ret == 4 — vtbl pointer is NULL (malformed descriptor).
// ret == 5 — vtbl is missing one of the three Q-Chain slots.
static int qvm_backend_init(void* sym, lux_gpu_backend_desc* desc) {
if (!sym || !desc) return 4;
memset(desc, 0, sizeof(*desc));
qvm_backend_init_fn fn = (qvm_backend_init_fn)sym;
if (!fn(desc)) return 1;
if (desc->abi_version != LUX_GPU_BACKEND_ABI_VERSION) return 2;
if (desc->vtbl_size != sizeof(lux_gpu_backend_vtbl)) return 3;
if (!desc->vtbl) return 4;
if (!desc->vtbl->create_context || !desc->vtbl->destroy_context) return 5;
// ML-DSA verify is the canonical Q-Chain slot. ML-DSA sign and
// SLH-DSA verify may be nullptr on backends that haven't wired
// their orchestrator yet — those methods then return
// LUX_BACKEND_ERROR_NOT_SUPPORTED at call time, which the Go
// wrapper translates to ErrGPUNotAvailable. The plugin is still
// viable if at least op_mldsa_verify_batch is wired (ML-DSA
// verify is the load-bearing Q-Chain op).
if (!desc->vtbl->op_mldsa_verify_batch) return 5;
return 0;
}
// Lifecycle helpers — exported so the Go side can open / close a
// LuxBackendContext per probe-result backend exactly once. device_index
// = 0 is the canonical "first available device" sentinel — backends
// like Metal that don't expose multi-GPU selection assume 0 and would
// throw on -1 (NSRangeException on Apple Silicon, observed).
static LuxBackendContext* qvm_create_context(const lux_gpu_backend_vtbl* vtbl) {
return vtbl->create_context(0);
}
static void qvm_destroy_context(const lux_gpu_backend_vtbl* vtbl, LuxBackendContext* ctx) {
if (ctx) vtbl->destroy_context(ctx);
}
// Per-slot wrappers. The trampolines unpack Go's flat parameter slices
// and dispatch through the vtbl function pointer. Each returns the raw
// LuxBackendError code — the Go side maps NOT_SUPPORTED (3) →
// ErrGPUNotAvailable and other non-zero codes → fmt.Errorf("rc=%d").
// op_mldsa_verify_batch — see backend_plugin.h v14 block. msg_lens is
// optional (NULL → use msg_width_hint for every element).
static int qvm_op_mldsa_verify(
const lux_gpu_backend_vtbl* vtbl,
LuxBackendContext* ctx,
const uint8_t* const* pubkeys,
const uint8_t* const* messages,
const size_t* msg_lens,
uint32_t msg_width_hint,
const uint8_t* const* signatures,
bool* results,
size_t count) {
if (!vtbl->op_mldsa_verify_batch) return LUX_BACKEND_ERROR_NOT_SUPPORTED;
return (int)vtbl->op_mldsa_verify_batch(
ctx, pubkeys, messages, msg_lens,
msg_width_hint, signatures, results, count);
}
// op_mldsa_sign_batch — see backend_plugin.h v14 block. sig_lens_out is
// optional (NULL is fine — caller falls back to per-element verify if
// it needs accept / reject feedback).
static int qvm_op_mldsa_sign(
const lux_gpu_backend_vtbl* vtbl,
LuxBackendContext* ctx,
const uint8_t* skeys,
size_t sk_stride,
const uint8_t* msgs,
const size_t* msg_lens,
uint32_t msg_width_hint,
size_t count,
uint8_t* sigs_out,
uint32_t* sig_lens_out) {
if (!vtbl->op_mldsa_sign_batch) return LUX_BACKEND_ERROR_NOT_SUPPORTED;
return (int)vtbl->op_mldsa_sign_batch(
ctx, skeys, sk_stride, msgs, msg_lens,
msg_width_hint, count, sigs_out, sig_lens_out);
}
// op_slhdsa_verify_batch — SHAKE-128f. See backend_plugin.h ABI v13.
static int qvm_op_slhdsa_verify_128f(
const lux_gpu_backend_vtbl* vtbl,
LuxBackendContext* ctx,
const uint8_t* const* pubkeys,
const uint8_t* const* messages,
const size_t* msg_lens,
const uint8_t* const* signatures,
bool* results,
size_t count) {
if (!vtbl->op_slhdsa_verify_batch) return LUX_BACKEND_ERROR_NOT_SUPPORTED;
return (int)vtbl->op_slhdsa_verify_batch(
ctx, pubkeys, messages, msg_lens, signatures, results, count);
}
// op_slhdsa_verify_batch_shake192f — SHAKE-192f. See backend_plugin.h
// ABI v13.
static int qvm_op_slhdsa_verify_192f(
const lux_gpu_backend_vtbl* vtbl,
LuxBackendContext* ctx,
const uint8_t* const* pubkeys,
const uint8_t* const* messages,
const size_t* msg_lens,
const uint8_t* const* signatures,
bool* results,
size_t count) {
if (!vtbl->op_slhdsa_verify_batch_shake192f) return LUX_BACKEND_ERROR_NOT_SUPPORTED;
return (int)vtbl->op_slhdsa_verify_batch_shake192f(
ctx, pubkeys, messages, msg_lens, signatures, results, count);
}
// dlopen / dlsym wrappers — kept here so backend.go stays pure Go.
static void* qvm_dlopen(const char* path) {
return dlopen(path, RTLD_NOW | RTLD_LOCAL);
}
static void* qvm_dlsym(void* handle, const char* sym) {
return dlsym(handle, sym);
}
static const char* qvm_dlerror(void) {
return dlerror();
}
static void qvm_dlclose(void* handle) {
if (handle) dlclose(handle);
}
// Allocates a lux_gpu_backend_desc the Go side keeps for the lifetime
// of the GPUBackend. Heap-allocated so the Go object's address can be
// shared across cgo calls without escape-analysis surprises.
static lux_gpu_backend_desc* qvm_alloc_desc(void) {
lux_gpu_backend_desc* d = (lux_gpu_backend_desc*)calloc(1, sizeof(*d));
return d;
}
static void qvm_free_desc(lux_gpu_backend_desc* d) {
free(d);
}
*/
import "C"
import (
"fmt"
"os"
"path/filepath"
"runtime"
"sync"
"unsafe"
)
// pluginEntrySymbol is the canonical ABI v14 entry point exported by every
// libluxgpu_backend_<x>.{so,dylib} plugin. Resolving this symbol yields a
// function pointer that populates a lux_gpu_backend_desc with abi_version,
// vtbl_size cookies, and the vtbl pointer the rest of this file walks.
const pluginEntrySymbol = "lux_gpu_backend_init"
// pluginEnv overrides the dlopen search path. When set, every probe joins
// the env value to the bare basename before calling dlopen — useful for
// in-tree development against a freshly-built plugin under
// the GPU plugin build tree without installing it.
const pluginEnv = "LUX_GPU_PLUGIN_DIR"
// probeOrder is the dlopen probe sequence fixed by the spec:
// cuda → hip → metal → vulkan → webgpu. First plugin that satisfies the
// cookie checks AND exposes the load-bearing op_mldsa_verify_batch slot
// wins; the remaining probes are skipped. Order is preserved at struct
// definition (not encoded as a map) so the iteration order is the same
// on every platform.
var probeOrder = []struct {
kind Backend
name string // basename without lib prefix or .so/.dylib suffix
}{
{BackendCUDA, "luxgpu_backend_cuda"},
{BackendHIP, "luxgpu_backend_hip"},
{BackendMetal, "luxgpu_backend_metal"},
{BackendVulkan, "luxgpu_backend_vulkan"},
{BackendWebGPU, "luxgpu_backend_webgpu"},
}
// pluginBasenames returns the platform-specific DSO basenames to attempt
// for a given backend. On darwin we try both .dylib (native) and .so
// (cross-tooling fallback). On linux / other we try .so first. The
// order within each entry's slice matches the lookup order used by
// dlopen — first hit wins.
func pluginBasenames(name string) []string {
switch runtime.GOOS {
case "darwin":
return []string{
"lib" + name + ".dylib",
"lib" + name + ".so",
}
case "windows":
return []string{
name + ".dll",
}
default:
return []string{
"lib" + name + ".so",
}
}
}
// =============================================================================
// gpuBackend — the cgo implementation of the GPUBackend interface.
//
// One struct per loaded plugin; pinned at package level by init()'s probe.
// All vtbl invocations route through C trampolines (qvm_op_*) defined in
// the cgo preamble above. mu serialises Close() against concurrent op
// dispatch — every vtbl call increments / decrements an in-flight counter
// so Close() can wait for outstanding calls before destroying the context.
// =============================================================================
type gpuBackend struct {
mu sync.Mutex
kind Backend
handle unsafe.Pointer // dlopen result (void*)
desc *C.lux_gpu_backend_desc // heap-allocated descriptor
ctx *C.LuxBackendContext // backend-managed opaque context
path string
}
// init runs the dlopen probe exactly once at process start. The probe
// walks probeOrder, attempts each candidate basename, and stops at the
// first plugin that:
//
// 1. Successfully dlopens.
// 2. Exposes the lux_gpu_backend_init symbol.
// 3. Reports abi_version == LUX_GPU_BACKEND_ABI_VERSION (14).
// 4. Reports vtbl_size == sizeof(lux_gpu_backend_vtbl) on our header.
// 5. Has op_mldsa_verify_batch wired (the load-bearing Q-Chain slot).
// 6. Successfully opens a LuxBackendContext via vtbl->create_context.
//
// On success the package-level activeBackend atomic is set to the
// matching Backend enum value. Failure at any stage is silent — we move
// to the next candidate. If no candidate succeeds, activeBackend stays
// BackendNone and ActiveGPUBackend() returns a noGPUBackend stub.
func init() {
if b, ok := tryProbe(); ok {
setActiveBackend(b.kind)
setActiveGPU(b)
return
}
setActiveBackend(BackendNone)
setActiveGPU(nil)
}
// tryProbe runs the probeOrder dlopen loop. Returns the loaded gpuBackend
// on success, or (nil, false) if no candidate satisfied all six checks.
// Errors are deliberately not surfaced — this is a probe, not a hard
// requirement. Callers see BackendNone and route to the CPU path.
func tryProbe() (*gpuBackend, bool) {
dir := os.Getenv(pluginEnv)
for _, p := range probeOrder {
for _, bn := range pluginBasenames(p.name) {
path := bn
if dir != "" {
path = filepath.Join(dir, bn)
}
b, err := openPlugin(p.kind, path)
if err == nil {
return b, true
}
}
}
return nil, false
}
// openPlugin attempts to dlopen `path`, resolve lux_gpu_backend_init,
// run the cookie checks, and create a backend context. Returns the
// loaded gpuBackend on success, or (nil, err) with the offending stage
// documented in the error. The dlopen handle is closed on any failure
// past the dlopen point — we never leak a half-bound plugin.
func openPlugin(kind Backend, path string) (*gpuBackend, error) {
cpath := C.CString(path)
defer C.free(unsafe.Pointer(cpath))
// Clear pending dlerror so a stale message from a prior failed call
// doesn't get mis-attributed to this dlopen.
C.qvm_dlerror()
handle := C.qvm_dlopen(cpath)
if handle == nil {
return nil, fmt.Errorf("quantumvm: dlopen(%s): %s",
path, C.GoString(C.qvm_dlerror()))
}
csym := C.CString(pluginEntrySymbol)
defer C.free(unsafe.Pointer(csym))
C.qvm_dlerror()
sym := C.qvm_dlsym(handle, csym)
if sym == nil {
C.qvm_dlclose(handle)
return nil, fmt.Errorf("quantumvm: dlsym(%s, %s): %s",
path, pluginEntrySymbol, C.GoString(C.qvm_dlerror()))
}
desc := C.qvm_alloc_desc()
if desc == nil {
C.qvm_dlclose(handle)
return nil, fmt.Errorf("quantumvm: calloc(lux_gpu_backend_desc) failed")
}
if rc := C.qvm_backend_init(sym, desc); rc != 0 {
C.qvm_free_desc(desc)
C.qvm_dlclose(handle)
return nil, fmt.Errorf(
"quantumvm: %s plugin init failed (rc=%d, path=%s)",
kind, int(rc), path)
}
ctx := C.qvm_create_context(desc.vtbl)
if ctx == nil {
C.qvm_free_desc(desc)
C.qvm_dlclose(handle)
return nil, fmt.Errorf(
"quantumvm: %s create_context returned NULL (no runtime on this host?)",
kind)
}
return &gpuBackend{
kind: kind,
handle: handle,
desc: desc,
ctx: ctx,
path: path,
}, nil
}
// =============================================================================
// Active-backend handle plumbing.
//
// activeGPU stores the *gpuBackend resolved by init()'s probe (or nil
// when no plugin loaded). Read via ActiveGPUBackend() — which returns
// the GPUBackend interface (gpuBackend implements it). The pointer is
// pinned at init time and never mutated thereafter; we still serialise
// access through a Mutex for safety against a future caller that might
// add an explicit re-probe path.
// =============================================================================
var (
activeGPUMu sync.RWMutex
activeGPU *gpuBackend
)
func setActiveGPU(b *gpuBackend) {
activeGPUMu.Lock()
activeGPU = b
activeGPUMu.Unlock()
}
// ActiveGPUBackend returns the package-level GPUBackend handle. Under
// cgo this is the *gpuBackend chosen by init()'s probe, or a sentinel
// noGPUBackend stub when no plugin loaded. The returned handle's
// methods are safe to call from any goroutine — every vtbl call
// holds the backend mutex.
func ActiveGPUBackend() GPUBackend {
activeGPUMu.RLock()
b := activeGPU
activeGPUMu.RUnlock()
if b == nil {
return noGPUBackend{}
}
return b
}
// noGPUBackend is the cgo build's stub for the "no plugin loaded" case.
// Distinct from the !cgo noGPUBackend so the !cgo file can ship without
// any cgo machinery; same method semantics.
type noGPUBackend struct{}
func (noGPUBackend) Backend() Backend { return BackendNone }
func (noGPUBackend) Close() error { return nil }
func (noGPUBackend) MLDSAVerifyBatch(
mode MLDSAMode,
pubkeys [][]byte,
messages [][]byte,
msgLens []int,
msgWidthHint uint32,
signatures [][]byte,
results []bool,
) error {
_ = mode
_ = messages
_ = msgLens
_ = msgWidthHint
_ = signatures
_ = results
if len(pubkeys) == 0 {
return nil
}
return ErrGPUNotAvailable
}
func (noGPUBackend) MLDSASignBatch(
mode MLDSAMode,
skeys []byte,
msgs []byte,
msgLens []int,
msgWidthHint uint32,
count int,
sigsOut []byte,
sigLensOut []uint32,
) error {
_ = mode
_ = skeys
_ = msgs
_ = msgLens
_ = msgWidthHint
_ = sigsOut
_ = sigLensOut
if count == 0 {
return nil
}
return ErrGPUNotAvailable
}
func (noGPUBackend) SLHDSAVerifyBatch(
variant SLHDSAVariant,
pubkeys [][]byte,
messages [][]byte,
msgLens []int,
signatures [][]byte,
results []bool,
) error {
_ = variant
_ = messages
_ = msgLens
_ = signatures
_ = results
if len(pubkeys) == 0 {
return nil
}
return ErrGPUNotAvailable
}
// =============================================================================
// gpuBackend method implementations.
// =============================================================================
func (b *gpuBackend) Backend() Backend {
if b == nil {
return BackendNone
}
return b.kind
}
// Close destroys the LuxBackendContext, dlcloses the plugin, and frees
// the descriptor. Safe on a nil receiver and idempotent — a second call
// is a no-op once the handle has been cleared.
func (b *gpuBackend) Close() error {
if b == nil {
return nil
}
b.mu.Lock()
defer b.mu.Unlock()
if b.handle == nil {
return nil
}
if b.ctx != nil {
C.qvm_destroy_context(b.desc.vtbl, b.ctx)
b.ctx = nil
}
if b.desc != nil {
C.qvm_free_desc(b.desc)
b.desc = nil
}
C.qvm_dlclose(b.handle)
b.handle = nil
return nil
}
// MLDSAVerifyBatch dispatches op_mldsa_verify_batch from the loaded
// vtbl. See the GPUBackend interface doc + backend_plugin.h v14 block
// for the contract.
func (b *gpuBackend) MLDSAVerifyBatch(
mode MLDSAMode,
pubkeys [][]byte,
messages [][]byte,
msgLens []int,
msgWidthHint uint32,
signatures [][]byte,
results []bool,
) error {
if mode != MLDSAMode65 {
// v14 vtbl only exposes ML-DSA-65 at op_mldsa_verify_batch.
// Modes 44 / 87 will land at sibling slots in a future ABI
// bump (see backend_plugin.h v14 block).
return ErrGPUNotAvailable
}
n := len(pubkeys)
if n == 0 {
return nil
}
if len(messages) != n || len(signatures) != n || len(results) != n {
return fmt.Errorf(
"quantumvm: MLDSAVerifyBatch: length mismatch (pubkeys=%d messages=%d signatures=%d results=%d)",
n, len(messages), len(signatures), len(results))
}
if msgLens != nil && len(msgLens) != n {
return fmt.Errorf(
"quantumvm: MLDSAVerifyBatch: msgLens length %d != batch %d",
len(msgLens), n)
}
if b == nil || b.ctx == nil || b.desc == nil {
return ErrGPUNotAvailable
}
b.mu.Lock()
defer b.mu.Unlock()
// Pin every Go-side byte slice for the duration of the C call.
// runtime.Pinner guarantees the GC won't move (or collect) the
// backing arrays while the kernel reads them.
var pinner runtime.Pinner
defer pinner.Unpin()
pkPtrs := make([]*C.uint8_t, n)
msgPtrs := make([]*C.uint8_t, n)
sigPtrs := make([]*C.uint8_t, n)
var lensBuf []C.size_t
for i := 0; i < n; i++ {
if len(pubkeys[i]) != MLDSA65PublicKeySize {
return fmt.Errorf(
"quantumvm: MLDSAVerifyBatch[%d]: pubkey len %d != %d",
i, len(pubkeys[i]), MLDSA65PublicKeySize)
}
if len(signatures[i]) > MLDSA65SignatureSize {
return fmt.Errorf(
"quantumvm: MLDSAVerifyBatch[%d]: signature len %d > max %d",
i, len(signatures[i]), MLDSA65SignatureSize)
}
pinner.Pin(&pubkeys[i][0])
pkPtrs[i] = (*C.uint8_t)(unsafe.Pointer(&pubkeys[i][0]))
// Allow zero-length message (legal — empty-message verify is
// a defined SHAKE256 absorb-zero operation per FIPS 204).
if len(messages[i]) > 0 {
pinner.Pin(&messages[i][0])
msgPtrs[i] = (*C.uint8_t)(unsafe.Pointer(&messages[i][0]))
}
pinner.Pin(&signatures[i][0])
sigPtrs[i] = (*C.uint8_t)(unsafe.Pointer(&signatures[i][0]))
}
if msgLens != nil {
lensBuf = make([]C.size_t, n)
for i := 0; i < n; i++ {
if msgLens[i] < 0 {
return fmt.Errorf(
"quantumvm: MLDSAVerifyBatch[%d]: negative msgLen %d",
i, msgLens[i])
}
lensBuf[i] = C.size_t(msgLens[i])
}
}
// bool layout in cgo: C.bool is one byte. Stage results in a
// scratch buffer; copy back to the caller's []bool after the call.
cresults := make([]C.bool, n)
pkPtrsPtr := (**C.uint8_t)(unsafe.Pointer(&pkPtrs[0]))
msgPtrsPtr := (**C.uint8_t)(unsafe.Pointer(&msgPtrs[0]))
sigPtrsPtr := (**C.uint8_t)(unsafe.Pointer(&sigPtrs[0]))
var lensPtr *C.size_t
if lensBuf != nil {
lensPtr = (*C.size_t)(unsafe.Pointer(&lensBuf[0]))
}
rc := C.qvm_op_mldsa_verify(
b.desc.vtbl,
b.ctx,
pkPtrsPtr,
msgPtrsPtr,
lensPtr,
C.uint32_t(msgWidthHint),
sigPtrsPtr,
(*C.bool)(unsafe.Pointer(&cresults[0])),
C.size_t(n),
)
runtime.KeepAlive(pkPtrs)
runtime.KeepAlive(msgPtrs)
runtime.KeepAlive(sigPtrs)
runtime.KeepAlive(lensBuf)
runtime.KeepAlive(cresults)
if rc != 0 {
return mapBackendError(int(rc), "op_mldsa_verify_batch")
}
for i := 0; i < n; i++ {
results[i] = bool(cresults[i])
}
return nil
}
// MLDSASignBatch dispatches op_mldsa_sign_batch from the loaded vtbl.
// See the GPUBackend interface doc + backend_plugin.h v14 block.
func (b *gpuBackend) MLDSASignBatch(
mode MLDSAMode,
skeys []byte,
msgs []byte,
msgLens []int,
msgWidthHint uint32,
count int,
sigsOut []byte,
sigLensOut []uint32,
) error {
if mode != MLDSAMode65 {
return ErrGPUNotAvailable
}
if count == 0 {
return nil
}
const skStride = MLDSA65SecretKeySize
const sigStride = MLDSA65SignatureSize
if len(skeys) != count*skStride {
return fmt.Errorf(
"quantumvm: MLDSASignBatch: skeys len %d != count*%d (%d)",
len(skeys), skStride, count*skStride)
}
if len(sigsOut) != count*sigStride {
return fmt.Errorf(
"quantumvm: MLDSASignBatch: sigsOut len %d != count*%d (%d)",
len(sigsOut), sigStride, count*sigStride)
}
if msgLens != nil && len(msgLens) != count {
return fmt.Errorf(
"quantumvm: MLDSASignBatch: msgLens length %d != count %d",
len(msgLens), count)
}
if sigLensOut != nil && len(sigLensOut) != count {
return fmt.Errorf(
"quantumvm: MLDSASignBatch: sigLensOut length %d != count %d",
len(sigLensOut), count)
}
if b == nil || b.ctx == nil || b.desc == nil {
return ErrGPUNotAvailable
}
// Validate the msgs pool is large enough to cover the per-element
// lengths. NULL msgLens uses msgWidthHint uniformly — same check
// against count * msgWidthHint.
var total int
if msgLens != nil {
for i := 0; i < count; i++ {
if msgLens[i] < 0 {
return fmt.Errorf(
"quantumvm: MLDSASignBatch[%d]: negative msgLen %d",
i, msgLens[i])
}
total += msgLens[i]
}
} else {
total = int(msgWidthHint) * count
}
if len(msgs) < total {
return fmt.Errorf(
"quantumvm: MLDSASignBatch: msgs pool len %d < total %d",
len(msgs), total)
}
b.mu.Lock()
defer b.mu.Unlock()
var pinner runtime.Pinner
defer pinner.Unpin()
pinner.Pin(&skeys[0])
if total > 0 {
pinner.Pin(&msgs[0])
}
pinner.Pin(&sigsOut[0])
var lensBuf []C.size_t
var lensPtr *C.size_t
if msgLens != nil {
lensBuf = make([]C.size_t, count)
for i := 0; i < count; i++ {
lensBuf[i] = C.size_t(msgLens[i])
}
lensPtr = (*C.size_t)(unsafe.Pointer(&lensBuf[0]))
}
var sigLensPtr *C.uint32_t
if sigLensOut != nil {
sigLensPtr = (*C.uint32_t)(unsafe.Pointer(&sigLensOut[0]))
}
var msgsPtr *C.uint8_t
if total > 0 {
msgsPtr = (*C.uint8_t)(unsafe.Pointer(&msgs[0]))
}
rc := C.qvm_op_mldsa_sign(
b.desc.vtbl,
b.ctx,
(*C.uint8_t)(unsafe.Pointer(&skeys[0])),
C.size_t(skStride),
msgsPtr,
lensPtr,
C.uint32_t(msgWidthHint),
C.size_t(count),
(*C.uint8_t)(unsafe.Pointer(&sigsOut[0])),
sigLensPtr,
)
runtime.KeepAlive(lensBuf)
if rc != 0 {
return mapBackendError(int(rc), "op_mldsa_sign_batch")
}
return nil
}
// SLHDSAVerifyBatch dispatches the SHAKE-128f or SHAKE-192f variant of
// op_slhdsa_verify_batch from the loaded vtbl. The 10 other FIPS 205
// variants are not exposed at v14 — they return ErrGPUNotAvailable.
func (b *gpuBackend) SLHDSAVerifyBatch(
variant SLHDSAVariant,
pubkeys [][]byte,
messages [][]byte,
msgLens []int,
signatures [][]byte,
results []bool,
) error {
is128f := variant == SLHDSAShake128f
is192f := variant == SLHDSAShake192f
if !is128f && !is192f {
return ErrGPUNotAvailable
}
n := len(pubkeys)
if n == 0 {
return nil
}
if len(messages) != n || len(signatures) != n || len(results) != n {
return fmt.Errorf(
"quantumvm: SLHDSAVerifyBatch: length mismatch (pubkeys=%d messages=%d signatures=%d results=%d)",
n, len(messages), len(signatures), len(results))
}
if msgLens != nil && len(msgLens) != n {
return fmt.Errorf(
"quantumvm: SLHDSAVerifyBatch: msgLens length %d != batch %d",
len(msgLens), n)
}
pkSize := variant.PublicKeySize()
sigSize := variant.SignatureSize()
if b == nil || b.ctx == nil || b.desc == nil {
return ErrGPUNotAvailable
}
b.mu.Lock()
defer b.mu.Unlock()
var pinner runtime.Pinner
defer pinner.Unpin()
pkPtrs := make([]*C.uint8_t, n)
msgPtrs := make([]*C.uint8_t, n)
sigPtrs := make([]*C.uint8_t, n)
lensBuf := make([]C.size_t, n)
for i := 0; i < n; i++ {
if len(pubkeys[i]) != pkSize {
return fmt.Errorf(
"quantumvm: SLHDSAVerifyBatch[%d]: pubkey len %d != %d",
i, len(pubkeys[i]), pkSize)
}
if len(signatures[i]) != sigSize {
return fmt.Errorf(
"quantumvm: SLHDSAVerifyBatch[%d]: signature len %d != %d",
i, len(signatures[i]), sigSize)
}
pinner.Pin(&pubkeys[i][0])
pkPtrs[i] = (*C.uint8_t)(unsafe.Pointer(&pubkeys[i][0]))
if len(messages[i]) > 0 {
pinner.Pin(&messages[i][0])
msgPtrs[i] = (*C.uint8_t)(unsafe.Pointer(&messages[i][0]))
}
pinner.Pin(&signatures[i][0])
sigPtrs[i] = (*C.uint8_t)(unsafe.Pointer(&signatures[i][0]))
// SLH-DSA has no msg_width_hint surface — msg_lens is
// mandatory at the vtbl boundary. Fill lensBuf from either
// msgLens or the message slice length.
if msgLens != nil {
if msgLens[i] < 0 {
return fmt.Errorf(
"quantumvm: SLHDSAVerifyBatch[%d]: negative msgLen %d",
i, msgLens[i])
}
lensBuf[i] = C.size_t(msgLens[i])
} else {
lensBuf[i] = C.size_t(len(messages[i]))
}
}
cresults := make([]C.bool, n)
pkPtrsPtr := (**C.uint8_t)(unsafe.Pointer(&pkPtrs[0]))
msgPtrsPtr := (**C.uint8_t)(unsafe.Pointer(&msgPtrs[0]))
sigPtrsPtr := (**C.uint8_t)(unsafe.Pointer(&sigPtrs[0]))
lensPtr := (*C.size_t)(unsafe.Pointer(&lensBuf[0]))
var rc C.int
if is128f {
rc = C.qvm_op_slhdsa_verify_128f(
b.desc.vtbl,
b.ctx,
pkPtrsPtr,
msgPtrsPtr,
lensPtr,
sigPtrsPtr,
(*C.bool)(unsafe.Pointer(&cresults[0])),
C.size_t(n),
)
} else {
rc = C.qvm_op_slhdsa_verify_192f(
b.desc.vtbl,
b.ctx,
pkPtrsPtr,
msgPtrsPtr,
lensPtr,
sigPtrsPtr,
(*C.bool)(unsafe.Pointer(&cresults[0])),
C.size_t(n),
)
}
runtime.KeepAlive(pkPtrs)
runtime.KeepAlive(msgPtrs)
runtime.KeepAlive(sigPtrs)
runtime.KeepAlive(lensBuf)
runtime.KeepAlive(cresults)
if rc != 0 {
return mapBackendError(int(rc), "op_slhdsa_verify_batch")
}
for i := 0; i < n; i++ {
results[i] = bool(cresults[i])
}
return nil
}
// mapBackendError translates a LuxBackendError code to a Go error.
// NOT_SUPPORTED (3) maps to the package sentinel so callers can route
// to the CPU oracle via errors.Is. Other non-zero codes return a
// formatted error including the numeric code for diagnostics.
func mapBackendError(rc int, op string) error {
switch rc {
case 0:
return nil
case int(C.LUX_BACKEND_ERROR_NOT_SUPPORTED):
return ErrGPUNotAvailable
case int(C.LUX_BACKEND_ERROR_INVALID_ARGUMENT):
return fmt.Errorf("quantumvm: %s: invalid argument (rc=1)", op)
case int(C.LUX_BACKEND_ERROR_OUT_OF_MEMORY):
return fmt.Errorf("quantumvm: %s: out of memory (rc=2)", op)
case int(C.LUX_BACKEND_ERROR_DEVICE_LOST):
return fmt.Errorf("quantumvm: %s: device lost (rc=4)", op)
case int(C.LUX_BACKEND_ERROR_INTERNAL):
return fmt.Errorf("quantumvm: %s: internal error (rc=5)", op)
default:
return fmt.Errorf("quantumvm: %s: rc=%d", op, rc)
}
}
-98
View File
@@ -1,98 +0,0 @@
//go:build !cgo
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package quantumvm
// !cgo build: no plugin DSO is loadable without cgo (we cannot call
// dlopen / dlsym from pure Go), so every GPUBackend method returns
// ErrGPUNotAvailable and AutoBackend() reports BackendNone. The cgo
// build's quantumvm_gpu.go does the runtime probe; this file keeps the
// package surface identical regardless of CGO_ENABLED.
func init() {
setActiveBackend(BackendNone)
}
// noGPUBackend is the !cgo implementation of the GPUBackend interface —
// every method returns ErrGPUNotAvailable so callers route to the CPU
// verify path cleanly.
type noGPUBackend struct{}
// ActiveGPUBackend returns the package-level GPUBackend handle. Under
// !cgo this is always a stub returning ErrGPUNotAvailable on every
// method call.
func ActiveGPUBackend() GPUBackend {
return noGPUBackend{}
}
func (noGPUBackend) Backend() Backend { return BackendNone }
func (noGPUBackend) Close() error { return nil }
func (noGPUBackend) MLDSAVerifyBatch(
mode MLDSAMode,
pubkeys [][]byte,
messages [][]byte,
msgLens []int,
msgWidthHint uint32,
signatures [][]byte,
results []bool,
) error {
_ = mode
_ = messages
_ = msgLens
_ = msgWidthHint
_ = signatures
_ = results
// Empty batch is a legal no-op — match the cgo implementation's
// short-circuit so callers don't have to special-case "no plugin
// loaded AND empty batch" at the call site.
if len(pubkeys) == 0 {
return nil
}
return ErrGPUNotAvailable
}
func (noGPUBackend) MLDSASignBatch(
mode MLDSAMode,
skeys []byte,
msgs []byte,
msgLens []int,
msgWidthHint uint32,
count int,
sigsOut []byte,
sigLensOut []uint32,
) error {
_ = mode
_ = skeys
_ = msgs
_ = msgLens
_ = msgWidthHint
_ = sigsOut
_ = sigLensOut
if count == 0 {
return nil
}
return ErrGPUNotAvailable
}
func (noGPUBackend) SLHDSAVerifyBatch(
variant SLHDSAVariant,
pubkeys [][]byte,
messages [][]byte,
msgLens []int,
signatures [][]byte,
results []bool,
) error {
_ = variant
_ = messages
_ = msgLens
_ = signatures
_ = results
if len(pubkeys) == 0 {
return nil
}
return ErrGPUNotAvailable
}
-176
View File
@@ -1,176 +0,0 @@
//go:build cgo
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package quantumvm
import (
"crypto/rand"
"errors"
"testing"
"github.com/luxfi/crypto/mldsa"
)
// TestAutoBackend_Probes documents what the init() probe found at process
// start. Skipped when no plugin loaded — this is the on-CI / clean-build
// signal that the binary stays portable. When a plugin IS loaded, we log
// its name so flakes can be tied back to a specific backend.
func TestAutoBackend_Probes(t *testing.T) {
b := AutoBackend()
if b == BackendNone {
t.Skip("no libluxgpu_backend_*.{so,dylib} loadable on the dlopen path — Q-Chain CPU path remains the only option")
}
t.Logf("AutoBackend() = %s", b)
}
// TestActiveGPUBackend_NoBackendReturnsErr exercises the noGPUBackend
// stub returned when no plugin was loaded. Every method must return
// ErrGPUNotAvailable so callers can route to the CPU verify path via
// errors.Is.
func TestActiveGPUBackend_NoBackendReturnsErr(t *testing.T) {
if AutoBackend() != BackendNone {
t.Skip("plugin loaded — noGPUBackend stub not exercised on this host")
}
g := ActiveGPUBackend()
if g.Backend() != BackendNone {
t.Fatalf("Backend() = %s, want none", g.Backend())
}
err := g.MLDSAVerifyBatch(MLDSAMode65, nil, nil, nil, 0, nil, nil)
if err != nil {
// Empty batch is a legal no-op even when no plugin loaded;
// length 0 short-circuits before the noGPUBackend stub runs.
t.Fatalf("MLDSAVerifyBatch(empty) = %v, want nil", err)
}
err = g.MLDSAVerifyBatch(MLDSAMode65,
[][]byte{make([]byte, MLDSA65PublicKeySize)},
[][]byte{nil},
nil, 0,
[][]byte{make([]byte, MLDSA65SignatureSize)},
make([]bool, 1))
if !errors.Is(err, ErrGPUNotAvailable) {
t.Fatalf("MLDSAVerifyBatch on noGPUBackend = %v, want ErrGPUNotAvailable", err)
}
}
// TestMLDSAVerifyBatch_RoundTrip — open a backend context, sign one
// message via the in-process circl ML-DSA-65 implementation, then run
// op_mldsa_verify_batch on the (pk, msg, sig) triple and assert
// results[0] == true.
//
// The plugin's wrapper consumes FIPS 204 wire-format (pk=1952 B,
// sig=3309 B max) which is exactly what circl's MarshalBinary() /
// SignTo() emit, so the round-trip is byte-compatible — no PQClean
// detour needed in the test.
//
// Skip conditions:
// * No plugin loaded -> AutoBackend() == BackendNone
// * Plugin loaded but op_mldsa_verify_batch is a -> rc=NOT_SUPPORTED
// CPU oracle returning NOT_SUPPORTED (e.g. some
// WebGPU phase-2 builds) — surfaces as ErrGPUNotAvailable.
//
// Either skip is a CLEAN exit, not a FAIL. The property under test is
// "ABI v14 wire-format round-trips through the loaded plugin", which
// is only meaningful when the plugin actually runs the verify path.
func TestMLDSAVerifyBatch_RoundTrip(t *testing.T) {
if AutoBackend() == BackendNone {
t.Skip("no GPU plugin loaded — round-trip test requires the dlopen probe to have succeeded at init()")
}
g := ActiveGPUBackend()
t.Logf("backend = %s", g.Backend())
// Generate one ML-DSA-65 keypair + signature via circl. The test
// is single-shot so we don't bother batching beyond N=1; the v14
// vtbl wrapper has the same shape for every batch size.
priv, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
if err != nil {
t.Fatalf("mldsa.GenerateKey: %v", err)
}
pkBytes := priv.PublicKey.Bytes()
if len(pkBytes) != MLDSA65PublicKeySize {
t.Fatalf("pubkey size = %d, want %d", len(pkBytes), MLDSA65PublicKeySize)
}
// 64-byte message — matches what the Q-Chain quantum stamp path
// hands the verifier (sha512 digest of the round digest + nonce +
// timestamp). Using a deterministic byte pattern keeps the test
// reproducible across reruns.
msg := make([]byte, 64)
for i := range msg {
msg[i] = byte(i)
}
// Sign with ctx=nil to match the orchestrator's ctx_len=0 wrap.
sig, err := priv.Sign(rand.Reader, msg, nil)
if err != nil {
t.Fatalf("priv.Sign: %v", err)
}
if got, max := len(sig), MLDSA65SignatureSize; got > max {
t.Fatalf("sig size = %d > max %d", got, max)
}
// Pad the signature out to the fixed 3309-byte width the orchestrator
// reads. Padding bytes after the real sig length are ignored by the
// FIPS 204 verify path (sig parsing terminates at the c̃ + z + h
// structural decode).
sigPadded := make([]byte, MLDSA65SignatureSize)
copy(sigPadded, sig)
results := make([]bool, 1)
err = g.MLDSAVerifyBatch(
MLDSAMode65,
[][]byte{pkBytes},
[][]byte{msg},
[]int{len(msg)},
/*msgWidthHint=*/ 0,
[][]byte{sigPadded},
results,
)
if errors.Is(err, ErrGPUNotAvailable) {
t.Skipf("plugin op_mldsa_verify_batch returned NOT_SUPPORTED (backend=%s) — surface contract honoured, round-trip not testable", g.Backend())
}
if err != nil {
t.Fatalf("MLDSAVerifyBatch: %v", err)
}
if !results[0] {
t.Fatalf("honest verify returned false (backend=%s) — wire-format incompatibility", g.Backend())
}
t.Logf("backend=%s op_mldsa_verify_batch honest verify -> true", g.Backend())
}
// TestMLDSAVerifyBatch_LengthMismatch covers the length-validation
// branch at the boundary. The wrapper must reject batches where the
// per-element slices disagree on cardinality before any C call.
func TestMLDSAVerifyBatch_LengthMismatch(t *testing.T) {
if AutoBackend() == BackendNone {
t.Skip("no GPU plugin loaded")
}
g := ActiveGPUBackend()
err := g.MLDSAVerifyBatch(
MLDSAMode65,
make([][]byte, 2),
make([][]byte, 1),
nil, 0,
make([][]byte, 2),
make([]bool, 2),
)
if err == nil {
t.Fatal("expected length-mismatch error, got nil")
}
}
// TestMLDSAVerifyBatch_NonMLDSA65Skips exercises the mode gate. Modes
// 44 / 87 aren't wired at v14 — passing them must surface as
// ErrGPUNotAvailable so callers fall through cleanly.
func TestMLDSAVerifyBatch_NonMLDSA65Skips(t *testing.T) {
g := ActiveGPUBackend()
for _, mode := range []MLDSAMode{MLDSAMode44, MLDSAMode87} {
err := g.MLDSAVerifyBatch(mode,
[][]byte{make([]byte, 1)},
[][]byte{nil}, nil, 0,
[][]byte{make([]byte, 1)},
make([]bool, 1))
if !errors.Is(err, ErrGPUNotAvailable) {
t.Fatalf("mode %d: got %v, want ErrGPUNotAvailable", mode, err)
}
}
}
-68
View File
@@ -1,68 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//
// Q-Chain Corona witness producer for Quasar parallel-witness finality
// (LP-020 §2 Consensus Modes, witness set bit WitnessQ).
//
// The Q-Chain VM is the parallel finality witness for the Q lane. Each
// consensus round, the Quasar driver pushes a 32-byte round digest to this
// adapter; the adapter drives the underlying Corona 2-round threshold
// ceremony (Module-LWE, eprint 2024/1113) and returns the resulting
// threshold signature as the round's Q-witness.
//
// The method signature matches consensus/protocol/quasar.QWitnessProducer
// (Witness(ctx, [32]byte) ([]byte, error)) so this adapter satisfies that
// interface structurally once the consensus dependency is bumped to ship
// the new type.
package quantumvm
import (
"context"
"errors"
"sync"
)
// QWitnessAdapter adapts the Q-Chain Quasar engine to the consensus
// QWitnessProducer interface used by the Quasar round driver.
//
// TODO(pqz): wire to a real per-round Corona ceremony driver. The current
// quantumvm.Quasar engine signs per-block by validator id; a per-consensus-
// round driver is required to land a true Q-witness. See LP-020 §9
// Implementation, "Three-lane signing".
type QWitnessAdapter struct {
mu sync.RWMutex
engine *Quasar
}
// NewQWitnessAdapter constructs a Q-witness adapter backed by the given
// Q-Chain Quasar engine. Pass the engine returned by NewQuasar.
func NewQWitnessAdapter(engine *Quasar) *QWitnessAdapter {
return &QWitnessAdapter{engine: engine}
}
// ErrQWitnessNotWired is returned by QWitnessAdapter.Witness until the
// per-round Corona driver lands. The interface is in place so the
// consensus driver can be configured today.
var ErrQWitnessNotWired = errors.New("Q-Chain per-round Corona driver not wired (TODO LP-020 §9)")
// Witness produces a Corona threshold signature over the round digest.
// Signature matches consensus/protocol/quasar.QWitnessProducer.
//
// Returns ErrQWitnessNotWired today; the round driver treats this as the
// witness being unavailable and finalizes at PolicyQuorum (or PolicyPZ if
// Z is enabled).
func (a *QWitnessAdapter) Witness(ctx context.Context, digest [32]byte) ([]byte, error) {
a.mu.RLock()
defer a.mu.RUnlock()
if a.engine == nil {
return nil, ErrQWitnessNotWired
}
// Eventual implementation: drive a Corona 2-round threshold ceremony
// (a.engine.SignBlock + AddCoronaSignature loop with consensus peers,
// then TryFinalize) and return the aggregated Corona bytes from the
// resulting AggregatedSignature.
_ = ctx
_ = digest
return nil, ErrQWitnessNotWired
}
-136
View File
@@ -1,136 +0,0 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package stamper
import (
"crypto/rand"
"math/big"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/luxfi/crypto/slhdsa"
"github.com/luxfi/geth/common"
"github.com/luxfi/geth/core/types"
)
// hybridStamp returns a hybrid-mode stamp whose SLH-DSA signature is made over
// its OWN signing bytes, plus the signing bytes themselves.
func hybridStamp(t *testing.T, qs *QuantumStamper, height uint64) (*QuantumStamp, []byte) {
t.Helper()
stamp := &QuantumStamp{
CChainHeight: height,
CChainHash: common.BigToHash(new(big.Int).SetUint64(height)),
QChainHeight: height,
QChainHash: common.BytesToHash([]byte{byte(height)}),
Mode: StampModeHybrid,
Timestamp: time.Unix(int64(height), 0),
StateRoot: common.BytesToHash([]byte{byte(height), 0x01}),
ReceiptsRoot: common.BytesToHash([]byte{byte(height), 0x02}),
GasUsed: 1000 + height,
Nonce: []byte{byte(height)},
}
signData := qs.prepareSignatureData(stamp)
sk, err := slhdsa.GenerateKey(rand.Reader, slhdsa.SHA2_128f)
require.NoError(t, err)
sig, err := sk.SignCtx(rand.Reader, signData, nil)
require.NoError(t, err)
stamp.SLHDSASignature = sig
stamp.PublicKeySLH = sk.PublicKey.Bytes()
return stamp, signData
}
// TestHybridBatchVerifiesEachStampAgainstItsOwnData proves the SLH-DSA leg of a
// hybrid stamp is checked against that stamp's signing bytes.
//
// signDataSlice is indexed by batch position while indices maps position to stamp
// index. Reading signDataSlice[0] for every hybrid stamp verified each one's
// post-quantum signature against the FIRST batch entry's bytes, so a signature
// over data the stamp does not commit to satisfied its post-quantum leg — and the
// submitter of a batch chooses what entry zero contains.
func TestHybridBatchVerifiesEachStampAgainstItsOwnData(t *testing.T) {
require := require.New(t)
qs := &QuantumStamper{log: nil}
first, firstData := hybridStamp(t, qs, 1)
second, secondData := hybridStamp(t, qs, 2)
require.NotEqual(firstData, secondData, "the two stamps commit to different bytes")
stamps := []*QuantumStamp{first, second}
indices := []int{0, 1}
signDataSlice := [][]byte{firstData, secondData}
results := []bool{true, true}
qs.verifyHybridSLHDSA(stamps, indices, signDataSlice, results)
require.True(results[0], "batch position 0 verifies against its own data")
require.True(results[1], "batch position 1 must verify against ITS data, not position 0's")
}
// TestHybridBatchRejectsSignatureOverAnotherStampsData is the negative half: a
// stamp carrying a signature made over a different stamp's bytes is refused.
func TestHybridBatchRejectsSignatureOverAnotherStampsData(t *testing.T) {
require := require.New(t)
qs := &QuantumStamper{log: nil}
first, firstData := hybridStamp(t, qs, 1)
forged, forgedData := hybridStamp(t, qs, 2)
// Give the second stamp a signature that is valid over the FIRST stamp's data.
sk, err := slhdsa.GenerateKey(rand.Reader, slhdsa.SHA2_128f)
require.NoError(err)
sig, err := sk.SignCtx(rand.Reader, firstData, nil)
require.NoError(err)
forged.SLHDSASignature = sig
forged.PublicKeySLH = sk.PublicKey.Bytes()
stamps := []*QuantumStamp{first, forged}
results := []bool{true, true}
qs.verifyHybridSLHDSA(stamps, []int{0, 1}, [][]byte{firstData, forgedData}, results)
require.True(results[0])
require.False(results[1], "a signature over another stamp's data must not satisfy this stamp")
}
// TestStampMatchesBlockCoversReceiptsRoot pins the block-correspondence predicate
// both verification paths share. The accelerated batch path re-listed the fields
// itself and omitted ReceiptsRoot, so a stamp with a forged receipts root was
// accepted whenever the batch went to the accelerator and refused when it did not.
func TestStampMatchesBlockCoversReceiptsRoot(t *testing.T) {
require := require.New(t)
header := &types.Header{
Number: new(big.Int).SetUint64(7),
Root: common.BytesToHash([]byte{0xaa}),
ReceiptHash: common.BytesToHash([]byte{0xbb}),
GasUsed: 4242,
}
block := types.NewBlockWithHeader(header)
good := &QuantumStamp{
CChainHeight: block.NumberU64(),
CChainHash: block.Hash(),
StateRoot: block.Root(),
ReceiptsRoot: block.ReceiptHash(),
GasUsed: block.GasUsed(),
}
require.True(stampMatchesBlock(good, block))
for name, tamper := range map[string]func(*QuantumStamp){
"height": func(s *QuantumStamp) { s.CChainHeight++ },
"hash": func(s *QuantumStamp) { s.CChainHash = common.BytesToHash([]byte{0x01}) },
"stateRoot": func(s *QuantumStamp) { s.StateRoot = common.BytesToHash([]byte{0x02}) },
"receiptsRoot": func(s *QuantumStamp) { s.ReceiptsRoot = common.BytesToHash([]byte{0x03}) },
"gasUsed": func(s *QuantumStamp) { s.GasUsed++ },
} {
bad := *good
tamper(&bad)
require.False(stampMatchesBlock(&bad, block), "a forged %s must not match the block", name)
}
}
-762
View File
@@ -1,762 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Q-Chain Quantum Stamper for C-Chain Block Replay
// Implements Crystal-Dilithium (ML-DSA) and SPHINCS+ (SLH-DSA) for post-quantum security
package stamper
import (
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/luxfi/accel"
"github.com/luxfi/crypto/mldsa"
"github.com/luxfi/crypto/slhdsa"
"github.com/luxfi/geth/common"
"github.com/luxfi/geth/core/types"
"github.com/luxfi/log"
"github.com/luxfi/node/cache"
"github.com/luxfi/chains/quantumvm/quantum"
)
var (
ErrStampingDisabled = errors.New("quantum stamping disabled")
ErrInvalidBlockHeight = errors.New("invalid block height")
ErrStampAlreadyExists = errors.New("quantum stamp already exists")
ErrStampVerificationFail = errors.New("quantum stamp verification failed")
ErrQChainNotSynced = errors.New("Q-chain not synchronized")
ErrInvalidSignatureMode = errors.New("invalid signature mode")
)
// QuantumStampMode defines the post-quantum signature algorithm
type QuantumStampMode uint8
const (
StampModeMLDSA44 QuantumStampMode = 0 // Crystal-Dilithium Level 2 (fast, smaller)
StampModeMLDSA65 QuantumStampMode = 1 // Crystal-Dilithium Level 3 (balanced)
StampModeMLDSA87 QuantumStampMode = 2 // Crystal-Dilithium Level 5 (highest security)
StampModeSLHDSA QuantumStampMode = 3 // SPHINCS+ (stateless hash-based)
StampModeHybrid QuantumStampMode = 4 // Hybrid ML-DSA + SLH-DSA
)
// QuantumStamp represents a quantum-resistant stamp for a C-Chain block
type QuantumStamp struct {
// Block identification
CChainHeight uint64 `json:"cchainHeight"`
CChainHash common.Hash `json:"cchainHash"`
QChainHeight uint64 `json:"qchainHeight"`
QChainHash common.Hash `json:"qchainHash"`
// Quantum signature data
Mode QuantumStampMode `json:"mode"`
Timestamp time.Time `json:"timestamp"`
MLDSASignature []byte `json:"mldsaSignature,omitempty"`
SLHDSASignature []byte `json:"slhdsaSignature,omitempty"`
PublicKeyML []byte `json:"publicKeyML,omitempty"`
PublicKeySLH []byte `json:"publicKeySLH,omitempty"`
// Metadata
StateRoot common.Hash `json:"stateRoot"`
ReceiptsRoot common.Hash `json:"receiptsRoot"`
LogsBloom []byte `json:"logsBloom"`
GasUsed uint64 `json:"gasUsed"`
// Cross-chain proof
MerkleProof []common.Hash `json:"merkleProof,omitempty"`
Nonce []byte `json:"nonce"`
}
// QuantumStamper handles quantum stamping of C-Chain blocks
type QuantumStamper struct {
log log.Logger
enabled atomic.Bool
mode QuantumStampMode
// Quantum signers
mldsaSigner *MLDSASigner
slhdsaSigner *SLHDSASigner
quantumSigner *quantum.QuantumSigner
// Block tracking
cchainHeight atomic.Uint64
qchainHeight atomic.Uint64
stampCache *cache.LRU[common.Hash, *QuantumStamp]
// Synchronization
mu sync.RWMutex
stampQueue chan *stampRequest
verifyQueue chan *verifyRequest
// Metrics
stampsCreated atomic.Uint64
stampsVerified atomic.Uint64
stampsFailed atomic.Uint64
}
type stampRequest struct {
block *types.Block
response chan *QuantumStamp
err chan error
}
type verifyRequest struct {
stamp *QuantumStamp
block *types.Block
response chan bool
}
// MLDSASigner wraps ML-DSA operations
type MLDSASigner struct {
mode mldsa.Mode
privKey *mldsa.PrivateKey
pubKey *mldsa.PublicKey
}
// SLHDSASigner wraps SLH-DSA operations
type SLHDSASigner struct {
mode slhdsa.Mode
privKey *slhdsa.PrivateKey
pubKey *slhdsa.PublicKey
}
// NewQuantumStamper creates a new quantum stamper for C-Chain blocks
func NewQuantumStamper(log log.Logger, mode QuantumStampMode, cacheSize int) (*QuantumStamper, error) {
qs := &QuantumStamper{
log: log,
mode: mode,
stampCache: &cache.LRU[common.Hash, *QuantumStamp]{Size: cacheSize},
stampQueue: make(chan *stampRequest, 100),
verifyQueue: make(chan *verifyRequest, 100),
}
// Initialize quantum signers based on mode
if err := qs.initializeSigners(); err != nil {
return nil, fmt.Errorf("failed to initialize signers: %w", err)
}
// Initialize Corona quantum signer for additional security
qs.quantumSigner = quantum.NewQuantumSigner(log, 1, 256, 5*time.Minute, cacheSize)
// Start worker goroutines
go qs.stampWorker()
go qs.verifyWorker()
qs.enabled.Store(true)
log.Info("Quantum stamper initialized",
"mode", mode,
"cacheSize", cacheSize)
return qs, nil
}
// initializeSigners creates the cryptographic signers based on mode
func (qs *QuantumStamper) initializeSigners() error {
switch qs.mode {
case StampModeMLDSA44:
return qs.initMLDSA(mldsa.MLDSA44)
case StampModeMLDSA65:
return qs.initMLDSA(mldsa.MLDSA65)
case StampModeMLDSA87:
return qs.initMLDSA(mldsa.MLDSA87)
case StampModeSLHDSA:
return qs.initSLHDSA(slhdsa.SHA2_128f)
case StampModeHybrid:
if err := qs.initMLDSA(mldsa.MLDSA65); err != nil {
return err
}
return qs.initSLHDSA(slhdsa.SHA2_128f)
default:
return ErrInvalidSignatureMode
}
}
func (qs *QuantumStamper) initMLDSA(mode mldsa.Mode) error {
priv, err := mldsa.GenerateKey(rand.Reader, mode)
if err != nil {
return fmt.Errorf("failed to generate ML-DSA key: %w", err)
}
qs.mldsaSigner = &MLDSASigner{
mode: mode,
privKey: priv,
pubKey: priv.PublicKey,
}
qs.log.Info("ML-DSA signer initialized", "mode", mode)
return nil
}
func (qs *QuantumStamper) initSLHDSA(mode slhdsa.Mode) error {
priv, err := slhdsa.GenerateKey(rand.Reader, mode)
if err != nil {
return fmt.Errorf("failed to generate SLH-DSA key: %w", err)
}
qs.slhdsaSigner = &SLHDSASigner{
mode: mode,
privKey: priv,
pubKey: priv.PublicKey,
}
qs.log.Info("SLH-DSA signer initialized", "mode", mode)
return nil
}
// StampBlock creates a quantum stamp for a C-Chain block during replay
func (qs *QuantumStamper) StampBlock(block *types.Block) (*QuantumStamp, error) {
if !qs.enabled.Load() {
return nil, ErrStampingDisabled
}
// Check cache first
blockHash := block.Hash()
if cached, found := qs.stampCache.Get(blockHash); found {
return cached, nil
}
// Create stamp request
req := &stampRequest{
block: block,
response: make(chan *QuantumStamp, 1),
err: make(chan error, 1),
}
select {
case qs.stampQueue <- req:
select {
case stamp := <-req.response:
return stamp, nil
case err := <-req.err:
return nil, err
case <-time.After(30 * time.Second):
return nil, errors.New("stamping timeout")
}
case <-time.After(5 * time.Second):
return nil, errors.New("stamp queue full")
}
}
// stampWorker processes stamp requests
func (qs *QuantumStamper) stampWorker() {
for req := range qs.stampQueue {
stamp, err := qs.createStamp(req.block)
if err != nil {
req.err <- err
} else {
req.response <- stamp
}
}
}
// createStamp creates the actual quantum stamp
func (qs *QuantumStamper) createStamp(block *types.Block) (*QuantumStamp, error) {
qs.mu.Lock()
defer qs.mu.Unlock()
blockHeight := block.NumberU64()
blockHash := block.Hash()
// Update C-Chain height
qs.cchainHeight.Store(blockHeight)
// Calculate Q-Chain height (synchronized with C-Chain)
qHeight := qs.calculateQChainHeight(blockHeight)
qs.qchainHeight.Store(qHeight)
// Create stamp data
stamp := &QuantumStamp{
CChainHeight: blockHeight,
CChainHash: blockHash,
QChainHeight: qHeight,
Mode: qs.mode,
Timestamp: time.Now(),
StateRoot: block.Root(),
ReceiptsRoot: block.ReceiptHash(),
GasUsed: block.GasUsed(),
Nonce: generateNonce(),
}
// Set logs bloom (limited to 256 bytes for efficiency)
bloomBytes := block.Bloom().Bytes()
if len(bloomBytes) > 256 {
stamp.LogsBloom = bloomBytes[:256]
} else {
stamp.LogsBloom = bloomBytes
}
// Generate Q-Chain block hash
stamp.QChainHash = qs.generateQChainHash(stamp)
// Create signatures based on mode
signData := qs.prepareSignatureData(stamp)
switch qs.mode {
case StampModeMLDSA44, StampModeMLDSA65, StampModeMLDSA87:
if err := qs.signWithMLDSA(stamp, signData); err != nil {
return nil, err
}
case StampModeSLHDSA:
if err := qs.signWithSLHDSA(stamp, signData); err != nil {
return nil, err
}
case StampModeHybrid:
if err := qs.signWithMLDSA(stamp, signData); err != nil {
return nil, err
}
if err := qs.signWithSLHDSA(stamp, signData); err != nil {
return nil, err
}
}
// Cache the stamp
qs.stampCache.Put(blockHash, stamp)
qs.stampsCreated.Add(1)
// Log progress every 1000 blocks
if blockHeight%1000 == 0 {
qs.log.Info("Quantum stamping progress",
"cchainHeight", blockHeight,
"qchainHeight", qHeight,
"totalStamped", qs.stampsCreated.Load())
}
return stamp, nil
}
// calculateQChainHeight determines Q-Chain height based on C-Chain height
func (qs *QuantumStamper) calculateQChainHeight(cchainHeight uint64) uint64 {
// Q-Chain maintains 1:1 correspondence with C-Chain during replay
// But starts from block 1 (genesis is block 0)
return cchainHeight + 1
}
// generateQChainHash creates a quantum-enhanced hash for Q-Chain block
func (qs *QuantumStamper) generateQChainHash(stamp *QuantumStamp) common.Hash {
hasher := sha256.New()
// Include C-Chain reference
hasher.Write(stamp.CChainHash.Bytes())
binary.Write(hasher, binary.BigEndian, stamp.CChainHeight)
// Include Q-Chain data
binary.Write(hasher, binary.BigEndian, stamp.QChainHeight)
hasher.Write(stamp.StateRoot.Bytes())
hasher.Write(stamp.ReceiptsRoot.Bytes())
hasher.Write(stamp.Nonce)
// Add timestamp for temporal ordering
binary.Write(hasher, binary.BigEndian, stamp.Timestamp.UnixNano())
sum := hasher.Sum(nil)
return common.BytesToHash(sum)
}
// prepareSignatureData creates the data to be signed
func (qs *QuantumStamper) prepareSignatureData(stamp *QuantumStamp) []byte {
data := make([]byte, 0, 512)
// Core block data
data = append(data, stamp.CChainHash.Bytes()...)
data = append(data, stamp.QChainHash.Bytes()...)
// Heights
heightBytes := make([]byte, 16)
binary.BigEndian.PutUint64(heightBytes[:8], stamp.CChainHeight)
binary.BigEndian.PutUint64(heightBytes[8:], stamp.QChainHeight)
data = append(data, heightBytes...)
// State data
data = append(data, stamp.StateRoot.Bytes()...)
data = append(data, stamp.ReceiptsRoot.Bytes()...)
// Gas and timestamp
gasBytes := make([]byte, 8)
binary.BigEndian.PutUint64(gasBytes, stamp.GasUsed)
data = append(data, gasBytes...)
timestampBytes := make([]byte, 8)
binary.BigEndian.PutUint64(timestampBytes, uint64(stamp.Timestamp.UnixNano()))
data = append(data, timestampBytes...)
// Nonce for uniqueness
data = append(data, stamp.Nonce...)
return data
}
// signWithMLDSA signs data using Crystal-Dilithium
func (qs *QuantumStamper) signWithMLDSA(stamp *QuantumStamp, data []byte) error {
if qs.mldsaSigner == nil {
return errors.New("ML-DSA signer not initialized")
}
signature, err := qs.mldsaSigner.privKey.Sign(rand.Reader, data, nil)
if err != nil {
return fmt.Errorf("ML-DSA signing failed: %w", err)
}
stamp.MLDSASignature = signature
stamp.PublicKeyML = qs.mldsaSigner.pubKey.Bytes()
return nil
}
// signWithSLHDSA signs data using SPHINCS+
func (qs *QuantumStamper) signWithSLHDSA(stamp *QuantumStamp, data []byte) error {
if qs.slhdsaSigner == nil {
return errors.New("SLH-DSA signer not initialized")
}
signature, err := qs.slhdsaSigner.privKey.Sign(rand.Reader, data, nil)
if err != nil {
return fmt.Errorf("failed to sign with SLH-DSA: %w", err)
}
stamp.SLHDSASignature = signature
stamp.PublicKeySLH = qs.slhdsaSigner.pubKey.Bytes()
return nil
}
// VerifyStamp verifies a quantum stamp
func (qs *QuantumStamper) VerifyStamp(stamp *QuantumStamp, block *types.Block) bool {
if !qs.enabled.Load() {
return false
}
req := &verifyRequest{
stamp: stamp,
block: block,
response: make(chan bool, 1),
}
select {
case qs.verifyQueue <- req:
select {
case valid := <-req.response:
return valid
case <-time.After(10 * time.Second):
return false
}
default:
// Queue full, verify synchronously
return qs.verifyStampSync(stamp, block)
}
}
// verifyWorker processes verification requests
func (qs *QuantumStamper) verifyWorker() {
for req := range qs.verifyQueue {
valid := qs.verifyStampSync(req.stamp, req.block)
req.response <- valid
}
}
// stampMatchesBlock reports whether a stamp commits to exactly the C-Chain block
// it claims. It is the ONE definition of that correspondence: the sequential and
// the accelerated batch paths both call it, so an accelerated verification can
// never accept a field the sequential one refuses.
func stampMatchesBlock(stamp *QuantumStamp, block *types.Block) bool {
return stamp.CChainHeight == block.NumberU64() &&
stamp.CChainHash == block.Hash() &&
stamp.StateRoot == block.Root() &&
stamp.ReceiptsRoot == block.ReceiptHash() &&
stamp.GasUsed == block.GasUsed()
}
// verifyStampSync performs synchronous stamp verification
func (qs *QuantumStamper) verifyStampSync(stamp *QuantumStamp, block *types.Block) bool {
if !stampMatchesBlock(stamp, block) {
return false
}
// Prepare signature data
signData := qs.prepareSignatureData(stamp)
// Verify signatures based on mode
switch stamp.Mode {
case StampModeMLDSA44, StampModeMLDSA65, StampModeMLDSA87:
if !qs.verifyMLDSA(stamp, signData) {
qs.stampsFailed.Add(1)
return false
}
case StampModeSLHDSA:
if !qs.verifySLHDSA(stamp, signData) {
qs.stampsFailed.Add(1)
return false
}
case StampModeHybrid:
if !qs.verifyMLDSA(stamp, signData) || !qs.verifySLHDSA(stamp, signData) {
qs.stampsFailed.Add(1)
return false
}
default:
return false
}
qs.stampsVerified.Add(1)
return true
}
// verifyMLDSA verifies Crystal-Dilithium signature
func (qs *QuantumStamper) verifyMLDSA(stamp *QuantumStamp, data []byte) bool {
if len(stamp.MLDSASignature) == 0 || len(stamp.PublicKeyML) == 0 {
return false
}
// Recreate public key from bytes
pubKey, err := mldsa.PublicKeyFromBytes(stamp.PublicKeyML, mldsa.MLDSA65)
if err != nil {
return false
}
return pubKey.Verify(data, stamp.MLDSASignature, nil)
}
// verifySLHDSA verifies SPHINCS+ signature
func (qs *QuantumStamper) verifySLHDSA(stamp *QuantumStamp, data []byte) bool {
if len(stamp.SLHDSASignature) == 0 || len(stamp.PublicKeySLH) == 0 {
return false
}
// Recreate public key from bytes
pubKey, err := slhdsa.PublicKeyFromBytes(stamp.PublicKeySLH, slhdsa.SHA2_128f)
if err != nil {
return false
}
return pubKey.Verify(data, stamp.SLHDSASignature, nil)
}
// VerifyStampBatch verifies a batch of quantum stamps using GPU acceleration
// when available, falling back to sequential CPU verification.
func (qs *QuantumStamper) VerifyStampBatch(stamps []*QuantumStamp, blocks []*types.Block) []bool {
if len(stamps) != len(blocks) || len(stamps) == 0 {
return nil
}
results := make([]bool, len(stamps))
// Try GPU batch path for ML-DSA stamps
if accel.Available() && len(stamps) >= accel.DilithiumBatchThreshold && qs.mldsaSigner != nil {
if qs.gpuBatchVerifyStamps(stamps, blocks, results) {
return results
}
// GPU failed, fall through to CPU
}
// CPU sequential fallback
for i := range stamps {
results[i] = qs.verifyStampSync(stamps[i], blocks[i])
}
return results
}
// gpuBatchVerifyStamps runs ML-DSA batch verification on GPU.
// Returns true if GPU path succeeded (results populated), false to fall back.
func (qs *QuantumStamper) gpuBatchVerifyStamps(stamps []*QuantumStamp, blocks []*types.Block, results []bool) bool {
n := len(stamps)
// Pre-validate block correspondence before GPU work
signDataSlice := make([][]byte, 0, n)
sigSlice := make([][]byte, 0, n)
pkSlice := make([][]byte, 0, n)
indices := make([]int, 0, n) // original indices of ML-DSA stamps
for i, stamp := range stamps {
block := blocks[i]
// Skip non-ML-DSA modes
if stamp.Mode == StampModeSLHDSA {
continue
}
// Verify block correspondence first (cheap check), through the same
// predicate the sequential path uses. Re-listing the fields here had
// already lost ReceiptsRoot, so a stamp with a forged receipts root was
// accepted whenever the batch went to the accelerator and refused when it
// did not.
if !stampMatchesBlock(stamp, block) {
results[i] = false
continue
}
if len(stamp.MLDSASignature) == 0 || len(stamp.PublicKeyML) == 0 {
results[i] = false
continue
}
signDataSlice = append(signDataSlice, qs.prepareSignatureData(stamp))
sigSlice = append(sigSlice, stamp.MLDSASignature)
pkSlice = append(pkSlice, stamp.PublicKeyML)
indices = append(indices, i)
}
if len(signDataSlice) < accel.DilithiumBatchThreshold {
return false
}
sess, err := accel.NewSession()
if err != nil {
return false
}
defer sess.Close()
latticeOps := sess.Lattice()
// Determine fixed sizes
sigSize := mldsa.GetSignatureSize(mldsa.MLDSA65)
pkSize := mldsa.GetPublicKeySize(mldsa.MLDSA65)
maxMsgLen := 0
for _, d := range signDataSlice {
if len(d) > maxMsgLen {
maxMsgLen = len(d)
}
}
batchN := len(signDataSlice)
msgBuf := make([]uint8, batchN*maxMsgLen)
sigBuf := make([]uint8, batchN*sigSize)
pkBuf := make([]uint8, batchN*pkSize)
for i := 0; i < batchN; i++ {
copy(msgBuf[i*maxMsgLen:], signDataSlice[i])
copy(sigBuf[i*sigSize:], sigSlice[i])
copy(pkBuf[i*pkSize:], pkSlice[i])
}
msgT, err := accel.NewTensorWithData[uint8](sess, []int{batchN, maxMsgLen}, msgBuf)
if err != nil {
return false
}
defer msgT.Close()
sigT, err := accel.NewTensorWithData[uint8](sess, []int{batchN, sigSize}, sigBuf)
if err != nil {
return false
}
defer sigT.Close()
pkT, err := accel.NewTensorWithData[uint8](sess, []int{batchN, pkSize}, pkBuf)
if err != nil {
return false
}
defer pkT.Close()
resT, err := accel.NewTensor[uint8](sess, []int{batchN})
if err != nil {
return false
}
defer resT.Close()
if err := latticeOps.DilithiumVerifyBatch(msgT.Untyped(), sigT.Untyped(), pkT.Untyped(), resT.Untyped()); err != nil {
return false
}
gpuResults, err := resT.ToSlice()
if err != nil {
return false
}
for i, idx := range indices {
results[idx] = gpuResults[i] == 1
if results[idx] {
qs.stampsVerified.Add(1)
} else {
qs.stampsFailed.Add(1)
}
}
// Handle hybrid mode: also verify SLH-DSA for hybrid stamps (CPU only)
qs.verifyHybridSLHDSA(stamps, indices, signDataSlice, results)
return true
}
// verifyHybridSLHDSA runs the SLH-DSA leg of every hybrid stamp in the batch that
// passed ML-DSA.
//
// signDataSlice is indexed by BATCH POSITION while indices maps position to the
// caller's stamp index; the two must be read together. Reading signDataSlice[0]
// for every stamp verified each hybrid stamp's SLH-DSA signature against the FIRST
// batch entry's signing bytes, so a signature over data the stamp does not commit
// to satisfied its post-quantum leg — and a batch submitter chooses what entry
// zero contains.
func (qs *QuantumStamper) verifyHybridSLHDSA(stamps []*QuantumStamp, indices []int, signDataSlice [][]byte, results []bool) {
for pos, idx := range indices {
if stamps[idx].Mode != StampModeHybrid || !results[idx] {
continue
}
if !qs.verifySLHDSA(stamps[idx], signDataSlice[pos]) {
results[idx] = false
qs.stampsFailed.Add(1)
}
}
}
// GetStampForBlock retrieves a stamp for a specific block
func (qs *QuantumStamper) GetStampForBlock(blockHash common.Hash) (*QuantumStamp, bool) {
return qs.stampCache.Get(blockHash)
}
// GetCurrentHeights returns current C-Chain and Q-Chain heights
func (qs *QuantumStamper) GetCurrentHeights() (cchainHeight, qchainHeight uint64) {
return qs.cchainHeight.Load(), qs.qchainHeight.Load()
}
// GetMetrics returns stamping metrics
func (qs *QuantumStamper) GetMetrics() map[string]uint64 {
return map[string]uint64{
"stamps_created": qs.stampsCreated.Load(),
"stamps_verified": qs.stampsVerified.Load(),
"stamps_failed": qs.stampsFailed.Load(),
"cchain_height": qs.cchainHeight.Load(),
"qchain_height": qs.qchainHeight.Load(),
}
}
// Enable enables quantum stamping
func (qs *QuantumStamper) Enable() {
qs.enabled.Store(true)
qs.log.Info("Quantum stamping enabled")
}
// Disable disables quantum stamping
func (qs *QuantumStamper) Disable() {
qs.enabled.Store(false)
qs.log.Info("Quantum stamping disabled")
}
// Close cleanly shuts down the stamper
func (qs *QuantumStamper) Close() {
qs.Disable()
close(qs.stampQueue)
close(qs.verifyQueue)
}
// Helper functions
func generateNonce() []byte {
nonce := make([]byte, 32)
rand.Read(nonce)
return nonce
}
// ExportStamps exports all stamps for persistence
func (qs *QuantumStamper) ExportStamps() map[common.Hash]*QuantumStamp {
stamps := make(map[common.Hash]*QuantumStamp)
// Cache iteration not supported; returns empty map
return stamps
}
// ImportStamps imports stamps from persistence
func (qs *QuantumStamper) ImportStamps(stamps map[common.Hash]*QuantumStamp) {
for hash, stamp := range stamps {
qs.stampCache.Put(hash, stamp)
}
qs.log.Info("Imported quantum stamps", "count", len(stamps))
}
-548
View File
@@ -1,548 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Real-time Q-Chain Quantum Stamping for Live Block Production
package stamper
import (
"context"
"errors"
"fmt"
"sync"
"time"
"github.com/luxfi/geth/common"
"github.com/luxfi/geth/core"
"github.com/luxfi/geth/core/types"
"github.com/luxfi/geth/ethdb"
"github.com/luxfi/geth/event"
"github.com/luxfi/log"
)
var (
ErrAlreadyRunning = errors.New("realtime stamper already running")
ErrNotRunning = errors.New("realtime stamper not running")
ErrNoBlockchain = errors.New("blockchain not configured")
)
// RealtimeStamperConfig configuration for real-time quantum stamping
type RealtimeStamperConfig struct {
Mode QuantumStampMode
CacheSize int
StampingInterval time.Duration // Minimum interval between stamps
BatchSize int // Number of blocks to batch stamp
VerifyPrevious bool // Verify previous stamps on startup
PersistenceDB ethdb.Database
EnableMetrics bool
}
// RealtimeQuantumStamper provides real-time quantum stamping for C-Chain blocks
type RealtimeQuantumStamper struct {
stamper *QuantumStamper
blockchain *core.BlockChain
config *RealtimeStamperConfig
logger log.Logger
// Block subscription
blockChan chan *types.Block
blockSub event.Subscription
headChan chan core.ChainHeadEvent
headSub event.Subscription
// Control
ctx context.Context
cancel context.CancelFunc
running bool
mu sync.RWMutex
// Batching
batchMu sync.Mutex
batch []*types.Block
batchTimer *time.Timer
// Metrics
metrics *StamperMetrics
}
// StamperMetrics tracks stamping performance metrics
type StamperMetrics struct {
BlocksReceived uint64
BlocksStamped uint64
BlocksVerified uint64
StampingErrors uint64
AvgStampingTimeMs float64
LastStampedHeight uint64
LastStampedHash common.Hash
QChainHeight uint64
}
// NewRealtimeQuantumStamper creates a new real-time quantum stamper
func NewRealtimeQuantumStamper(
logger log.Logger,
blockchain *core.BlockChain,
config *RealtimeStamperConfig,
) (*RealtimeQuantumStamper, error) {
if blockchain == nil {
return nil, ErrNoBlockchain
}
// Create base stamper
stamper, err := NewQuantumStamper(logger, config.Mode, config.CacheSize)
if err != nil {
return nil, fmt.Errorf("failed to create quantum stamper: %w", err)
}
rts := &RealtimeQuantumStamper{
stamper: stamper,
blockchain: blockchain,
config: config,
logger: logger,
blockChan: make(chan *types.Block, 100),
batch: make([]*types.Block, 0, config.BatchSize),
metrics: &StamperMetrics{},
}
// Verify previous stamps if configured
if config.VerifyPrevious {
if err := rts.verifyHistoricalStamps(); err != nil {
logger.Warn("Historical stamp verification failed", "error", err)
}
}
return rts, nil
}
// Start begins real-time quantum stamping
func (rts *RealtimeQuantumStamper) Start() error {
rts.mu.Lock()
defer rts.mu.Unlock()
if rts.running {
return ErrAlreadyRunning
}
rts.logger.Info("Starting real-time quantum stamper",
"mode", rts.config.Mode,
"interval", rts.config.StampingInterval,
"batchSize", rts.config.BatchSize)
// Create context for lifecycle management
rts.ctx, rts.cancel = context.WithCancel(context.Background())
// Subscribe to new blocks
rts.headChan = make(chan core.ChainHeadEvent, 10)
rts.headSub = rts.blockchain.SubscribeChainHeadEvent(rts.headChan)
// Start workers
go rts.blockProcessor()
go rts.stampingWorker()
go rts.metricsReporter()
rts.running = true
rts.logger.Info("Real-time quantum stamper started")
return nil
}
// Stop halts real-time quantum stamping
func (rts *RealtimeQuantumStamper) Stop() error {
rts.mu.Lock()
defer rts.mu.Unlock()
if !rts.running {
return ErrNotRunning
}
rts.logger.Info("Stopping real-time quantum stamper")
// Cancel context
rts.cancel()
// Unsubscribe from events
if rts.headSub != nil {
rts.headSub.Unsubscribe()
}
// Process any remaining batch
rts.processBatch()
// Close channels
close(rts.blockChan)
if rts.headChan != nil {
close(rts.headChan)
}
// Close base stamper
rts.stamper.Close()
rts.running = false
rts.logger.Info("Real-time quantum stamper stopped")
return nil
}
// blockProcessor handles incoming blocks
func (rts *RealtimeQuantumStamper) blockProcessor() {
for {
select {
case <-rts.ctx.Done():
return
case event, ok := <-rts.headChan:
if !ok {
return
}
rts.metrics.BlocksReceived++
// Get the block from the header
block := rts.blockchain.GetBlock(event.Header.Hash(), event.Header.Number.Uint64())
if block == nil {
rts.logger.Error("Failed to get block", "hash", event.Header.Hash(), "number", event.Header.Number)
continue
}
// Add to batch
rts.batchMu.Lock()
rts.batch = append(rts.batch, block)
// Check if batch is full
if len(rts.batch) >= rts.config.BatchSize {
rts.processBatch()
} else if rts.batchTimer == nil {
// Start batch timer
rts.batchTimer = time.AfterFunc(rts.config.StampingInterval, func() {
rts.batchMu.Lock()
defer rts.batchMu.Unlock()
rts.processBatch()
})
}
rts.batchMu.Unlock()
// Log important blocks
if block.NumberU64()%1000 == 0 {
rts.logger.Info("Processing milestone block",
"height", block.NumberU64(),
"hash", block.Hash().Hex())
}
}
}
}
// processBatch stamps a batch of blocks
func (rts *RealtimeQuantumStamper) processBatch() {
if len(rts.batch) == 0 {
return
}
startTime := time.Now()
blocks := rts.batch
rts.batch = make([]*types.Block, 0, rts.config.BatchSize)
// Cancel timer if running
if rts.batchTimer != nil {
rts.batchTimer.Stop()
rts.batchTimer = nil
}
// Stamp blocks in parallel
var wg sync.WaitGroup
stampChan := make(chan *stampResult, len(blocks))
for _, block := range blocks {
wg.Add(1)
go func(b *types.Block) {
defer wg.Done()
stamp, err := rts.stamper.StampBlock(b)
stampChan <- &stampResult{
block: b,
stamp: stamp,
err: err,
}
}(block)
}
// Wait for all stamps
go func() {
wg.Wait()
close(stampChan)
}()
// Collect results
successCount := 0
for result := range stampChan {
if result.err != nil {
rts.logger.Warn("Failed to stamp block",
"height", result.block.NumberU64(),
"error", result.err)
rts.metrics.StampingErrors++
} else {
successCount++
rts.metrics.BlocksStamped++
rts.metrics.LastStampedHeight = result.block.NumberU64()
rts.metrics.LastStampedHash = result.block.Hash()
rts.metrics.QChainHeight = result.stamp.QChainHeight
// Persist stamp if configured
if rts.config.PersistenceDB != nil {
rts.persistStamp(result.stamp)
}
}
}
// Update metrics
duration := time.Since(startTime)
avgMs := float64(duration.Milliseconds()) / float64(len(blocks))
rts.metrics.AvgStampingTimeMs = (rts.metrics.AvgStampingTimeMs + avgMs) / 2
rts.logger.Info("Batch stamping completed",
"blocks", len(blocks),
"success", successCount,
"duration", duration,
"avgMs", avgMs)
}
type stampResult struct {
block *types.Block
stamp *QuantumStamp
err error
}
// stampingWorker handles continuous stamping
func (rts *RealtimeQuantumStamper) stampingWorker() {
ticker := time.NewTicker(rts.config.StampingInterval)
defer ticker.Stop()
for {
select {
case <-rts.ctx.Done():
return
case <-ticker.C:
// Force batch processing if any blocks pending
rts.batchMu.Lock()
if len(rts.batch) > 0 {
rts.processBatch()
}
rts.batchMu.Unlock()
}
}
}
// metricsReporter periodically reports metrics
func (rts *RealtimeQuantumStamper) metricsReporter() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-rts.ctx.Done():
return
case <-ticker.C:
rts.reportMetrics()
}
}
}
// reportMetrics logs current metrics
func (rts *RealtimeQuantumStamper) reportMetrics() {
rts.mu.RLock()
metrics := *rts.metrics
rts.mu.RUnlock()
successRate := float64(0)
if metrics.BlocksReceived > 0 {
successRate = float64(metrics.BlocksStamped) * 100 / float64(metrics.BlocksReceived)
}
rts.logger.Info("Quantum stamping metrics",
"received", metrics.BlocksReceived,
"stamped", metrics.BlocksStamped,
"verified", metrics.BlocksVerified,
"errors", metrics.StampingErrors,
"successRate", fmt.Sprintf("%.2f%%", successRate),
"avgTimeMs", fmt.Sprintf("%.2f", metrics.AvgStampingTimeMs),
"lastHeight", metrics.LastStampedHeight,
"qHeight", metrics.QChainHeight)
}
// verifyHistoricalStamps verifies stamps from previous runs
func (rts *RealtimeQuantumStamper) verifyHistoricalStamps() error {
if rts.config.PersistenceDB == nil {
return nil
}
rts.logger.Info("Verifying historical quantum stamps...")
// Get current chain head
head := rts.blockchain.CurrentBlock()
if head == nil {
return errors.New("no chain head")
}
verifiedCount := 0
failedCount := 0
startHeight := uint64(0)
if head.Number.Uint64() > 1000 {
startHeight = head.Number.Uint64() - 1000
}
// Verify last 1000 blocks
for height := startHeight; height <= head.Number.Uint64(); height++ {
block := rts.blockchain.GetBlockByNumber(height)
if block == nil {
continue
}
// Load stamp from persistence
stamp, err := rts.loadStamp(block.Hash())
if err != nil {
continue // No stamp found
}
// Verify stamp
if rts.stamper.VerifyStamp(stamp, block) {
verifiedCount++
rts.metrics.BlocksVerified++
} else {
failedCount++
rts.logger.Warn("Historical stamp verification failed",
"height", height,
"hash", block.Hash().Hex())
}
}
rts.logger.Info("Historical verification complete",
"verified", verifiedCount,
"failed", failedCount)
return nil
}
// persistStamp saves a quantum stamp to persistence database
func (rts *RealtimeQuantumStamper) persistStamp(stamp *QuantumStamp) error {
if rts.config.PersistenceDB == nil {
return nil
}
key := append([]byte("qstamp-realtime-"), stamp.CChainHash.Bytes()...)
data, err := encodeStamp(stamp)
if err != nil {
return err
}
return rts.config.PersistenceDB.Put(key, data)
}
// loadStamp loads a quantum stamp from persistence database
func (rts *RealtimeQuantumStamper) loadStamp(blockHash common.Hash) (*QuantumStamp, error) {
if rts.config.PersistenceDB == nil {
return nil, errors.New("no persistence database")
}
key := append([]byte("qstamp-realtime-"), blockHash.Bytes()...)
data, err := rts.config.PersistenceDB.Get(key)
if err != nil {
return nil, err
}
return decodeStamp(data)
}
// GetMetrics returns current metrics
func (rts *RealtimeQuantumStamper) GetMetrics() *StamperMetrics {
rts.mu.RLock()
defer rts.mu.RUnlock()
metrics := *rts.metrics
return &metrics
}
// VerifyBlock verifies quantum stamp for a specific block
func (rts *RealtimeQuantumStamper) VerifyBlock(blockHash common.Hash) (bool, error) {
// Get block from blockchain
block := rts.blockchain.GetBlockByHash(blockHash)
if block == nil {
return false, errors.New("block not found")
}
// Check cache first
stamp, found := rts.stamper.GetStampForBlock(blockHash)
if !found {
// Try loading from persistence
var err error
stamp, err = rts.loadStamp(blockHash)
if err != nil {
return false, fmt.Errorf("stamp not found: %w", err)
}
}
// Verify stamp
valid := rts.stamper.VerifyStamp(stamp, block)
if valid {
rts.metrics.BlocksVerified++
}
return valid, nil
}
// GetStampInfo returns quantum stamp information for a block
func (rts *RealtimeQuantumStamper) GetStampInfo(blockHash common.Hash) (map[string]interface{}, error) {
stamp, found := rts.stamper.GetStampForBlock(blockHash)
if !found {
// Try loading from persistence
var err error
stamp, err = rts.loadStamp(blockHash)
if err != nil {
return nil, fmt.Errorf("stamp not found: %w", err)
}
}
info := map[string]interface{}{
"cchain_height": stamp.CChainHeight,
"cchain_hash": stamp.CChainHash.Hex(),
"qchain_height": stamp.QChainHeight,
"qchain_hash": stamp.QChainHash.Hex(),
"mode": stamp.Mode,
"timestamp": stamp.Timestamp.Format(time.RFC3339),
"state_root": stamp.StateRoot.Hex(),
"receipts_root": stamp.ReceiptsRoot.Hex(),
"gas_used": stamp.GasUsed,
}
// Add signature info
if len(stamp.MLDSASignature) > 0 {
info["mldsa_sig_size"] = len(stamp.MLDSASignature)
info["mldsa_pubkey_size"] = len(stamp.PublicKeyML)
}
if len(stamp.SLHDSASignature) > 0 {
info["slhdsa_sig_size"] = len(stamp.SLHDSASignature)
info["slhdsa_pubkey_size"] = len(stamp.PublicKeySLH)
}
return info, nil
}
// Helper functions for stamp encoding/decoding
func encodeStamp(stamp *QuantumStamp) ([]byte, error) {
// Simplified encoding
data := make([]byte, 0, 1024)
// Add fields...
// This is simplified - implement proper encoding
return data, nil
}
func decodeStamp(data []byte) (*QuantumStamp, error) {
// Simplified decoding
stamp := &QuantumStamp{}
// Decode fields...
// This is simplified - implement proper decoding
return stamp, nil
}