mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
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:
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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[:]...)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user