zkvm: a transaction's identity is its content, and one predicate decides

The proof cache was keyed on tx.ID as read off the wire, plus the proof
type, the proof and its public inputs — all four the sender's to choose.
Copy an accepted transaction's four onto a transaction spending entirely
different notes and the key matched; the hit returned nil from the cache,
ahead of verifyPublicInputs, which is the only thing tying those inputs to
what the transaction spends. Shielded value, backed by a proof that
attests to nothing about it.

The id is no longer on the wire. parseTransaction derives it, the cache is
keyed on ComputeID(), and a block's id folds in ComputeID() rather than
whatever id arrived — which also ends the block and vertex id malleability
that one flipped byte bought.

Every block and vertex id, and the first public input of every proof, now
open with sha256(ChainID ‖ NetworkID). It is not on the wire, so a block
from another chain has no parent here and its proofs do not verify, rather
than passing a check someone can forget to write.

Also on the consensus surface:

  Verify required height == parent+1 and nothing more, so a block whose
  parent was a long-accepted ancestor verified, and accepting it rewound
  the tip and left the height index naming an orphan to every peer that
  bootstraps from it. Verify now requires the parent to be the tip or a
  block above it, and Accept re-checks against the tip, which moves
  between the two.

  Vertex.Verify checked its transactions and nothing else — no parent, no
  height — so a vertex naming no parent at height 1<<40 verified, and
  accepting it set the store's height to 1<<40 and left the chain unable
  to propose again. Its decoder also ignored trailing bytes, so one
  logical vertex had unboundedly many encodings under one id, each of
  which the store writes to disk.

  BuildBlock skipped the ValidateBasic that Verify runs, so a transaction
  with an out-of-range Type — read straight off the wire — was assembled
  into every block and refused by every node including the proposer, and
  nothing evicted it. admit is now that one predicate, assembly drops what
  it cannot build, and a transaction names an expiry so a bounded pool
  drains.

  BuildBlock stamped an unclamped time.Now(), which is below a parent
  legally up to maxClockSkew ahead — a block its own Verify refuses.

  ParseBlock never tracked what it parsed and SetPreference discarded it,
  so a follower could verify the first block of a run and not the second.

  A genesis naming no timestamp was stamped with the wall clock, hashed
  into the genesis id: one genesis file, a different chain per node and
  per restart.

  VerifyBlockProof took a GPU path on accel.Available() — a second, inline
  copy of the Groth16 checks that consulted neither the cache nor the
  dummy-key refusal — so whether a block was accepted turned on whether
  the node had an accelerator.

Reads that failed were reported as absence: the spent set answered "not
spent" for a set it could not read, which is the whole double-spend gate;
the state root answered "empty" at boot AND on rollback, where it also
returned nil; the UTXO store answered "not found". Each now reports the
failure.

Deleted, none of it reachable: a node-side wallet holding plaintext
spending keys behind an HTTP endpoint, with a loadAddresses that returned
nil so the keys were unusable anyway; note construction and the package's
only panic; an FHE processor whose verifier tested len(proof) >= 128 and
whose "encryption" wrote the plaintext into the first eight bytes; a
zwitness carrying a TODO; a 256-level sparse Merkle tree that computed no
root and wrote its cache under a read lock; six endpoints answering "Not
implemented"; a Signature field nothing verified; and eight config knobs
nothing read. The endpoints that remain answer at the path the node mounts
them on, rather than behind a mux that dispatches on a path it never sees.

Coverage 58.9% -> 76.7% on a denominator 539 statements smaller.
This commit is contained in:
2026-08-28 15:02:32 -07:00
parent ec074f11aa
commit 6919ac8229
29 changed files with 969 additions and 2803 deletions
-255
View File
@@ -1,255 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zkvm
import (
"errors"
"fmt"
"math/big"
"github.com/consensys/gnark-crypto/ecc/bn254"
"github.com/consensys/gnark-crypto/ecc/bn254/fr"
"github.com/luxfi/accel"
"github.com/luxfi/log"
)
// msmGPU computes multi-scalar multiplication using GPU acceleration.
// scalars: field elements, bases: G1 affine points.
// Returns the resulting G1 point = sum(scalar_i * base_i).
// Falls back to CPU if GPU is unavailable.
func msmGPU(scalars []fr.Element, bases []bn254.G1Affine, logger log.Logger) (bn254.G1Affine, error) {
var result bn254.G1Affine
n := len(scalars)
if n == 0 {
return result, errors.New("empty MSM inputs")
}
if n != len(bases) {
return result, errors.New("MSM: scalars/bases length mismatch")
}
// GPU path
if accel.Available() {
session, err := accel.DefaultSession()
if err == nil {
r, gpuErr := msmWithSession(session, scalars, bases)
if gpuErr == nil {
return r, nil
}
logger.Debug("GPU MSM failed, falling back to CPU", log.Reflect("error", gpuErr))
}
}
// CPU fallback: sequential scalar multiplication
return msmCPU(scalars, bases), nil
}
// msmWithSession runs MSM on GPU via accel session.
func msmWithSession(session *accel.Session, scalars []fr.Element, bases []bn254.G1Affine) (bn254.G1Affine, error) {
var result bn254.G1Affine
n := len(scalars)
// Serialize scalars: each fr.Element is 32 bytes
const scalarSize = 32
scalarBytes := make([]byte, n*scalarSize)
for i, s := range scalars {
b := s.Bytes() // [32]byte big-endian
copy(scalarBytes[i*scalarSize:], b[:])
}
// Serialize bases: each G1Affine is 64 bytes (two 32-byte coordinates)
const pointSize = 64
baseBytes := make([]byte, n*pointSize)
for i, p := range bases {
b := p.Marshal()
copy(baseBytes[i*pointSize:], b[:pointSize])
}
// Create tensors
scalarTensor, err := accel.NewTensorWithData[byte](session, []int{n, scalarSize}, scalarBytes)
if err != nil {
return result, err
}
defer scalarTensor.Close()
baseTensor, err := accel.NewTensorWithData[byte](session, []int{n, pointSize}, baseBytes)
if err != nil {
return result, err
}
defer baseTensor.Close()
resultTensor, err := accel.NewTensor[byte](session, []int{pointSize})
if err != nil {
return result, err
}
defer resultTensor.Close()
// Execute MSM
zk := session.ZK()
if err := zk.MSM(scalarTensor.Untyped(), baseTensor.Untyped(), resultTensor.Untyped()); err != nil {
return result, err
}
if err := session.Sync(); err != nil {
return result, err
}
// Read result
resultBytes, err := resultTensor.ToSlice()
if err != nil {
return result, err
}
if err := result.Unmarshal(resultBytes); err != nil {
return result, fmt.Errorf("unmarshal MSM result: %w", err)
}
return result, nil
}
// msmCPU computes MSM sequentially on CPU.
func msmCPU(scalars []fr.Element, bases []bn254.G1Affine) bn254.G1Affine {
var result bn254.G1Affine
var scalar big.Int
for i := range scalars {
var term bn254.G1Affine
term.ScalarMultiplication(&bases[i], scalars[i].BigInt(&scalar))
result.Add(&result, &term)
}
return result
}
// batchVerifyProofsGPU verifies multiple ZK proofs in a block using GPU batch MSM.
// Returns per-proof results. Falls back to sequential CPU verification.
func batchVerifyProofsGPU(pv *ProofVerifier, txs []*Transaction) []error {
results := make([]error, len(txs))
// Collect Groth16 proofs that can be batched
type batchEntry struct {
index int
proof *Groth16Proof
vk *Groth16VerifyingKey
wit []fr.Element
}
var batch []batchEntry
for i, tx := range txs {
if tx.Proof == nil {
results[i] = errors.New("transaction missing proof")
continue
}
// Strict-PQ gate (Red H1). The GPU batch path deserializes and
// verifies Groth16 INLINE below, bypassing VerifyTransactionProof,
// so the strict-PQ refusal MUST be applied here too — otherwise a
// strict-PQ chain could verify a classical proof via the batch path.
if err := pv.refuseClassicalUnderStrictPQ(tx.Proof.ProofType); err != nil {
results[i] = err
continue
}
// Only batch Groth16 — other types (incl. "stark") verified
// individually through VerifyTransactionProof, which routes STARK
// to the strict-PQ starkfri verifier.
if tx.Proof.ProofType != "groth16" {
results[i] = pv.VerifyTransactionProof(tx)
continue
}
vkBytes, exists := pv.verifyingKeys[string(tx.Type)]
if !exists {
results[i] = errors.New("verifying key not found for circuit type")
continue
}
if err := pv.verifyPublicInputs(tx); err != nil {
results[i] = err
continue
}
if len(tx.Proof.ProofData) < 256 {
results[i] = errors.New("invalid proof data length for Groth16")
continue
}
grothProof, err := deserializeGroth16Proof(tx.Proof.ProofData)
if err != nil {
results[i] = fmt.Errorf("deserialize proof: %w", err)
continue
}
vk, err := deserializeVerifyingKey(vkBytes)
if err != nil {
results[i] = fmt.Errorf("deserialize vk: %w", err)
continue
}
if err := validateVerifyingKey(vk); err != nil {
results[i] = err
continue
}
witness := make([]fr.Element, 0, len(tx.Proof.PublicInputs))
for _, inputBytes := range tx.Proof.PublicInputs {
var elem fr.Element
elem.SetBytes(inputBytes)
witness = append(witness, elem)
}
batch = append(batch, batchEntry{index: i, proof: grothProof, vk: vk, wit: witness})
}
// One pairing check serves both doors: a block of one transaction and a
// block of many must reach the same verdict on the same proof.
for _, e := range batch {
results[e.index] = verifyGroth16Pairing(e.proof, e.vk, e.wit)
}
return results
}
// poseidonHashGPU computes Poseidon hash of inputs using GPU acceleration.
// Falls back to SHA-256 if GPU is unavailable.
func poseidonHashGPU(inputs [][]byte) ([]byte, error) {
if !accel.Available() || len(inputs) == 0 {
return nil, errors.New("GPU unavailable")
}
session, err := accel.DefaultSession()
if err != nil {
return nil, err
}
// Each input is a field element (uint64). Pad or truncate to 8 bytes.
const fieldSize = 8
n := len(inputs)
flat := make([]byte, n*fieldSize)
for i, inp := range inputs {
if len(inp) >= fieldSize {
copy(flat[i*fieldSize:], inp[:fieldSize])
} else {
copy(flat[i*fieldSize:], inp)
}
}
inputTensor, err := accel.NewTensorWithData[byte](session, []int{1, n * fieldSize}, flat)
if err != nil {
return nil, err
}
defer inputTensor.Close()
outputTensor, err := accel.NewTensor[byte](session, []int{1, fieldSize})
if err != nil {
return nil, err
}
defer outputTensor.Close()
crypto := session.Crypto()
if err := crypto.Poseidon(inputTensor.Untyped(), outputTensor.Untyped()); err != nil {
return nil, err
}
if err := session.Sync(); err != nil {
return nil, err
}
return outputTensor.ToSlice()
}
-294
View File
@@ -1,294 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zkvm
import (
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"sync"
"time"
"github.com/luxfi/log"
"github.com/luxfi/database"
)
const (
// Database prefixes
addressPrefix = 0x30
viewingKeyPrefix = 0x31
addressCountKey = "address_count"
)
// AddressManager manages private addresses and viewing keys
type AddressManager struct {
db database.Database
log log.Logger
enablePrivate bool
// Address mappings
addresses map[string]*PrivateAddress // address -> private address info
viewingKeys map[string][]string // viewing key -> addresses
addressCount uint64
mu sync.RWMutex
}
// PrivateAddress represents a private address
type PrivateAddress struct {
Address []byte `json:"address"` // Public address (32 bytes)
ViewingKey []byte `json:"viewingKey"` // Viewing key for scanning
SpendingKey []byte `json:"spendingKey"` // Spending key (private)
Diversifier []byte `json:"diversifier"` // Address diversifier
IncomingViewKey []byte `json:"incomingViewKey"` // For incoming payments only
CreatedAt int64 `json:"createdAt"`
}
// NewAddressManager creates a new address manager
func NewAddressManager(db database.Database, enablePrivate bool, log log.Logger) (*AddressManager, error) {
am := &AddressManager{
db: db,
log: log,
enablePrivate: enablePrivate,
addresses: make(map[string]*PrivateAddress),
viewingKeys: make(map[string][]string),
}
// Load address count
countBytes, err := db.Get([]byte(addressCountKey))
if err == database.ErrNotFound {
am.addressCount = 0
} else if err != nil {
return nil, err
} else {
am.addressCount = binary.BigEndian.Uint64(countBytes)
}
if err := am.loadAddresses(); err != nil {
return nil, err
}
return am, nil
}
// GenerateAddress generates a new private address
func (am *AddressManager) GenerateAddress() (*PrivateAddress, error) {
if !am.enablePrivate {
return nil, errors.New("private addresses not enabled")
}
am.mu.Lock()
defer am.mu.Unlock()
// Generate keys
spendingKey := make([]byte, 32)
if _, err := rand.Read(spendingKey); err != nil {
return nil, err
}
// Derive viewing key from spending key
h := sha256.New()
h.Write([]byte("viewing_key"))
h.Write(spendingKey)
viewingKey := h.Sum(nil)
// Derive incoming viewing key
h.Reset()
h.Write([]byte("incoming_view_key"))
h.Write(viewingKey)
incomingViewKey := h.Sum(nil)
// Generate diversifier
diversifier := make([]byte, 11)
if _, err := rand.Read(diversifier); err != nil {
return nil, err
}
// Derive address from viewing key and diversifier
h.Reset()
h.Write(viewingKey)
h.Write(diversifier)
address := h.Sum(nil)
// Create private address
privAddr := &PrivateAddress{
Address: address,
ViewingKey: viewingKey,
SpendingKey: spendingKey,
Diversifier: diversifier,
IncomingViewKey: incomingViewKey,
CreatedAt: time.Now().Unix(),
}
// Store in database
if err := am.storeAddress(privAddr); err != nil {
return nil, err
}
// Update caches
addressStr := string(address)
am.addresses[addressStr] = privAddr
viewingKeyStr := string(viewingKey)
am.viewingKeys[viewingKeyStr] = append(am.viewingKeys[viewingKeyStr], addressStr)
// Update count
am.addressCount++
countBytes := make([]byte, 8)
binary.BigEndian.PutUint64(countBytes, am.addressCount)
if err := am.db.Put([]byte(addressCountKey), countBytes); err != nil {
return nil, err
}
am.log.Info("Generated new private address",
log.String("address", fmt.Sprintf("%x", address[:8])),
log.String("diversifier", fmt.Sprintf("%x", diversifier[:4])),
)
return privAddr, nil
}
// GetAddress retrieves an address by its public address
func (am *AddressManager) GetAddress(address []byte) (*PrivateAddress, error) {
am.mu.RLock()
defer am.mu.RUnlock()
addressStr := string(address)
privAddr, exists := am.addresses[addressStr]
if !exists {
return nil, errors.New("address not found")
}
return privAddr, nil
}
// GetAddressesByViewingKey returns all addresses associated with a viewing key
func (am *AddressManager) GetAddressesByViewingKey(viewingKey []byte) ([]*PrivateAddress, error) {
am.mu.RLock()
defer am.mu.RUnlock()
viewingKeyStr := string(viewingKey)
addressStrs, exists := am.viewingKeys[viewingKeyStr]
if !exists {
return nil, nil
}
addresses := make([]*PrivateAddress, 0, len(addressStrs))
for _, addressStr := range addressStrs {
if addr, exists := am.addresses[addressStr]; exists {
addresses = append(addresses, addr)
}
}
return addresses, nil
}
// CanDecryptNote checks if we have the keys to decrypt a note
func (am *AddressManager) CanDecryptNote(ephemeralPubKey []byte, address []byte) bool {
am.mu.RLock()
defer am.mu.RUnlock()
_, exists := am.addresses[string(address)]
return exists
}
// DeriveNullifier derives a nullifier using the spending key
func (am *AddressManager) DeriveNullifier(address []byte, note *Note) ([]byte, error) {
am.mu.RLock()
defer am.mu.RUnlock()
privAddr, exists := am.addresses[string(address)]
if !exists {
return nil, errors.New("address not found")
}
// Compute nullifier using spending key
nullifier := ComputeNullifier(note, privAddr.SpendingKey)
return nullifier, nil
}
// SignTransaction signs a transaction with the appropriate keys
func (am *AddressManager) SignTransaction(tx *Transaction, signingAddresses [][]byte) error {
h := sha256.New()
h.Write(tx.ID[:])
for _, addr := range signingAddresses {
privAddr, err := am.GetAddress(addr)
if err != nil {
return err
}
// Sign with spending key
h.Write(privAddr.SpendingKey)
}
tx.Signature = h.Sum(nil)
return nil
}
// storeAddress stores an address in the database
func (am *AddressManager) storeAddress(privAddr *PrivateAddress) error {
// Serialize address
addrBytes, err := privAddr.Marshal()
if err != nil {
return err
}
// Store by address
key := makeAddressKey(privAddr.Address)
if err := am.db.Put(key, addrBytes); err != nil {
return err
}
// Store viewing key index
vkKey := makeViewingKeyKey(privAddr.ViewingKey, privAddr.Address)
if err := am.db.Put(vkKey, []byte{1}); err != nil {
return err
}
return nil
}
// loadAddresses loads addresses from database
func (am *AddressManager) loadAddresses() error {
return nil
}
// makeAddressKey creates a database key for an address
func makeAddressKey(address []byte) []byte {
key := make([]byte, 1+len(address))
key[0] = addressPrefix
copy(key[1:], address)
return key
}
// makeViewingKeyKey creates a database key for viewing key index
func makeViewingKeyKey(viewingKey, address []byte) []byte {
key := make([]byte, 1+len(viewingKey)+len(address))
key[0] = viewingKeyPrefix
copy(key[1:], viewingKey)
copy(key[1+len(viewingKey):], address)
return key
}
// GetAddressCount returns the total number of addresses
func (am *AddressManager) GetAddressCount() uint64 {
am.mu.RLock()
defer am.mu.RUnlock()
return am.addressCount
}
// Close closes the address manager
func (am *AddressManager) Close() {
am.mu.Lock()
defer am.mu.Unlock()
am.addresses = nil
am.viewingKeys = nil
}
+8 -8
View File
@@ -53,7 +53,7 @@ func newRefusingVM(t *testing.T) (*VM, *refusingDB) {
genesisBytes, err := json.Marshal(&Genesis{Timestamp: 1607144400, InitialTxs: []*Transaction{}})
require.NoError(t, err)
configBytes, err := json.Marshal(ZConfig{
ProofSystem: "groth16", MaxUTXOsPerBlock: 100, ProofCacheSize: 1000,
MaxTxPerBlock: 100, ProofCacheSize: 1000,
})
require.NoError(t, err)
@@ -93,7 +93,7 @@ func TestABlockThatCannotCommitSpendsNothing(t *testing.T) {
blk := built.(*Block)
tipBefore, heightBefore := vmImpl.chain.Tip()
rootBefore := append([]byte(nil), vmImpl.stateTree.GetRoot()...)
rootBefore := append([]byte(nil), vmImpl.root.Get()...)
db.refuse = true
require.ErrorIs(t, blk.Accept(context.Background()), errRefused)
@@ -109,9 +109,9 @@ func TestABlockThatCannotCommitSpendsNothing(t *testing.T) {
// And nothing in memory believes it did. This is the part that mattered:
// the nullifier cache is what decides whether a note can be spent, and a
// cache holding a spend the database does not have is a note lost forever.
require.False(t, vmImpl.nullifierDB.IsNullifierSpent(spent),
require.False(t, spentOf(t, vmImpl.nullifierDB, spent),
"the spent-nullifier cache must agree with the database")
require.True(t, bytes.Equal(rootBefore, vmImpl.stateTree.GetRoot()),
require.True(t, bytes.Equal(rootBefore, vmImpl.root.Get()),
"the committed state root must not have advanced")
tip, height := vmImpl.chain.Tip()
@@ -129,8 +129,8 @@ func TestABlockThatCannotCommitSpendsNothing(t *testing.T) {
has, err = db.Database.Has(makeNullifierKey(spent))
require.NoError(t, err)
require.True(t, has)
require.True(t, vmImpl.nullifierDB.IsNullifierSpent(spent))
require.False(t, bytes.Equal(rootBefore, vmImpl.stateTree.GetRoot()))
require.True(t, spentOf(t, vmImpl.nullifierDB, spent))
require.False(t, bytes.Equal(rootBefore, vmImpl.root.Get()))
tip, height = vmImpl.chain.Tip()
require.Equal(t, blk.ID(), tip)
@@ -162,7 +162,7 @@ func TestAVertexThatCannotCommitSpendsNothing(t *testing.T) {
has, err := db.Database.Has(makeNullifierKey(spent))
require.NoError(t, err)
require.False(t, has)
require.False(t, vmImpl.nullifierDB.IsNullifierSpent(spent))
require.False(t, spentOf(t, vmImpl.nullifierDB, spent))
tip, height := vmImpl.chain.Tip()
require.Equal(t, tipBefore, tip)
@@ -171,7 +171,7 @@ func TestAVertexThatCannotCommitSpendsNothing(t *testing.T) {
db.refuse = false
require.NoError(t, vtx.Accept(context.Background()))
require.True(t, vmImpl.nullifierDB.IsNullifierSpent(spent))
require.True(t, spentOf(t, vmImpl.nullifierDB, spent))
tip, _ = vmImpl.chain.Tip()
require.Equal(t, vtx.ID(), tip)
}
+64 -19
View File
@@ -10,6 +10,7 @@ import (
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/luxfi/log"
@@ -48,19 +49,26 @@ func (b *Block) ID() ids.ID {
return b.ID_
}
// computeID computes the block ID
// computeID computes the block ID.
//
// It opens with the chain's binding — sha256(ChainID ‖ NetworkID), which is NOT
// on the wire — so the same bytes name a different block on a different chain.
// Two chains with different ids and an identical genesis config would otherwise
// derive the same genesis id, and one chain's blocks would then chain onto the
// other's verbatim.
//
// Each transaction contributes ComputeID() rather than tx.ID: an id is a
// function of content, and a block whose identity depended on an id a peer
// supplied would have as many identities as the peer cared to send.
func (b *Block) computeID() ids.ID {
h := sha256.New()
h.Write(b.vm.bind[:])
h.Write(b.ParentID_[:])
binary.Write(h, binary.BigEndian, b.BlockHeight)
binary.Write(h, binary.BigEndian, b.BlockTimestamp)
// Include transaction IDs
for _, tx := range b.Txs {
txID := tx.ID
if txID == ids.Empty {
txID = tx.ComputeID()
}
txID := tx.ComputeID()
h.Write(txID[:])
}
@@ -101,13 +109,20 @@ func (b *Block) Status() uint8 {
return uint8(b.status)
}
// Verify verifies the block
// Verify verifies the block.
func (b *Block) Verify(ctx context.Context) error {
// Basic validation
if b.BlockHeight == 0 && b.ParentID_ != ids.Empty {
return errInvalidBlock
}
// A block off the wire is held to the bound a block this node builds is
// held to, so a proposer cannot produce one its own peers refuse.
if uint32(len(b.Txs)) > b.vm.config.MaxTxPerBlock {
return fmt.Errorf("%w: %d transactions over the %d cap",
errInvalidBlock, len(b.Txs), b.vm.config.MaxTxPerBlock)
}
// Verify timestamp
if b.BlockTimestamp > time.Now().Unix()+maxClockSkew {
return errFutureBlock
@@ -128,14 +143,9 @@ func (b *Block) Verify(ctx context.Context) error {
}
}
// Verify each transaction
// Verify each transaction against the SAME predicate assembly ran.
for _, tx := range b.Txs {
if err := tx.ValidateBasic(); err != nil {
return err
}
// Verify transaction proof
if err := b.vm.verifyTransaction(tx); err != nil {
if err := b.vm.admit(tx, b.BlockHeight); err != nil {
return err
}
}
@@ -159,6 +169,18 @@ func (b *Block) Verify(ctx context.Context) error {
return errors.New("invalid parent block type")
}
// The parent must be one this chain can still build on: the accepted
// tip, or a block verified above it and not yet decided. Height alone
// is not that check — a block whose parent is an OLD accepted block
// satisfies height == parent+1 perfectly well, and accepting it rewinds
// the tip and leaves the height index naming an orphan as the block at
// that height to every peer that bootstraps from it.
tip, tipHeight := b.vm.chain.Tip()
if parentBlock.ID() != tip && parentBlock.BlockHeight <= tipHeight {
return fmt.Errorf("%w: parent %s at height %d is beneath the tip at %d",
ErrNotOnTip, parentBlock.ID(), parentBlock.BlockHeight, tipHeight)
}
if b.BlockHeight != parentBlock.BlockHeight+1 {
return errInvalidHeight
}
@@ -173,6 +195,11 @@ func (b *Block) Verify(ctx context.Context) error {
return errInvalidStateRoot
}
// A block that verifies is one the engine may build on, so it has to be
// findable by id — including one parsed from a peer rather than built here.
// Tracking only self-built blocks leaves a follower able to verify the
// first block of a run and unable to verify the second.
b.vm.chain.Track(b)
return nil
}
@@ -186,6 +213,15 @@ func (b *Block) Verify(ctx context.Context) error {
// under a tip the chain had already advanced — a shielded pool half applied,
// with no way back and no way to apply the block again.
func (b *Block) Accept(ctx context.Context) error {
// A block extends the tip or it is not accepted. Verify reached the same
// verdict earlier, against the tip AT THAT TIME; the tip moves between the
// two, and a block whose parent has since been buried would otherwise write
// the height index and the tip pointer for an abandoned branch.
tip, _ := b.vm.chain.Tip()
if b.ParentID_ != tip {
return fmt.Errorf("%w: %s extends %s, which is not the tip %s",
ErrNotOnTip, b.ID(), b.ParentID_, tip)
}
return b.vm.chain.Accept(b)
}
@@ -212,7 +248,7 @@ func (b *Block) Write(database.Database) error {
}
}
}
return b.vm.stateTree.Finalize(b.StateRoot)
return b.vm.root.Finalize(b.StateRoot)
}
// Publish marks the block accepted and releases the transactions it carried.
@@ -224,6 +260,11 @@ func (b *Block) Publish() {
b.vm.mempool.RemoveTransaction(tx.ID)
}
// The chain has passed this height, so anything expiring at or below it
// can never enter a block. Nothing else drops those, and a pool full of
// them refuses every honest arrival paying the same floor.
b.vm.mempool.PruneExpired(b.BlockHeight)
b.vm.log.Info("Block accepted",
log.Uint64("height", b.BlockHeight),
log.String("id", b.ID().String()),
@@ -292,10 +333,10 @@ func ParseGenesis(genesisBytes []byte) (*Genesis, error) {
}
}
if genesis.Timestamp == 0 {
genesis.Timestamp = time.Now().Unix()
}
// A genesis that names no timestamp is stamped 0, not "now". The genesis
// timestamp is hashed into the genesis block id, so reading the wall clock
// here gave every node a different genesis id — a different chain — for the
// same genesis file, and a different one again after each restart.
return &genesis, nil
}
@@ -330,6 +371,10 @@ var (
errInvalidTimestamp = errors.New("invalid block timestamp")
errInvalidStateRoot = errors.New("invalid state root")
// ErrNotOnTip refuses a block that does not extend the chain: one whose
// parent is neither the accepted tip nor a block verified above it.
ErrNotOnTip = errors.New("zkvm: block does not extend the accepted tip")
// errDuplicateNullifier — the same nullifier appears twice inside one block or
// vertex, i.e. one shielded note spent twice. Fail closed.
errDuplicateNullifier = errors.New("nullifier spent twice in one block")
+1
View File
@@ -24,6 +24,7 @@ func blockTx(fee uint64) *Transaction {
Nullifiers: [][]byte{[]byte("n1"), []byte("n2")},
Outputs: []*ShieldedOutput{{Commitment: []byte("c"), EncryptedNote: []byte("n"), EphemeralPubKey: []byte("e"), OutputProof: []byte("p")}},
Proof: &ZKProof{ProofType: "groth16", ProofData: []byte("pd"), PublicInputs: [][]byte{[]byte("a"), []byte("bb")}},
Expiry: 1 << 20,
Memo: []byte("memo"),
}
tx.ID = tx.ComputeID()
+10 -9
View File
@@ -30,8 +30,9 @@ func spendTx(nullifiers ...[]byte) *Transaction {
OutputProof: make([]byte, 32),
},
},
Proof: &ZKProof{ProofType: "groth16", ProofData: make([]byte, 32)},
Fee: 1,
Proof: &ZKProof{ProofType: "groth16", ProofData: make([]byte, 32)},
Fee: 1,
Expiry: 1 << 20,
}
tx.ID = tx.ComputeID()
return tx
@@ -51,7 +52,7 @@ func acceptProofs(vmImpl *VM, txs ...*Transaction) {
pv := vmImpl.proofVerifier
pv.dummyKeys = false
for _, tx := range txs {
pv.proofCache.Add(string(pv.hashProof(tx)), true)
pv.proofCache.Add(tx.ComputeID(), true)
}
}
@@ -92,7 +93,7 @@ func TestStateRootHasOneDefinition(t *testing.T) {
txs := []*Transaction{spendTx(nullifier(1), nullifier(2)), spendTx(nullifier(3))}
h := sha256.New()
h.Write(vmImpl.stateTree.GetRoot())
h.Write(vmImpl.root.Get())
for _, tx := range txs {
for _, out := range tx.Outputs {
h.Write(out.Commitment)
@@ -114,14 +115,14 @@ func TestStateRootAdvancesOnlyOnFinalize(t *testing.T) {
vmImpl := setupTestVM(t)
defer vmImpl.Shutdown(context.Background())
before := append([]byte(nil), vmImpl.stateTree.GetRoot()...)
before := append([]byte(nil), vmImpl.root.Get()...)
txs := []*Transaction{spendTx(nullifier(4))}
root := vmImpl.computeStateRoot(txs)
require.Equal(before, vmImpl.stateTree.GetRoot(), "computing a root must not advance it")
require.Equal(before, vmImpl.root.Get(), "computing a root must not advance it")
require.NoError(vmImpl.stateTree.Finalize(root))
require.Equal(root, vmImpl.stateTree.GetRoot())
require.NoError(vmImpl.root.Finalize(root))
require.Equal(root, vmImpl.root.Get())
require.NotEqual(root, vmImpl.computeStateRoot(txs),
"the next block folds the same txs onto the new committed root")
}
@@ -136,7 +137,7 @@ func TestBlockVerifyRejectsDuplicateNullifier(t *testing.T) {
defer vmImpl.Shutdown(context.Background())
reused := nullifier(7)
require.False(vmImpl.nullifierDB.IsNullifierSpent(reused), "precondition: not yet spent")
require.False(spentOf(t, vmImpl.nullifierDB, reused), "precondition: not yet spent")
first, second := spendTx(reused), spendTx(reused)
second.Fee = 2 // distinct tx, same spent note
+57 -40
View File
@@ -7,7 +7,8 @@ import (
"context"
"crypto/sha256"
"encoding/binary"
"time"
"errors"
"fmt"
"github.com/luxfi/consensus/core/choices"
"github.com/luxfi/consensus/engine/dag/vertex"
@@ -17,6 +18,8 @@ import (
var _ vertex.DAGVM = (*VM)(nil)
var errNoTransactions = errors.New("zkvm: nothing to propose")
// Vertex represents a DAG vertex in the ZK UTXO chain.
// Conflict key: set of nullifiers spent in the vertex.
// Two vertices conflict iff their nullifier sets intersect.
@@ -40,11 +43,33 @@ func (v *Vertex) Parents() []ids.ID { return v.parents }
func (v *Vertex) Txs() []ids.ID { return v.txIDs }
func (v *Vertex) Status() choices.Status { return v.status }
// Verify holds a vertex to what a block is held to. It used to check the
// transactions and NOTHING ELSE — not the parents, not the height — so a vertex
// naming no parent at height 1<<40 verified, and accepting it set the store's
// height to 1<<40, pruned every block in flight, and left the linear chain
// unable to propose a child ever again.
func (v *Vertex) Verify(ctx context.Context) error {
// Every nullifier in the vertex must be distinct. verifyTransaction only sees
// accepted state; BuildVertex already refuses to batch conflicting txs, and a
// vertex that arrived on the wire is held to the same rule or one shielded
// note is spent twice.
if uint32(len(v.txs)) > v.vm.config.MaxTxPerBlock {
return fmt.Errorf("%w: %d transactions over the %d cap",
errInvalidBlock, len(v.txs), v.vm.config.MaxTxPerBlock)
}
// A vertex extends the frontier. The store keeps one tip for both shapes,
// so a vertex that names something else moves the chain sideways.
tip, tipHeight := v.vm.chain.Tip()
if len(v.parents) != 1 || v.parents[0] != tip {
return fmt.Errorf("%w: vertex parents %v do not name the tip %s",
ErrNotOnTip, v.parents, tip)
}
if v.height != tipHeight+1 {
return fmt.Errorf("%w: height %d does not follow the tip at %d",
errInvalidHeight, v.height, tipHeight)
}
// Every nullifier in the vertex must be distinct. admit only sees
// nullifiers already spent in ACCEPTED state; BuildVertex refuses to batch
// conflicting txs, and a vertex that arrived on the wire is held to the
// same rule or one shielded note is spent twice.
spentHere := make(map[string]struct{}, len(v.txs))
for _, tx := range v.txs {
for _, nullifier := range tx.Nullifiers {
@@ -55,10 +80,7 @@ func (v *Vertex) Verify(ctx context.Context) error {
}
}
for _, tx := range v.txs {
if err := tx.ValidateBasic(); err != nil {
return err
}
if err := v.vm.verifyTransaction(tx); err != nil {
if err := v.vm.admit(tx, v.height); err != nil {
return err
}
}
@@ -70,6 +92,12 @@ func (v *Vertex) Verify(ctx context.Context) error {
// the tip. A vertex is not a block — it has several parents and no timestamp —
// but it changes state the same way, and this is that way.
func (v *Vertex) Accept(ctx context.Context) error {
// The tip moves between Verify and here. See Block.Accept.
tip, _ := v.vm.chain.Tip()
if len(v.parents) != 1 || v.parents[0] != tip {
return fmt.Errorf("%w: vertex %s extends %v, and the tip is %s",
ErrNotOnTip, v.id, v.parents, tip)
}
return v.vm.chain.Accept(v)
}
@@ -147,18 +175,17 @@ func (v *Vertex) ConflictsVertex(other vertex.Vertex) bool {
return v.Conflicts(ov)
}
// computeID binds the chain, the shape and the content. See Block.computeID.
func (v *Vertex) computeID() ids.ID {
h := sha256.New()
h.Write(v.vm.bind[:])
binary.Write(h, binary.BigEndian, v.height)
binary.Write(h, binary.BigEndian, v.epoch)
for _, p := range v.parents {
h.Write(p[:])
}
for _, tx := range v.txs {
txID := tx.ID
if txID == ids.Empty {
txID = tx.ComputeID()
}
txID := tx.ComputeID()
h.Write(txID[:])
}
return ids.ID(h.Sum(nil))
@@ -168,16 +195,19 @@ func (v *Vertex) computeID() ids.ID {
func (vm *VM) BuildVertex(ctx context.Context) (vertex.Vertex, error) {
parent, height := vm.chain.Tip()
candidates := vm.mempool.GetPendingTransactions(int(vm.config.MaxUTXOsPerBlock))
candidates := vm.mempool.GetPendingTransactions(int(vm.config.MaxTxPerBlock))
if len(candidates) == 0 {
return nil, errNoTransactions
}
// Greedily batch non-conflicting txs: skip any tx whose nullifiers collide with the batch.
// Greedily batch non-conflicting txs: skip any tx whose nullifiers collide
// with the batch. admit is the same predicate Verify runs, so nothing is
// batched that a peer will refuse.
usedNullifiers := make(map[string]struct{})
var batch []*Transaction
for _, tx := range candidates {
if err := vm.verifyTransaction(tx); err != nil {
if err := vm.admit(tx, height+1); err != nil {
vm.mempool.RemoveTransaction(tx.ID)
continue
}
conflict := false
@@ -201,10 +231,7 @@ func (vm *VM) BuildVertex(ctx context.Context) (vertex.Vertex, error) {
txIDs := make([]ids.ID, len(batch))
for i, tx := range batch {
if tx.ID == ids.Empty {
tx.ID = tx.ComputeID()
}
txIDs[i] = tx.ID
txIDs[i] = tx.ComputeID()
}
v := &Vertex{
@@ -223,23 +250,12 @@ func (vm *VM) BuildVertex(ctx context.Context) (vertex.Vertex, error) {
// ParseVertex deserializes a vertex from bytes.
func (vm *VM) ParseVertex(ctx context.Context, b []byte) (vertex.Vertex, error) {
v, err := deserializeVertex(b, vm)
if err != nil {
return nil, err
}
return v, nil
return deserializeVertex(b, vm)
}
func (v *Vertex) serialize() []byte {
// Format: height(8) + epoch(4) + parentCount(4) + parents + txCount(4) + txBytes
size := 8 + 4 + 4 + len(v.parents)*32 + 4
for _, tx := range v.txs {
if tx.ID == ids.Empty {
tx.ID = tx.ComputeID()
}
}
// Estimate: use codec for real txs, here store IDs + raw nullifiers for vertex identity
buf := make([]byte, 0, size+len(v.txs)*64)
b8 := make([]byte, 8)
@@ -320,14 +336,20 @@ func deserializeVertex(data []byte, vm *VM) (*Vertex, error) {
if err != nil {
return nil, err
}
if tx.ID == ids.Empty {
tx.ID = tx.ComputeID()
}
txs = append(txs, tx)
txIDs = append(txIDs, tx.ID)
pos += int(txLen)
}
// Every byte handed in belongs to the vertex, or the value read is not the
// value that was sent. Without this, arbitrary trailing bytes rode along in
// v.bytes — which is what the store writes to disk — so one logical vertex
// had unboundedly many encodings all mapping to the same id, and a peer
// could park megabytes under a legitimate one.
if pos != len(data) {
return nil, errTrailingBytes
}
v := &Vertex{
height: height,
epoch: epoch,
@@ -341,8 +363,3 @@ func deserializeVertex(data []byte, vm *VM) (*Vertex, error) {
v.id = v.computeID()
return v, nil
}
var errNoTransactions = errInvalidBlock // reuse existing sentinel
// compile-time epoch zero for fresh chains
var _ = time.Now // avoid unused import if time were only in block.go
+54 -9
View File
@@ -10,6 +10,7 @@ import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/luxfi/database/memdb"
@@ -85,22 +86,66 @@ func TestZKVM_FeePolicy_AcceptsMinFee(t *testing.T) {
// zero-fee tx with a 4xx; a paying tx admits into the mempool.
func TestZKVM_HTTP_SendTransaction_FeePolicy(t *testing.T) {
v := newZKVMForFeeTest(t)
mux := NewRPCHandler(v)
handler := v.endpoints()["/sendTransaction"]
post := func(body []byte) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/sendTransaction", bytes.NewReader(body))
post := func(tx *Transaction) *httptest.ResponseRecorder {
body, err := json.Marshal(tx)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/v1/bc/z/sendTransaction", bytes.NewReader(body))
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
handler.ServeHTTP(w, req)
return w
}
zero, _ := json.Marshal(&Transaction{ID: ids.GenerateTestID(), Fee: 0})
if got := post(zero); got.Code < 400 {
t.Fatalf("POST /sendTransaction (zero-fee) status = %d, want >= 400", got.Code)
// A transaction with a shape no block can carry.
shaped := func(fee uint64) *Transaction {
return &Transaction{
ID: ids.GenerateTestID(), // ignored: the id is derived
Type: TransactionTypeTransfer,
Nullifiers: [][]byte{[]byte("n")},
Outputs: []*ShieldedOutput{{Commitment: []byte("c")}},
Proof: &ZKProof{ProofType: "stark", ProofData: []byte("p")},
Expiry: 100,
Fee: fee,
}
}
paid, _ := json.Marshal(&Transaction{ID: ids.GenerateTestID(), Fee: fee.MinTxFeeFloor})
if got := post(paid); got.Code != http.StatusOK {
if got := post(shaped(0)); got.Code != http.StatusPaymentRequired {
t.Fatalf("POST /sendTransaction (zero-fee) status = %d, want 402", got.Code)
}
// A transaction that pays but can never enter a block occupies a slot in a
// bounded pool forever, so the door refuses its shape too.
empty := &Transaction{Fee: fee.MinTxFeeFloor, Expiry: 100}
if got := post(empty); got.Code != http.StatusBadRequest {
t.Fatalf("POST /sendTransaction (no inputs) status = %d, want 400", got.Code)
}
noExpiry := shaped(fee.MinTxFeeFloor)
noExpiry.Expiry = 0
if got := post(noExpiry); got.Code != http.StatusBadRequest {
t.Fatalf("POST /sendTransaction (no expiry) status = %d, want 400", got.Code)
}
paid := shaped(fee.MinTxFeeFloor)
got := post(paid)
if got.Code != http.StatusOK {
t.Fatalf("POST /sendTransaction (paid) status = %d body=%q, want 200", got.Code, got.Body.String())
}
if !strings.Contains(got.Body.String(), paid.ComputeID().String()) {
t.Fatalf("the reply must name the derived id, got %q", got.Body.String())
}
if v.mempool.Size() != 1 {
t.Fatalf("mempool size = %d, want 1", v.mempool.Size())
}
// A method the endpoint does not serve is refused, not silently accepted.
req := httptest.NewRequest(http.MethodGet, "/v1/bc/z/sendTransaction", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Fatalf("GET /sendTransaction status = %d, want 405", w.Code)
}
}
-198
View File
@@ -1,198 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zkvm
import (
"encoding/binary"
"errors"
"sync"
"github.com/luxfi/log"
)
// FHEProcessor handles fully homomorphic encryption operations
type FHEProcessor struct {
config ZConfig
log log.Logger
// FHE parameters
publicKey []byte
evalKey []byte
// Processing statistics
processCount uint64
mu sync.RWMutex
}
// NewFHEProcessor creates a new FHE processor
func NewFHEProcessor(config ZConfig, log log.Logger) (*FHEProcessor, error) {
if !config.EnableFHE {
return nil, errors.New("FHE not enabled in config")
}
fp := &FHEProcessor{
config: config,
log: log,
}
// Initialize FHE parameters
if err := fp.initializeFHEParams(); err != nil {
return nil, err
}
return fp, nil
}
// VerifyFHEOperations verifies FHE operations in a transaction
func (fp *FHEProcessor) VerifyFHEOperations(tx *Transaction) error {
if tx.FHEData == nil {
return errors.New("no FHE data in transaction")
}
fp.mu.Lock()
fp.processCount++
fp.mu.Unlock()
// Verify circuit ID is supported
if !fp.isCircuitSupported(tx.FHEData.CircuitID) {
return errors.New("unsupported FHE circuit")
}
// Verify encrypted inputs
if len(tx.FHEData.EncryptedInputs) == 0 {
return errors.New("no encrypted inputs provided")
}
// Verify computation proof
if len(tx.FHEData.ComputationProof) < 128 {
return errors.New("invalid computation proof")
}
fp.log.Debug("FHE operations verified",
log.String("txID", tx.ID.String()),
log.String("circuitID", tx.FHEData.CircuitID),
log.Int("inputCount", len(tx.FHEData.EncryptedInputs)),
)
return nil
}
// ProcessFHEComputation performs an FHE computation
func (fp *FHEProcessor) ProcessFHEComputation(
circuitID string,
encryptedInputs [][]byte,
) ([]byte, []byte, error) {
// Stub: returns zero-filled result and proof
encryptedResult := make([]byte, 256)
computationProof := make([]byte, 256)
fp.log.Debug("FHE computation processed",
log.String("circuitID", circuitID),
log.Int("inputCount", len(encryptedInputs)),
)
return encryptedResult, computationProof, nil
}
// EncryptValue encrypts a value using FHE
func (fp *FHEProcessor) EncryptValue(value uint64) ([]byte, error) {
// Stub: encodes value in first 8 bytes (not encrypted)
ciphertext := make([]byte, 256)
// Encode value in first 8 bytes (insecure, just for testing)
binary.BigEndian.PutUint64(ciphertext[:8], value)
return ciphertext, nil
}
// DecryptValue decrypts an FHE ciphertext
func (fp *FHEProcessor) DecryptValue(ciphertext []byte, privateKey []byte) (uint64, error) {
if len(ciphertext) < 256 {
return 0, errors.New("invalid ciphertext length")
}
// Stub: reads value from first 8 bytes (not decrypting)
value := binary.BigEndian.Uint64(ciphertext[:8])
return value, nil
}
// AddCiphertexts performs homomorphic addition
func (fp *FHEProcessor) AddCiphertexts(ct1, ct2 []byte) ([]byte, error) {
if len(ct1) != 256 || len(ct2) != 256 {
return nil, errors.New("invalid ciphertext length")
}
// Stub: adds raw encoded values (not homomorphic)
val1 := binary.BigEndian.Uint64(ct1[:8])
val2 := binary.BigEndian.Uint64(ct2[:8])
result := make([]byte, 256)
binary.BigEndian.PutUint64(result[:8], val1+val2)
return result, nil
}
// MultiplyCiphertext performs homomorphic multiplication by a plaintext
func (fp *FHEProcessor) MultiplyCiphertext(ct []byte, scalar uint64) ([]byte, error) {
if len(ct) != 256 {
return nil, errors.New("invalid ciphertext length")
}
// Stub: multiplies raw encoded value (not homomorphic)
val := binary.BigEndian.Uint64(ct[:8])
result := make([]byte, 256)
binary.BigEndian.PutUint64(result[:8], val*scalar)
return result, nil
}
// initializeFHEParams initializes FHE parameters
func (fp *FHEProcessor) initializeFHEParams() error {
// Stub: generates zero-filled keys matching expected sizes
switch fp.config.FHEScheme {
case "BFV":
fp.publicKey = make([]byte, 2048)
fp.evalKey = make([]byte, 4096)
case "CKKS":
fp.publicKey = make([]byte, 2048)
fp.evalKey = make([]byte, 4096)
default:
return errors.New("unsupported FHE scheme")
}
fp.log.Info("FHE parameters initialized",
log.String("scheme", fp.config.FHEScheme),
log.Int("securityLevel", int(fp.config.SecurityLevel)),
)
return nil
}
// isCircuitSupported checks if a circuit ID is supported
func (fp *FHEProcessor) isCircuitSupported(circuitID string) bool {
supportedCircuits := []string{
"add",
"multiply",
"compare",
"range_proof",
"balance_check",
}
for _, supported := range supportedCircuits {
if circuitID == supported {
return true
}
}
return false
}
// GetStats returns FHE processing statistics
func (fp *FHEProcessor) GetStats() uint64 {
fp.mu.RLock()
defer fp.mu.RUnlock()
return fp.processCount
}
+120 -295
View File
@@ -5,338 +5,163 @@ package zkvm
import (
"encoding/json"
"errors"
"net/http"
"github.com/luxfi/ids"
)
// NewRPCHandler creates the main RPC handler
func NewRPCHandler(vm *VM) http.Handler {
mux := http.NewServeMux()
var (
errUnknownTransaction = errors.New("transaction not found")
errNoCommitment = errors.New("commitment required")
errNoNullifier = errors.New("nullifier required")
)
// Transaction endpoints
mux.HandleFunc("/sendTransaction", handleSendTransaction(vm))
mux.HandleFunc("/getTransaction", handleGetTransaction(vm))
mux.HandleFunc("/createShieldedTransaction", handleCreateShieldedTransaction(vm))
// Block endpoints
mux.HandleFunc("/getBlock", handleGetBlock(vm))
mux.HandleFunc("/getLatestBlock", handleGetLatestBlock(vm))
// UTXO endpoints
mux.HandleFunc("/getUTXO", handleGetUTXO(vm))
mux.HandleFunc("/getUTXOCount", handleGetUTXOCount(vm))
// Status endpoints
mux.HandleFunc("/getStatus", handleGetStatus(vm))
return mux
}
// NewPrivacyHandler creates the privacy-specific handler
func NewPrivacyHandler(vm *VM) http.Handler {
mux := http.NewServeMux()
// Address management
mux.HandleFunc("/generateAddress", handleGenerateAddress(vm))
mux.HandleFunc("/getAddress", handleGetAddress(vm))
// Note decryption
mux.HandleFunc("/decryptNote", handleDecryptNote(vm))
// Nullifier queries
mux.HandleFunc("/isNullifierSpent", handleIsNullifierSpent(vm))
return mux
}
// NewProofHandler creates the proof-specific handler
func NewProofHandler(vm *VM) http.Handler {
mux := http.NewServeMux()
// Proof generation
mux.HandleFunc("/generateTransferProof", handleGenerateTransferProof(vm))
mux.HandleFunc("/generateShieldProof", handleGenerateShieldProof(vm))
// Proof verification
mux.HandleFunc("/verifyProof", handleVerifyProof(vm))
// Proof statistics
mux.HandleFunc("/getProofStats", handleGetProofStats(vm))
return mux
}
// Transaction handlers
func handleSendTransaction(vm *VM) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var tx Transaction
if err := json.NewDecoder(r.Body).Decode(&tx); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Fee policy: refuse zero-fee user txs at the public entry
// before mempool/heap pressure. Internal callers (consensus
// replay) bypass this gate by reaching Mempool directly.
if err := vm.gateUserTx(&tx); err != nil {
http.Error(w, err.Error(), http.StatusPaymentRequired)
return
}
// Add to mempool
if err := vm.mempool.AddTransaction(&tx); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
resp := map[string]interface{}{
"txID": tx.ID.String(),
"success": true,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
// The node mounts each CreateHandlers key under /v1/bc/<chainID> and matches
// that full path EXACTLY, then hands the handler the request with the path it
// arrived on. A mux behind one key therefore serves nothing: it dispatches on
// r.URL.Path, which is the mounted path and never the route it registered. The
// key IS the route; one handler per key.
func (vm *VM) endpoints() map[string]http.Handler {
return map[string]http.Handler{
"/sendTransaction": post(vm.sendTransaction),
"/getTransaction": get(vm.getTransaction),
"/getBlock": get(vm.getBlock),
"/getLatestBlock": get(vm.getLatestBlock),
"/getUTXO": get(vm.getUTXO),
"/getUTXOCount": get(vm.getUTXOCount),
"/isNullifierSpent": get(vm.isNullifierSpent),
"/getStatus": get(vm.getStatus),
"/getProofStats": get(vm.getProofStats),
}
}
func handleGetTransaction(vm *VM) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
txIDStr := r.URL.Query().Get("txID")
if txIDStr == "" {
http.Error(w, "txID required", http.StatusBadRequest)
// handler is what an endpoint answers: a value to encode, or an error and the
// status that names it.
type handler func(*http.Request) (interface{}, int, error)
func get(h handler) http.Handler { return method(http.MethodGet, h) }
func post(h handler) http.Handler { return method(http.MethodPost, h) }
func method(verb string, h handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != verb {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
txID, err := ids.FromString(txIDStr)
v, status, err := h(r)
if err != nil {
http.Error(w, "Invalid txID", http.StatusBadRequest)
http.Error(w, err.Error(), status)
return
}
// Check mempool first
if vm.mempool.HasTransaction(txID) {
resp := map[string]interface{}{
"status": "pending",
"txID": txID.String(),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
return
}
http.Error(w, "Transaction not found", http.StatusNotFound)
}
json.NewEncoder(w).Encode(v)
})
}
func handleCreateShieldedTransaction(vm *VM) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
resp := map[string]interface{}{
"error": "Not implemented",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
func (vm *VM) sendTransaction(r *http.Request) (interface{}, int, error) {
var tx Transaction
if err := json.NewDecoder(http.MaxBytesReader(nil, r.Body, MaxTxSize)).Decode(&tx); err != nil {
return nil, http.StatusBadRequest, err
}
// Fee policy: refuse zero-fee user txs at the public entry before
// mempool pressure. Internal callers reach Mempool directly.
if err := vm.gateUserTx(&tx); err != nil {
return nil, http.StatusPaymentRequired, err
}
// Shape too. A transaction that cannot be built into any block must not
// occupy a slot in a bounded pool; assembly drops what it cannot build,
// and this stops the obvious garbage arriving in the first place.
if err := tx.ValidateBasic(); err != nil {
return nil, http.StatusBadRequest, err
}
if err := vm.mempool.AddTransaction(&tx); err != nil {
return nil, http.StatusBadRequest, err
}
// AddTransaction derives the id from the content, so this reports the
// transaction the chain will carry rather than the one the caller named.
return map[string]interface{}{"txID": tx.ID.String(), "success": true}, 0, nil
}
// Block handlers
func handleGetBlock(vm *VM) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
blockIDStr := r.URL.Query().Get("blockID")
if blockIDStr == "" {
http.Error(w, "blockID required", http.StatusBadRequest)
return
}
blockID, err := ids.FromString(blockIDStr)
if err != nil {
http.Error(w, "Invalid blockID", http.StatusBadRequest)
return
}
block, err := vm.GetBlock(r.Context(), blockID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
zkBlock := block.(*Block)
resp := zkBlock.ToSummary()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
func (vm *VM) getTransaction(r *http.Request) (interface{}, int, error) {
txID, err := ids.FromString(r.URL.Query().Get("txID"))
if err != nil {
return nil, http.StatusBadRequest, err
}
if !vm.mempool.HasTransaction(txID) {
return nil, http.StatusNotFound, errUnknownTransaction
}
return map[string]interface{}{"status": "pending", "txID": txID.String()}, 0, nil
}
func handleGetLatestBlock(vm *VM) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, _ := vm.chain.Tip()
block, err := vm.GetBlock(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
resp := block.(*Block).ToSummary()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
func (vm *VM) getBlock(r *http.Request) (interface{}, int, error) {
blockID, err := ids.FromString(r.URL.Query().Get("blockID"))
if err != nil {
return nil, http.StatusBadRequest, err
}
block, err := vm.GetBlock(r.Context(), blockID)
if err != nil {
return nil, http.StatusNotFound, err
}
return block.(*Block).ToSummary(), 0, nil
}
// UTXO handlers
func handleGetUTXO(vm *VM) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
commitment := r.URL.Query().Get("commitment")
if commitment == "" {
http.Error(w, "commitment required", http.StatusBadRequest)
return
}
utxo, err := vm.utxoDB.GetUTXO([]byte(commitment))
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(utxo)
func (vm *VM) getLatestBlock(r *http.Request) (interface{}, int, error) {
id, _ := vm.chain.Tip()
block, err := vm.GetBlock(r.Context(), id)
if err != nil {
return nil, http.StatusNotFound, err
}
return block.(*Block).ToSummary(), 0, nil
}
func handleGetUTXOCount(vm *VM) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
count := vm.utxoDB.GetUTXOCount()
resp := map[string]interface{}{
"count": count,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
func (vm *VM) getUTXO(r *http.Request) (interface{}, int, error) {
commitment := r.URL.Query().Get("commitment")
if commitment == "" {
return nil, http.StatusBadRequest, errNoCommitment
}
utxo, err := vm.utxoDB.GetUTXO([]byte(commitment))
if err != nil {
return nil, http.StatusNotFound, err
}
return utxo, 0, nil
}
// Status handler
func handleGetStatus(vm *VM) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
health, err := vm.HealthCheck(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(health)
}
func (vm *VM) getUTXOCount(*http.Request) (interface{}, int, error) {
return map[string]interface{}{"count": vm.utxoDB.GetUTXOCount()}, 0, nil
}
// Privacy handlers
func handleGenerateAddress(vm *VM) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
addr, err := vm.addressManager.GenerateAddress()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
resp := map[string]interface{}{
"address": addr.Address,
"viewingKey": addr.ViewingKey,
"incomingViewKey": addr.IncomingViewKey,
"diversifier": addr.Diversifier,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
// isNullifierSpent reports a READ FAILURE as a failure. Answering "not spent"
// for a set that could not be read is the answer that reopens a double spend.
func (vm *VM) isNullifierSpent(r *http.Request) (interface{}, int, error) {
nullifier := r.URL.Query().Get("nullifier")
if nullifier == "" {
return nil, http.StatusBadRequest, errNoNullifier
}
height, spent, err := vm.nullifierDB.Spent([]byte(nullifier))
if err != nil {
return nil, http.StatusInternalServerError, err
}
return map[string]interface{}{"nullifier": nullifier, "isSpent": spent, "height": height}, 0, nil
}
func handleGetAddress(vm *VM) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Would implement address lookup
http.Error(w, "Not implemented", http.StatusNotImplemented)
func (vm *VM) getStatus(r *http.Request) (interface{}, int, error) {
health, err := vm.HealthCheck(r.Context())
if err != nil {
return nil, http.StatusInternalServerError, err
}
return health, 0, nil
}
func handleDecryptNote(vm *VM) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Would implement note decryption
http.Error(w, "Not implemented", http.StatusNotImplemented)
}
}
func handleIsNullifierSpent(vm *VM) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
nullifier := r.URL.Query().Get("nullifier")
if nullifier == "" {
http.Error(w, "nullifier required", http.StatusBadRequest)
return
}
isSpent := vm.nullifierDB.IsNullifierSpent([]byte(nullifier))
resp := map[string]interface{}{
"nullifier": nullifier,
"isSpent": isSpent,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
}
// Proof handlers
func handleGenerateTransferProof(vm *VM) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Would implement proof generation
http.Error(w, "Not implemented", http.StatusNotImplemented)
}
}
func handleGenerateShieldProof(vm *VM) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Would implement shield proof generation
http.Error(w, "Not implemented", http.StatusNotImplemented)
}
}
func handleVerifyProof(vm *VM) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Would implement proof verification endpoint
http.Error(w, "Not implemented", http.StatusNotImplemented)
}
}
func handleGetProofStats(vm *VM) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
verifyCount, cacheHits, cacheMisses := vm.proofVerifier.GetStats()
resp := map[string]interface{}{
"verifyCount": verifyCount,
"cacheHits": cacheHits,
"cacheMisses": cacheMisses,
"cacheSize": vm.proofVerifier.GetCacheSize(),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
func (vm *VM) getProofStats(*http.Request) (interface{}, int, error) {
verifyCount, cacheHits, cacheMisses := vm.proofVerifier.GetStats()
return map[string]interface{}{
"verifyCount": verifyCount,
"cacheHits": cacheHits,
"cacheMisses": cacheMisses,
"cacheSize": vm.proofVerifier.GetCacheSize(),
}, 0, nil
}
+24 -46
View File
@@ -8,7 +8,6 @@ import (
"context"
"errors"
"sync"
"time"
"github.com/luxfi/log"
@@ -37,9 +36,8 @@ type Mempool struct {
// MempoolTx represents a transaction in the mempool
type MempoolTx struct {
tx *Transaction
addedAt time.Time
feePerByte uint64
tx *Transaction
fee uint64
}
// NewMempool creates a new mempool
@@ -53,11 +51,18 @@ func NewMempool(maxSize int, log log.Logger) *Mempool {
}
}
// AddTransaction adds a transaction to the mempool
// AddTransaction adds a transaction to the mempool.
//
// The id is derived here, not read from the caller. An HTTP client decoding
// straight into a Transaction supplies whatever id it likes, and a proposer
// that carried that id would compute a block id its own peers do not — the
// block it built would not be the block they see.
func (mp *Mempool) AddTransaction(tx *Transaction) error {
mp.mu.Lock()
defer mp.mu.Unlock()
tx.ID = tx.ComputeID()
// Check if transaction already exists
if _, exists := mp.txs[tx.ID]; exists {
return nil // Already in mempool
@@ -74,28 +79,20 @@ func (mp *Mempool) AddTransaction(tx *Transaction) error {
}
}
// Fixed size estimate for fee calculation
txSize := uint64(256)
feePerByte := tx.Fee / txSize
// A full pool gives up whatever pays least, so what it holds is always the
// best it has been offered. An arrival that is itself the cheapest is
// refused: taking it would mean dropping a better transaction for a worse
// one.
// one. Ordering is by fee — it used to divide every fee by the same fixed
// 256, which orders identically and reads as a rate it is not.
if len(mp.txs) >= mp.maxSize {
cheapest := mp.cheapest()
if cheapest == nil || cheapest.feePerByte >= feePerByte {
if cheapest == nil || cheapest.fee >= tx.Fee {
return errors.New("mempool is full and the transaction pays less than what it would displace")
}
mp.removeTxNoLock(cheapest.tx.ID)
}
// Create mempool entry
mempoolTx := &MempoolTx{
tx: tx,
addedAt: time.Now(),
feePerByte: feePerByte,
}
mempoolTx := &MempoolTx{tx: tx, fee: tx.Fee}
// Add to storage
mp.txs[tx.ID] = mempoolTx
@@ -132,14 +129,14 @@ func (mp *Mempool) RemoveTransaction(txID ids.ID) {
mp.removeTxNoLock(txID)
}
// cheapest returns the transaction paying least per byte, which is the one a
// full pool gives up. The heap is ordered highest-first, so the cheapest sits
// among its leaves rather than at its root; the pool is bounded and this runs
// only when it is full, so walking it is the whole of the work.
// cheapest returns the transaction paying least, which is the one a full pool
// gives up. The heap is ordered highest-first, so the cheapest sits among its
// leaves rather than at its root; the pool is bounded and this runs only when
// it is full, so walking it is the whole of the work.
func (mp *Mempool) cheapest() *MempoolTx {
var out *MempoolTx
for _, candidate := range mp.txHeap {
if out == nil || candidate.feePerByte < out.feePerByte {
if out == nil || candidate.fee < out.fee {
out = candidate
}
}
@@ -199,15 +196,6 @@ func (mp *Mempool) HasTransaction(txID ids.ID) bool {
return exists
}
// HasNullifier checks if a nullifier is already in the mempool
func (mp *Mempool) HasNullifier(nullifier []byte) bool {
mp.mu.RLock()
defer mp.mu.RUnlock()
_, exists := mp.nullifiers[string(nullifier)]
return exists
}
// Size returns the number of transactions in the mempool
func (mp *Mempool) Size() int {
mp.mu.RLock()
@@ -216,19 +204,9 @@ func (mp *Mempool) Size() int {
return len(mp.txs)
}
// Clear removes all transactions from the mempool
func (mp *Mempool) Clear() {
mp.mu.Lock()
defer mp.mu.Unlock()
mp.txs = make(map[ids.ID]*MempoolTx)
mp.txHeap = make(TxHeap, 0)
mp.nullifiers = make(map[string]ids.ID)
mp.log.Info("Mempool cleared")
}
// PruneExpired removes expired transactions
// PruneExpired drops transactions the chain has passed. Nothing else does:
// a transaction that can never enter a block occupies a slot forever, and a
// pool full of those refuses every honest arrival that pays the same floor.
func (mp *Mempool) PruneExpired(currentHeight uint64) {
mp.mu.Lock()
defer mp.mu.Unlock()
@@ -259,8 +237,8 @@ type TxHeap []*MempoolTx
func (h TxHeap) Len() int { return len(h) }
func (h TxHeap) Less(i, j int) bool {
// Higher fee per byte = higher priority
return h[i].feePerByte > h[j].feePerByte
// Higher fee = higher priority
return h[i].fee > h[j].fee
}
func (h TxHeap) Swap(i, j int) {
+1
View File
@@ -22,6 +22,7 @@ func shieldedTransfer(fee uint64, n byte) *Transaction {
Nullifiers: [][]byte{{n}},
Outputs: []*ShieldedOutput{{Commitment: []byte{n}}},
Proof: &ZKProof{ProofType: "groth16", ProofData: []byte("p")},
Expiry: 1 << 20,
}
tx.ID = tx.ComputeID()
return tx
+62 -156
View File
@@ -13,24 +13,21 @@ import (
"github.com/luxfi/database"
)
const (
// Database prefixes
nullifierPrefix = 0x20
nullifierHeightKey = "nullifier_height_"
)
// nullifierPrefix keys the spent set. A nullifier record is the height of the
// block that spent it.
const nullifierPrefix = 0x20
// NullifierDB manages spent nullifiers
var errNullifierSpent = errors.New("zkvm: nullifier already spent")
// NullifierDB is the spent set: the whole of what stops a shielded note being
// spent twice.
type NullifierDB struct {
db database.Database
log log.Logger
// Caches. Every nullifier record is loaded at startup and nullifiers are
// never pruned, so this is the whole set, not a sample of it — which is why
// the count is read off it rather than kept beside it.
nullifierCache map[string]uint64 // nullifier -> height when spent
// Indexes
heightIndex map[uint64][]string // height -> nullifiers
// spent is the entire set, loaded at startup and never pruned — which is
// why the count is read off it rather than kept beside it and reconciled.
spent map[string]uint64 // nullifier -> height when spent
mu sync.RWMutex
}
@@ -38,118 +35,77 @@ type NullifierDB struct {
// NewNullifierDB creates a new nullifier database
func NewNullifierDB(db database.Database, log log.Logger) (*NullifierDB, error) {
ndb := &NullifierDB{
db: db,
log: log,
nullifierCache: make(map[string]uint64),
heightIndex: make(map[uint64][]string),
db: db,
log: log,
spent: make(map[string]uint64),
}
if err := ndb.loadNullifiers(); err != nil {
if err := ndb.load(); err != nil {
return nil, err
}
return ndb, nil
}
// MarkNullifierSpent marks a nullifier as spent
// MarkNullifierSpent records a spend.
func (ndb *NullifierDB) MarkNullifierSpent(nullifier []byte, height uint64) error {
ndb.mu.Lock()
defer ndb.mu.Unlock()
nullifierStr := string(nullifier)
// Check if already spent
if _, exists := ndb.nullifierCache[nullifierStr]; exists {
return errors.New("nullifier already spent")
key := string(nullifier)
if _, exists := ndb.spent[key]; exists {
return errNullifierSpent
}
// Store in database
key := makeNullifierKey(nullifier)
heightBytes := make([]byte, 8)
binary.BigEndian.PutUint64(heightBytes, height)
if err := ndb.db.Put(key, heightBytes); err != nil {
if err := ndb.db.Put(makeNullifierKey(nullifier), heightBytes); err != nil {
return err
}
// Update cache
ndb.nullifierCache[nullifierStr] = height
// Update height index
ndb.heightIndex[height] = append(ndb.heightIndex[height], nullifierStr)
ndb.spent[key] = height
ndb.log.Debug("Marked nullifier as spent",
log.Uint64("height", height),
log.Int("nullifiers", len(ndb.nullifierCache)),
log.Int("nullifiers", len(ndb.spent)),
)
return nil
}
// IsNullifierSpent checks if a nullifier has been spent
func (ndb *NullifierDB) IsNullifierSpent(nullifier []byte) bool {
ndb.mu.RLock()
defer ndb.mu.RUnlock()
nullifierStr := string(nullifier)
// Check cache
if _, exists := ndb.nullifierCache[nullifierStr]; exists {
return true
}
// Check database
key := makeNullifierKey(nullifier)
_, err := ndb.db.Get(key)
return err == nil
}
// GetNullifierHeight returns the height when a nullifier was spent.
// Spent reports whether a nullifier has been spent and at what height.
//
// It is ONE question with one answer, because the two it used to be —
// IsNullifierSpent returning a bool and GetNullifierHeight returning a height —
// could not report a failed read at all. `_, err := Get(key); return err == nil`
// answers "not spent" for a set that could not be read, and that answer is what
// lets an already-spent note be spent again. A read that failed is an error
// here, and verifyTransaction refuses the transaction rather than admitting it.
//
// A miss falls through to the records and returns what it finds without
// memoising it. Memoising here would be a write on a path that holds only the
// read lock, and a read lock promises every other reader that nothing is
// changing: two callers missing at once would write the same map at the same
// time, which is a runtime throw, not a returned error. The write lock is not
// the answer either — it would serialise every reader of a path consensus and
// RPC both sit on, to save a lookup the set already answers.
func (ndb *NullifierDB) GetNullifierHeight(nullifier []byte) (uint64, error) {
// memoising it. Memoising would be a write on a path that holds only the read
// lock, and a read lock promises every other reader that nothing is changing:
// two callers missing at once would write the same map at the same time, which
// is a runtime throw, not a returned error. The write lock is not the answer
// either — it would serialise every reader of a path consensus and RPC both sit
// on, to save a lookup the set already answers.
func (ndb *NullifierDB) Spent(nullifier []byte) (uint64, bool, error) {
ndb.mu.RLock()
defer ndb.mu.RUnlock()
nullifierStr := string(nullifier)
// Check cache
if height, exists := ndb.nullifierCache[nullifierStr]; exists {
return height, nil
if height, exists := ndb.spent[string(nullifier)]; exists {
return height, true, nil
}
// Load from database
key := makeNullifierKey(nullifier)
heightBytes, err := ndb.db.Get(key)
if err != nil || len(heightBytes) != 8 {
return 0, errors.New("nullifier not found")
raw, err := ndb.db.Get(makeNullifierKey(nullifier))
switch {
case errors.Is(err, database.ErrNotFound):
return 0, false, nil
case err != nil:
return 0, false, err
case len(raw) != 8:
return 0, false, errors.New("zkvm: nullifier record is not a height")
}
return binary.BigEndian.Uint64(heightBytes), nil
}
// GetNullifiersByHeight returns all nullifiers spent at a specific height
func (ndb *NullifierDB) GetNullifiersByHeight(height uint64) [][]byte {
ndb.mu.RLock()
defer ndb.mu.RUnlock()
nullifierStrs, exists := ndb.heightIndex[height]
if !exists {
return nil
}
nullifiers := make([][]byte, len(nullifierStrs))
for i, nullifierStr := range nullifierStrs {
nullifiers[i] = []byte(nullifierStr)
}
return nullifiers
return binary.BigEndian.Uint64(raw), true, nil
}
// GetNullifierCount returns the number of spent nullifiers, counted off the set
@@ -160,89 +116,40 @@ func (ndb *NullifierDB) GetNullifiersByHeight(height uint64) [][]byte {
func (ndb *NullifierDB) GetNullifierCount() uint64 {
ndb.mu.RLock()
defer ndb.mu.RUnlock()
return uint64(len(ndb.nullifierCache))
return uint64(len(ndb.spent))
}
// RemoveNullifier removes a nullifier (used for reorg)
func (ndb *NullifierDB) RemoveNullifier(nullifier []byte) error {
ndb.mu.Lock()
defer ndb.mu.Unlock()
// Nullifiers are permanent and MUST NOT be pruned or removed. Deleting a spent
// nullifier lets a previously spent note be spent again, so there is no
// removal path here — not for reorg, not for compaction. If storage becomes a
// concern, a Merkle accumulator is the compaction.
nullifierStr := string(nullifier)
// Get height from cache
height, exists := ndb.nullifierCache[nullifierStr]
if !exists {
// Try loading from DB
key := makeNullifierKey(nullifier)
heightBytes, err := ndb.db.Get(key)
if err != nil {
return errors.New("nullifier not found")
}
height = binary.BigEndian.Uint64(heightBytes)
}
// Remove from database
key := makeNullifierKey(nullifier)
if err := ndb.db.Delete(key); err != nil {
return err
}
// Remove from cache
delete(ndb.nullifierCache, nullifierStr)
// Update height index
if heightNullifiers, exists := ndb.heightIndex[height]; exists {
for i, n := range heightNullifiers {
if n == nullifierStr {
ndb.heightIndex[height] = append(heightNullifiers[:i], heightNullifiers[i+1:]...)
break
}
}
}
return nil
}
// Nullifiers are permanent and MUST NOT be pruned. Deleting spent nullifiers
// would allow double-spending of previously spent notes. If storage becomes
// a concern, a Merkle accumulator should be used for compaction.
// loadNullifiers loads nullifiers from database to cache
func (ndb *NullifierDB) loadNullifiers() error {
prefix := []byte{nullifierPrefix}
it := ndb.db.NewIteratorWithPrefix(prefix)
// load reads the whole set from the database.
func (ndb *NullifierDB) load() error {
it := ndb.db.NewIteratorWithPrefix([]byte{nullifierPrefix})
defer it.Release()
for it.Next() {
key := it.Key()
val := it.Value()
key, val := it.Key(), it.Value()
if len(key) < 2 || len(val) != 8 {
continue
}
nullifier := string(key[1:]) // strip prefix byte
height := binary.BigEndian.Uint64(val)
ndb.nullifierCache[nullifier] = height
ndb.heightIndex[height] = append(ndb.heightIndex[height], nullifier)
ndb.spent[string(key[1:])] = binary.BigEndian.Uint64(val)
}
return it.Error()
}
// reload rebuilds the cache from what the database now says. A block whose
// writes were discarded has had its nullifiers discarded with them, so a cache
// reload rebuilds the set from what the database now says. A block whose
// writes were discarded has had its nullifiers discarded with them, so a set
// that already recorded them must stop claiming those notes are spent —
// otherwise the block can never be applied again.
func (ndb *NullifierDB) reload() error {
ndb.mu.Lock()
defer ndb.mu.Unlock()
ndb.nullifierCache = make(map[string]uint64)
ndb.heightIndex = make(map[uint64][]string)
return ndb.loadNullifiers()
ndb.spent = make(map[string]uint64)
return ndb.load()
}
// makeNullifierKey creates a database key for a nullifier
@@ -258,6 +165,5 @@ func (ndb *NullifierDB) Close() {
ndb.mu.Lock()
defer ndb.mu.Unlock()
ndb.nullifierCache = nil
ndb.heightIndex = nil
ndb.spent = nil
}
+92 -28
View File
@@ -4,14 +4,34 @@
package zkvm
import (
"errors"
"sync"
"testing"
"github.com/luxfi/database"
"github.com/luxfi/database/memdb"
"github.com/luxfi/log"
"github.com/stretchr/testify/require"
)
// spentOf asks the spent set, and fails the test on a read that FAILED rather
// than reading it as "not spent" — which is the shape the production code had
// and the shape that reopens a double spend.
func spentOf(t *testing.T, ndb *NullifierDB, nullifier []byte) bool {
t.Helper()
_, spent, err := ndb.Spent(nullifier)
require.NoError(t, err)
return spent
}
// brokenDB fails every read with something that is NOT ErrNotFound.
type brokenDB struct {
database.Database
err error
}
func (d *brokenDB) Get([]byte) ([]byte, error) { return nil, d.err }
// TestSpentCountIsReadOffTheRecords pins that the count describes the records
// and cannot drift from them. It starts from the state a total kept alongside
// would disagree with: a record on disk with no counter beside it.
@@ -23,41 +43,56 @@ func TestSpentCountIsReadOffTheRecords(t *testing.T) {
ndb, err := NewNullifierDB(db, log.NoLog{})
require.NoError(t, err)
require.True(t, ndb.IsNullifierSpent([]byte("orphan")), "the set is rebuilt from the records")
require.True(t, spentOf(t, ndb, []byte("orphan")), "the set is rebuilt from the records")
require.Equal(t, uint64(1), ndb.GetNullifierCount(),
"the count is read off the records, so it cannot disagree with the set it describes")
require.NoError(t, ndb.RemoveNullifier([]byte("orphan")))
require.False(t, ndb.IsNullifierSpent([]byte("orphan")))
require.Zero(t, ndb.GetNullifierCount(),
"removing the last record must leave zero, not wrap to 2^64-1")
}
// The count has to follow ordinary spending too, not just the recovery path.
// The count follows ordinary spending, and a nullifier is spent once.
func TestSpentCountFollowsTheSet(t *testing.T) {
ndb, err := NewNullifierDB(memdb.New(), log.NoLog{})
require.NoError(t, err)
require.Zero(t, ndb.GetNullifierCount())
require.NoError(t, ndb.MarkNullifierSpent([]byte("a"), 1))
require.NoError(t, ndb.MarkNullifierSpent([]byte("b"), 1))
require.NoError(t, ndb.MarkNullifierSpent([]byte("b"), 2))
require.Equal(t, uint64(2), ndb.GetNullifierCount())
require.NoError(t, ndb.RemoveNullifier([]byte("a")))
require.Equal(t, uint64(1), ndb.GetNullifierCount())
// The set is permanent: spending the same note again is refused, and there
// is no removal path that could make room for it.
require.ErrorIs(t, ndb.MarkNullifierSpent([]byte("a"), 3), errNullifierSpent)
require.Equal(t, uint64(2), ndb.GetNullifierCount())
// A nullifier that was never spent is not a removal, and must not be
// counted as one.
require.Error(t, ndb.RemoveNullifier([]byte("never")))
require.Equal(t, uint64(1), ndb.GetNullifierCount())
height, spent, err := ndb.Spent([]byte("b"))
require.NoError(t, err)
require.True(t, spent)
require.Equal(t, uint64(2), height, "the set records which block spent the note")
_, spent, err = ndb.Spent([]byte("never"))
require.NoError(t, err)
require.False(t, spent)
}
// TestNullifierReadDoesNotWriteTheSet. GetNullifierHeight must not memoise what
// it loaded while holding only the read lock, and a read lock promises every
// other reader that nothing is changing — so two callers missing at once wrote
// the same map at the same time, which is a runtime throw that takes the
// process down rather than an error anyone can handle. Consensus and RPC both
// sit on this path.
// A read that FAILED is not "not spent". The old IsNullifierSpent ended
// `_, err := Get(key); return err == nil`, so an unreadable set answered "this
// note is unspent" for every note in it — and verifyTransaction, whose only
// double-spend gate that is, admitted the spend.
func TestFailedReadIsNotAnUnspentNote(t *testing.T) {
boom := errors.New("disk gone")
ndb, err := NewNullifierDB(memdb.New(), log.NoLog{})
require.NoError(t, err)
ndb.db = &brokenDB{Database: ndb.db, err: boom}
_, spent, err := ndb.Spent([]byte("n"))
require.ErrorIs(t, err, boom)
require.False(t, spent)
}
// TestNullifierReadDoesNotWriteTheSet. Spent must not memoise what it loaded
// while holding only the read lock: a read lock promises every other reader
// that nothing is changing, so two callers missing at once would write the same
// map at the same time, which is a runtime throw that takes the process down
// rather than an error anyone can handle. Consensus and RPC both sit here.
func TestNullifierReadDoesNotWriteTheSet(t *testing.T) {
ndb, err := NewNullifierDB(memdb.New(), log.NoLog{})
require.NoError(t, err)
@@ -65,17 +100,19 @@ func TestNullifierReadDoesNotWriteTheSet(t *testing.T) {
// A record on disk that the in-memory set does not know about is what a
// memoising read would fill in.
ndb.nullifierCache = map[string]uint64{}
ndb.spent = map[string]uint64{}
var readers sync.WaitGroup
for i := 0; i < 8; i++ {
readers.Add(1)
go func() {
defer readers.Done()
height, err := ndb.GetNullifierHeight([]byte("n"))
height, spent, err := ndb.Spent([]byte("n"))
switch {
case err != nil:
t.Errorf("read failed: %v", err)
case !spent:
t.Error("record on disk read as unspent")
case height != 4:
t.Errorf("read height %d, want 4", height)
}
@@ -83,14 +120,14 @@ func TestNullifierReadDoesNotWriteTheSet(t *testing.T) {
}
readers.Wait()
require.Empty(t, ndb.nullifierCache, "a read must not change the set it reads")
require.Empty(t, ndb.spent, "a read must not change the set it reads")
}
// TestShortRecordIsNotAHeight. Blocks are stored in the same database under
// their raw id, so the nullifier prefix can turn up over a value that is not a
// height. loadNullifiers already passes over anything that is not eight bytes;
// the read path has to agree with it, or reading one of those is a panic where
// a miss was meant.
// height. load already passes over anything that is not eight bytes; the read
// path has to agree with it, or reading one of those is a panic where a miss
// was meant.
func TestShortRecordIsNotAHeight(t *testing.T) {
db := memdb.New()
require.NoError(t, db.Put(makeNullifierKey([]byte("n")), []byte{1}))
@@ -99,6 +136,33 @@ func TestShortRecordIsNotAHeight(t *testing.T) {
require.NoError(t, err)
require.Zero(t, ndb.GetNullifierCount())
_, err = ndb.GetNullifierHeight([]byte("n"))
require.ErrorContains(t, err, "not found")
_, _, err = ndb.Spent([]byte("n"))
require.ErrorContains(t, err, "not a height")
}
// A set that cannot be read at startup is not an empty set.
func TestUnreadableSetDoesNotOpen(t *testing.T) {
boom := errors.New("iterator gone")
_, err := NewNullifierDB(&failIterDB{Database: memdb.New(), err: boom}, log.NoLog{})
require.ErrorIs(t, err, boom)
}
type failIterDB struct {
database.Database
err error
}
func (d *failIterDB) NewIteratorWithPrefix([]byte) database.Iterator {
return &brokenIter{err: d.err}
}
type brokenIter struct {
database.Iterator
err error
}
func (i *brokenIter) Next() bool { return false }
func (i *brokenIter) Error() error { return i.err }
func (i *brokenIter) Release() {}
func (i *brokenIter) Key() []byte { return nil }
func (i *brokenIter) Value() []byte { return nil }
+135
View File
@@ -0,0 +1,135 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zkvm
import (
"errors"
"fmt"
"sync"
"crypto/sha256"
"github.com/luxfi/log"
"github.com/luxfi/database"
)
// rootKey holds the committed state root.
var rootKey = []byte("state_root")
// Root is the committed state root and the fold that produces the next one.
//
// It was a "sparse Merkle tree": 256 levels, a node cache, GetMerkleProof and
// VerifyMerkleProof. None of it was reachable — the root is the SHA-256 fold
// below and always was — and the unreachable half held a map written under a
// READ lock, which in Go is a fatal throw rather than a bug you get to debug.
// A type named for a structure it does not have costs exactly that.
type Root struct {
db database.Database
log log.Logger
committed []byte
mu sync.RWMutex
}
// NewRoot opens the committed state root.
func NewRoot(db database.Database, log log.Logger) (*Root, error) {
r := &Root{db: db, log: log, committed: make([]byte, 32)}
if err := r.reload(); err != nil {
return nil, err
}
return r, nil
}
// After returns the state root that results from applying txs on top of the
// committed root, as SHA-256 over
//
// committed ‖ every output commitment (tx order) ‖ every nullifier (tx order)
//
// It is PURE: nothing is mutated, so computing a root is safe inside
// Block.Verify. Verifying the same block twice, or verifying a block that is
// later rejected and then verifying its competitor, all yield the root that
// block's proposer computed. Only Finalize advances the committed root, and
// only Accept calls Finalize.
//
// There is exactly ONE root function. A hardware-conditional digest (a GPU
// Poseidon path with a SHA-256 fallback) would make the consensus-committed
// root depend on whether the node has an accelerator, so validators with and
// without one would reject each other's blocks.
func (r *Root) After(txs []*Transaction) []byte {
r.mu.RLock()
defer r.mu.RUnlock()
h := sha256.New()
h.Write(r.committed)
for _, tx := range txs {
for _, output := range tx.Outputs {
h.Write(output.Commitment)
}
}
for _, tx := range txs {
for _, nullifier := range tx.Nullifiers {
h.Write(nullifier)
}
}
return h.Sum(nil)
}
// Finalize advances the committed root. It is the only mutation, and Accept is
// its only caller.
func (r *Root) Finalize(next []byte) error {
r.mu.Lock()
defer r.mu.Unlock()
// The record first: a root held in memory that is not in the database is a
// root this node alone believes.
if err := r.db.Put(rootKey, next); err != nil {
return err
}
r.committed = next
r.log.Debug("State root finalized", log.String("root", fmt.Sprintf("%x", next[:8])))
return nil
}
// Get returns the committed state root.
func (r *Root) Get() []byte {
r.mu.RLock()
defer r.mu.RUnlock()
return r.committed
}
// reload puts the committed root back, discarding an advance that belonged to
// a block whose writes were discarded.
//
// A read that FAILED is not an absent root. Both the constructor and this
// rollback path used to answer any error with the empty root and report
// success — so an unreadable database booted the node believing the shielded
// state was empty, and it then disagreed with the network on every block it
// saw, permanently. Only database.ErrNotFound means a chain with no root yet.
func (r *Root) reload() error {
r.mu.Lock()
defer r.mu.Unlock()
raw, err := r.db.Get(rootKey)
switch {
case errors.Is(err, database.ErrNotFound):
r.committed = make([]byte, 32)
return nil
case err != nil:
return fmt.Errorf("zkvm: read state root: %w", err)
case len(raw) != 32:
return fmt.Errorf("zkvm: state root is %d bytes, want 32", len(raw))
}
r.committed = raw
return nil
}
// Close releases the root.
func (r *Root) Close() {
r.mu.Lock()
defer r.mu.Unlock()
r.committed = nil
}
+8 -125
View File
@@ -11,7 +11,6 @@ import (
"testing"
"github.com/luxfi/database/memdb"
"github.com/luxfi/ids"
"github.com/luxfi/log"
)
@@ -87,7 +86,7 @@ func TestRegressionH01_Groth16SubgroupCheck(t *testing.T) {
Proof: &ZKProof{
ProofType: "groth16",
ProofData: make([]byte, 256), // zero bytes = invalid curve points
PublicInputs: [][]byte{nullifier, commitment},
PublicInputs: [][]byte{testBind[:], nullifier, commitment},
},
}
tx.ID = tx.ComputeID()
@@ -187,34 +186,6 @@ func TestRegressionH03_PublicInputsValueEqual(t *testing.T) {
}
}
// TestRegressionH04_HKDFChainBound verifies that deriveEncryptionKey binds the
// chain ID into the HKDF salt, so the same secret on different chains produces
// different encryption keys.
// Finding H-04: HKDF salt was static ("zkvm-v1") without chain binding.
func TestRegressionH04_HKDFChainBound(t *testing.T) {
secret := make([]byte, 32)
for i := range secret {
secret[i] = byte(i + 1)
}
chainA := ids.ID{0x0A}
chainB := ids.ID{0x0B}
txID := ids.ID{0x0C}
keyA := deriveEncryptionKey(secret, chainA, txID)
keyB := deriveEncryptionKey(secret, chainB, txID)
if bytes.Equal(keyA, keyB) {
t.Fatal("keys must differ for different chain IDs -- H-04 regression")
}
// Also verify txID binding
txID2 := ids.ID{0x0D}
keyC := deriveEncryptionKey(secret, chainA, txID2)
if bytes.Equal(keyA, keyC) {
t.Fatal("keys must differ for different tx IDs -- H-04 regression")
}
}
// TestRegressionH05_NoMutableVerifiedField verifies that the ZKProof struct
// does not contain a mutable 'verified' field.
// Finding H-05: A 'verified' bool on ZKProof allowed bypass of proof
@@ -239,25 +210,9 @@ func TestRegressionH05_NoMutableVerifiedField(t *testing.T) {
// nullifiers after a time window.
func TestRegressionM03_NullifierPruningRemoved(t *testing.T) {
typ := reflect.TypeOf(&NullifierDB{})
_, found := typ.MethodByName("PruneOldNullifiers")
if found {
t.Fatal("PruneOldNullifiers must be removed -- M-03 regression (enables double-spend)")
}
}
// TestRegressionM04_MerklePositionOrdering verifies that the Merkle proof uses
// position-based (bit index) ordering, not hash-comparison ordering.
// Finding M-04: Hash-based left/right ordering in Merkle proof was incorrect
// for sparse Merkle trees where position determines path direction.
func TestRegressionM04_MerklePositionOrdering(t *testing.T) {
// getBit must extract individual bits at specific positions.
// A hash-comparison approach would lose position information.
data := []byte{0b10110100, 0b01101001}
expected := []byte{1, 0, 1, 1, 0, 1, 0, 0, 0, 1, 1, 0, 1, 0, 0, 1}
for i, want := range expected {
got := getBit(data, i)
if got != want {
t.Fatalf("getBit(data, %d) = %d, want %d -- M-04 regression (position ordering broken)", i, got, want)
for _, name := range []string{"PruneOldNullifiers", "RemoveNullifier", "Prune", "Delete", "Clear"} {
if _, found := typ.MethodByName(name); found {
t.Fatalf("%s must not exist -- M-03 regression (enables double-spend)", name)
}
}
}
@@ -321,77 +276,6 @@ func TestRegressionM06_FiatShamirFullLength(t *testing.T) {
// INFO Regressions
// =============================================================================
// TestRegressionI01_DerivePublicKeyInvalidLength verifies that derivePublicKey
// returns an error for non-32-byte inputs.
// Finding I-01: Missing length check caused panic on invalid key lengths.
func TestRegressionI01_DerivePublicKeyInvalidLength(t *testing.T) {
for _, tc := range []struct {
name string
key []byte
}{
{"nil", nil},
{"empty", []byte{}},
{"too_short_16", make([]byte, 16)},
{"too_long_64", make([]byte, 64)},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := derivePublicKey(tc.key)
if err == nil {
t.Fatalf("derivePublicKey(%d bytes) must return error -- I-01 regression", len(tc.key))
}
})
}
validKey := make([]byte, 32)
validKey[0] = 1
pub, err := derivePublicKey(validKey)
if err != nil {
t.Fatalf("derivePublicKey(32 bytes) must succeed: %v", err)
}
if len(pub) != 32 {
t.Fatalf("public key must be 32 bytes, got %d", len(pub))
}
}
// TestRegressionI02_UsesCurve25519X25519 verifies that key derivation uses
// the modern curve25519.X25519 API, not deprecated ScalarMult/ScalarBaseMult.
// Finding I-02: Deprecated curve25519 functions have subtle edge cases.
func TestRegressionI02_UsesCurve25519X25519(t *testing.T) {
privKey := make([]byte, 32)
privKey[0] = 42
pubKey, err := derivePublicKey(privKey)
if err != nil {
t.Fatalf("X25519 base mult failed: %v", err)
}
privKey2 := make([]byte, 32)
privKey2[0] = 99
shared, err := deriveSharedSecret(privKey2, pubKey)
if err != nil {
t.Fatalf("X25519 key exchange failed: %v", err)
}
if len(shared) != 32 {
t.Fatalf("shared secret must be 32 bytes, got %d", len(shared))
}
}
// TestRegressionI03_DefaultPowerIs20 is a cross-reference to the ceremony
// package test. The actual flag default verification lives there because
// the ceremony is package main (cannot be imported).
// Finding I-03: Default power of 10 was too small for production circuits.
func TestRegressionI03_DefaultPowerIs20(t *testing.T) {
// See cmd/ceremony/security_regression_test.go TestRegressionI03
// for the actual verification. Here we verify the ceremony
// constraint count math: 2^20 + 1 = 1048577 powers.
power := 20
numConstraints := 1 << power
powersNeeded := numConstraints + 1
if powersNeeded != 1048577 {
t.Fatalf("2^20 + 1 must equal 1048577, got %d -- I-03 regression", powersNeeded)
}
}
// =============================================================================
// LOW Regressions
// =============================================================================
@@ -418,13 +302,13 @@ func TestRegressionL02_LoadNullifiersPopulatesCache(t *testing.T) {
}
// Cache must contain the nullifier loaded from disk
if !ndb.IsNullifierSpent(nullifier) {
if !spentOf(t, ndb, nullifier) {
t.Fatal("loadNullifiers must populate cache from database -- L-02 regression")
}
height, err := ndb.GetNullifierHeight(nullifier)
height, _, err := ndb.Spent(nullifier)
if err != nil {
t.Fatalf("GetNullifierHeight: %v", err)
t.Fatalf("Spent: %v", err)
}
if height != 42 {
t.Fatalf("expected height 42, got %d -- L-02 regression", height)
@@ -438,10 +322,9 @@ func TestRegressionL02_LoadNullifiersPopulatesCache(t *testing.T) {
func newTestProofVerifier(t *testing.T) *ProofVerifier {
t.Helper()
config := ZConfig{
ProofSystem: "groth16",
ProofCacheSize: 100,
}
pv, err := NewProofVerifier(config, log.NoLog{})
pv, err := NewProofVerifier(config, [32]byte{}, log.NoLog{})
if err != nil {
t.Fatalf("NewProofVerifier: %v", err)
}
-270
View File
@@ -1,270 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zkvm
import (
"bytes"
"crypto/sha256"
"fmt"
"sync"
"github.com/luxfi/log"
"github.com/luxfi/database"
)
// StateTree manages a sparse Merkle tree of the UTXO set
type StateTree struct {
db database.Database
log log.Logger
// Current state
currentRoot []byte
treeHeight int
// Merkle tree cache (path -> hash)
nodeCache map[string][]byte
mu sync.RWMutex
}
const (
// Default empty tree leaf hash
emptyLeafHash = "0000000000000000000000000000000000000000000000000000000000000000"
)
// NewStateTree creates a new sparse Merkle tree
func NewStateTree(db database.Database, log log.Logger) (*StateTree, error) {
st := &StateTree{
db: db,
log: log,
treeHeight: 256, // 256 levels for 256-bit hashes
nodeCache: make(map[string][]byte),
currentRoot: make([]byte, 32),
}
// Initialize with empty tree root (all zeros for sparse Merkle tree)
st.currentRoot = make([]byte, 32)
// Try to load existing root from database
if rootBytes, err := db.Get([]byte("state_root")); err == nil {
st.currentRoot = rootBytes
}
return st, nil
}
// RootAfter returns the state root that results from applying txs on top of the
// current root, as SHA-256 over
//
// currentRoot ‖ every output commitment (tx order) ‖ every nullifier (tx order)
//
// It is PURE: the tree is not mutated, so computing a root is safe to do inside
// Block.Verify. Verifying the same block twice, or verifying a block that is
// later rejected and then verifying its competitor, all yield the root that
// block's proposer computed. Only Finalize advances currentRoot, and only Accept
// calls Finalize.
//
// There is exactly ONE root function. A hardware-conditional digest (a GPU
// Poseidon path with a SHA-256 fallback) would make the consensus-committed root
// depend on whether the node has an accelerator, so validators with and without
// one would reject each other's blocks.
func (st *StateTree) RootAfter(txs []*Transaction) []byte {
st.mu.RLock()
defer st.mu.RUnlock()
h := sha256.New()
h.Write(st.currentRoot)
for _, tx := range txs {
for _, output := range tx.Outputs {
h.Write(output.Commitment)
}
}
for _, tx := range txs {
for _, nullifier := range tx.Nullifiers {
h.Write(nullifier)
}
}
return h.Sum(nil)
}
// Finalize advances the committed root. It is the only mutation of tree state,
// and Accept is its only caller.
func (st *StateTree) Finalize(newRoot []byte) error {
st.mu.Lock()
defer st.mu.Unlock()
// The record first: a root held in memory that is not in the database is a
// root this node alone believes.
if err := st.db.Put([]byte("state_root"), newRoot); err != nil {
return err
}
st.currentRoot = newRoot
st.log.Debug("State tree finalized",
log.String("root", fmt.Sprintf("%x", newRoot[:8])),
)
return nil
}
// reload puts the committed root back, discarding an advance that belonged to
// a block whose writes were discarded.
func (st *StateTree) reload() error {
st.mu.Lock()
defer st.mu.Unlock()
root, err := st.db.Get([]byte("state_root"))
if err != nil {
st.currentRoot = make([]byte, 32)
return nil
}
st.currentRoot = root
return nil
}
// GetRoot returns the current state root
func (st *StateTree) GetRoot() []byte {
st.mu.RLock()
defer st.mu.RUnlock()
return st.currentRoot
}
// GetMerkleProof generates a Merkle proof for a commitment in the sparse Merkle tree
func (st *StateTree) GetMerkleProof(commitment []byte) ([][]byte, error) {
st.mu.RLock()
defer st.mu.RUnlock()
// Hash the commitment to get the leaf index
leafHash := sha256.Sum256(commitment)
leafIndex := leafHash[:]
// Generate proof path (sibling hashes from leaf to root)
proof := make([][]byte, st.treeHeight)
currentHash := leafHash[:]
for level := 0; level < st.treeHeight; level++ {
// Determine if we're on left or right branch
bit := getBit(leafIndex, level)
// Get sibling hash from database or use empty hash
siblingPath := getSiblingPath(leafIndex, level)
siblingHash, err := st.getNodeHash(siblingPath)
if err != nil {
// Sibling doesn't exist, use empty hash
siblingHash = make([]byte, 32)
}
proof[level] = siblingHash
// Compute parent hash
if bit == 0 {
currentHash = hashPair(currentHash, siblingHash)
} else {
currentHash = hashPair(siblingHash, currentHash)
}
}
return proof, nil
}
// VerifyMerkleProof verifies a sparse Merkle proof
func (st *StateTree) VerifyMerkleProof(commitment []byte, proof [][]byte, root []byte) bool {
if len(proof) != st.treeHeight {
return false
}
// Hash the commitment to get the leaf
leafHash := sha256.Sum256(commitment)
leafIndex := leafHash[:]
// Recompute root from leaf using proof
currentHash := leafHash[:]
for level := 0; level < st.treeHeight; level++ {
bit := getBit(leafIndex, level)
siblingHash := proof[level]
if siblingHash == nil || len(siblingHash) != 32 {
return false
}
// Hash current with sibling based on bit position
if bit == 0 {
currentHash = hashPair(currentHash, siblingHash)
} else {
currentHash = hashPair(siblingHash, currentHash)
}
}
// Verify computed root matches expected root
return bytes.Equal(currentHash, root)
}
// Close closes the state tree
func (st *StateTree) Close() {
st.mu.Lock()
defer st.mu.Unlock()
st.nodeCache = nil
}
// Helper functions for sparse Merkle tree
// getBit returns the bit at position 'pos' in the byte array (0 or 1)
func getBit(data []byte, pos int) byte {
byteIndex := pos / 8
bitIndex := pos % 8
if byteIndex >= len(data) {
return 0
}
// Read bit from MSB to LSB within each byte
return (data[byteIndex] >> (7 - bitIndex)) & 1
}
// getSiblingPath returns the path to the sibling node at a given level
func getSiblingPath(leafIndex []byte, level int) []byte {
// Create a copy and flip the bit at the level position
path := make([]byte, len(leafIndex))
copy(path, leafIndex)
byteIndex := level / 8
bitIndex := level % 8
if byteIndex < len(path) {
// Flip the bit
path[byteIndex] ^= (1 << (7 - bitIndex))
}
return path
}
// hashPair hashes two nodes together using SHA-256
func hashPair(left, right []byte) []byte {
h := sha256.New()
h.Write(left)
h.Write(right)
return h.Sum(nil)
}
// getNodeHash retrieves a node hash from the database or cache
func (st *StateTree) getNodeHash(path []byte) ([]byte, error) {
// Check cache first
pathKey := string(path)
if hash, ok := st.nodeCache[pathKey]; ok {
return hash, nil
}
// Try database
dbKey := append([]byte("smt_node_"), path...)
hash, err := st.db.Get(dbKey)
if err != nil {
return nil, err
}
// Cache for future use
st.nodeCache[pathKey] = hash
return hash, nil
}
+10 -32
View File
@@ -28,10 +28,9 @@ import (
func strictPQVerifier(t *testing.T) *ProofVerifier {
t.Helper()
pv, err := NewProofVerifier(ZConfig{
ProofSystem: "stark",
ProofCacheSize: 100,
StrictPQ: true,
}, log.NoLog{})
}, [32]byte{}, log.NoLog{})
if err != nil {
t.Fatalf("NewProofVerifier(strictPQ): %v", err)
}
@@ -51,7 +50,7 @@ func shieldedTx(proofType string, proofData []byte) *Transaction {
Proof: &ZKProof{
ProofType: proofType,
ProofData: proofData,
PublicInputs: [][]byte{nullifier, commitment},
PublicInputs: [][]byte{testBind[:], nullifier, commitment},
},
}
tx.ID = tx.ComputeID()
@@ -92,13 +91,12 @@ func TestStrictPQ_RealBN254VKLoadErrors(t *testing.T) {
realVK[0] = 0x01 // non-zero ⇒ a real (non-dummy) bn254 verifying key
_, err := NewProofVerifier(ZConfig{
ProofSystem: "stark",
ProofCacheSize: 100,
StrictPQ: true,
VerifyingKeys: map[string][]byte{
string(TransactionTypeTransfer): realVK,
},
}, log.NoLog{})
}, [32]byte{}, log.NoLog{})
if !errors.Is(err, errStrictPQRealVKForbidden) {
t.Fatalf("strict-PQ chain must error on real bn254 VK loaded by the production loadVerifyingKeys, got: %v", err)
}
@@ -122,13 +120,12 @@ func TestNonStrict_RealVKConstructs(t *testing.T) {
realVK := make([]byte, 1024)
realVK[0] = 0x01
pv, err := NewProofVerifier(ZConfig{
ProofSystem: "groth16",
ProofCacheSize: 100,
StrictPQ: false,
VerifyingKeys: map[string][]byte{
string(TransactionTypeTransfer): realVK,
},
}, log.NoLog{})
}, [32]byte{}, log.NoLog{})
if err != nil {
t.Fatalf("non-strict chain must accept a real bn254 VK, got: %v", err)
}
@@ -165,9 +162,11 @@ func TestStrictPQ_STARKIsOnlyAcceptedSystem(t *testing.T) {
if err := pv.VerifyTransactionProof(tx); err != nil {
t.Fatalf("bound STARK accept path returned error: %v", err)
}
// 1 nullifier (32) + 1 commitment (32) = 64 bytes of bound public input.
if len(sawPub) != 64 {
t.Fatalf("STARK public inputs must bind nullifiers+commitments (64 bytes), got %d", len(sawPub))
// chain bind (32) + 1 nullifier (32) + 1 commitment (32) = 96 bytes. The
// chain goes first, so a proof made for another chain does not verify here
// even when the notes it names are unspent on both.
if len(sawPub) != 96 {
t.Fatalf("STARK public inputs must bind chain+nullifiers+commitments (96 bytes), got %d", len(sawPub))
}
// Bound + reject: a non-verifying proof is rejected.
@@ -177,36 +176,15 @@ func TestStrictPQ_STARKIsOnlyAcceptedSystem(t *testing.T) {
}
}
// TestStrictPQ_GPUBatchPathRefusesClassical proves the strict-PQ gate is
// ALSO enforced on the GPU batch verification path (which deserializes
// and verifies Groth16 INLINE, bypassing VerifyTransactionProof). A
// strict-PQ chain must refuse a classical proof regardless of path.
func TestStrictPQ_GPUBatchPathRefusesClassical(t *testing.T) {
pv := strictPQVerifier(t)
// Two groth16 txs to exercise the batch collector.
txs := []*Transaction{
shieldedTx("groth16", make([]byte, 256)),
shieldedTx("groth16", make([]byte, 256)),
}
results := batchVerifyProofsGPU(pv, txs)
for i, err := range results {
if !errors.Is(err, errStrictPQClassicalForbidden) {
t.Fatalf("batch tx %d: strict-PQ must refuse groth16, got: %v", i, err)
}
}
}
// TestNonStrict_ClassicalPathUnchanged confirms a NON-strict chain is
// unaffected: a groth16 proof reaches the classical verification body
// (and is rejected only on its own merits / dummy keys, NOT by a
// strict-PQ refusal).
func TestNonStrict_ClassicalPathUnchanged(t *testing.T) {
pv, err := NewProofVerifier(ZConfig{
ProofSystem: "groth16",
ProofCacheSize: 100,
StrictPQ: false,
}, log.NoLog{})
}, [32]byte{}, log.NoLog{})
if err != nil {
t.Fatalf("NewProofVerifier: %v", err)
}
+11 -9
View File
@@ -4,8 +4,8 @@
package zkvm
import (
"encoding/json"
"context"
"encoding/json"
"testing"
"github.com/luxfi/chains/zkvm/precompiles"
@@ -72,8 +72,13 @@ func TestZChain_DefaultIsStrictPQ(t *testing.T) {
if !v.StrictPQ() {
t.Fatal("Z-Chain default config must be strict-PQ (StrictPQ==true)")
}
if v.config.ProofSystem != "stark" {
t.Fatalf("Z-Chain default ProofSystem = %q, want \"stark\" (only system accepted under strict-PQ)", v.config.ProofSystem)
// Which system a chain accepts is decided per proof by the strict-PQ gate,
// not by a config string nothing reads.
if err := v.proofVerifier.refuseClassicalUnderStrictPQ("groth16"); err == nil {
t.Fatal("Z-Chain default must refuse a classical proof system")
}
if err := v.proofVerifier.refuseClassicalUnderStrictPQ("stark"); err != nil {
t.Fatalf("Z-Chain default must accept STARK: %v", err)
}
}
@@ -128,12 +133,9 @@ func TestZChain_StrictPQ_ShieldedRefusesGroth16(t *testing.T) {
func TestZChain_ExplicitNonStrictGenesisOptsOut(t *testing.T) {
// Build a ZConfig with StrictPQ=false and feed it as init.Config.
cfg := ZConfig{
EnableConfidentialTransfers: true,
ProofSystem: "groth16",
CircuitType: "transfer",
StrictPQ: false,
MaxUTXOsPerBlock: 100,
ProofCacheSize: 1000,
StrictPQ: false,
MaxTxPerBlock: 100,
ProofCacheSize: 1000,
}
cfgBytes, err := json.Marshal(cfg)
if err != nil {
+25 -317
View File
@@ -4,20 +4,23 @@
package zkvm
import (
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"math/big"
"golang.org/x/crypto/chacha20poly1305"
"golang.org/x/crypto/curve25519"
"golang.org/x/crypto/hkdf"
"github.com/luxfi/ids"
)
// Note construction — deriving a nullifier, committing to a note, encrypting
// one to a recipient — is a WALLET's work and lived here, along with the only
// panic in the package. A validator holds no spending keys and builds no
// notes: it checks proofs and the spent set. What it needs of a note is the
// commitment and the nullifier the transaction already carries.
// MaxTxSize bounds a transaction on the way in, so a peer cannot make this
// node hold what it would never build.
const MaxTxSize = 1 << 20
// TransactionType represents the type of transaction
type TransactionType uint8
@@ -46,16 +49,10 @@ type Transaction struct {
// Zero-knowledge proof
Proof *ZKProof `json:"proof"`
// FHE operations (optional)
FHEData *FHEData `json:"fheData,omitempty"`
// Transaction metadata
Fee uint64 `json:"fee"`
Expiry uint64 `json:"expiry"` // Block height
Memo []byte `json:"memo,omitempty"` // Encrypted memo
// Signature for transparent components
Signature []byte `json:"signature,omitempty"`
}
// TransparentInput represents an unshielded input
@@ -95,42 +92,18 @@ type ZKProof struct {
PublicInputs [][]byte `json:"publicInputs"`
}
// FHEData represents fully homomorphic encryption data
type FHEData struct {
// Encrypted computation inputs
EncryptedInputs [][]byte `json:"encryptedInputs"`
// Computation circuit
CircuitID string `json:"circuitId"`
// Encrypted result
EncryptedResult []byte `json:"encryptedResult"`
// Proof of correct computation
ComputationProof []byte `json:"computationProof"`
}
// Note represents a shielded note (internal representation)
type Note struct {
Value *big.Int `json:"value"` // Encrypted amount
Address []byte `json:"address"` // Recipient address
AssetID ids.ID `json:"assetId"` // Asset type
Randomness []byte `json:"randomness"` // Note randomness
Nullifier []byte `json:"nullifier"` // Computed nullifier
}
// ComputeID is the transaction's identity: a hash over everything the
// transaction means.
// transaction means. It is NOT carried on the wire — parseTransaction derives
// it — because an identity a peer supplies is an identity a peer chooses, and
// the proof cache is keyed on it: copy an accepted transaction's id, proof and
// public inputs onto a transaction spending different notes and the cache
// answers nil before anything binds the proof to what it spends.
//
// Every variable-length field is written with its length first and every list
// with its count. Concatenated raw, a byte could move from the end of one field
// to the start of the next without the hash noticing — ["ab","c"] and ["a","bc"]
// are the same bytes — and two transactions sharing an identity is what
// consensus decides between blocks with.
//
// The signature is left out because it is made over this value. Including it
// would be circular, and would let a second valid signature give a different
// identity to a transaction moving the same value.
func (tx *Transaction) ComputeID() ids.ID {
h := sha256.New()
num := func(v uint64) { binary.Write(h, binary.BigEndian, v) }
@@ -184,17 +157,6 @@ func (tx *Transaction) ComputeID() ids.ID {
}
}
present(tx.FHEData != nil)
if tx.FHEData != nil {
count(len(tx.FHEData.EncryptedInputs))
for _, input := range tx.FHEData.EncryptedInputs {
blob(input)
}
blob([]byte(tx.FHEData.CircuitID))
blob(tx.FHEData.EncryptedResult)
blob(tx.FHEData.ComputationProof)
}
num(tx.Fee)
num(tx.Expiry)
blob(tx.Memo)
@@ -202,11 +164,6 @@ func (tx *Transaction) ComputeID() ids.ID {
return ids.ID(h.Sum(nil))
}
// HasFHEOperations returns true if the transaction includes FHE operations
func (tx *Transaction) HasFHEOperations() bool {
return tx.FHEData != nil && len(tx.FHEData.EncryptedInputs) > 0
}
// GetNullifiers returns all nullifiers in the transaction
func (tx *Transaction) GetNullifiers() [][]byte {
return tx.Nullifiers
@@ -242,6 +199,14 @@ func (tx *Transaction) ValidateBasic() error {
return errMissingProof
}
// A transaction names the height it stops being valid at. Without one it
// sits in a bounded pool forever: it can never enter a block, nothing
// evicts it, and once the pool is full of them every honest arrival paying
// the same floor is refused.
if tx.Expiry == 0 {
return errNoExpiry
}
// Type-specific validation
switch tx.Type {
case TransactionTypeTransfer:
@@ -266,270 +231,13 @@ func (tx *Transaction) ValidateBasic() error {
return nil
}
// ComputeNullifier computes a nullifier for a note
func ComputeNullifier(note *Note, spendingKey []byte) []byte {
h := sha256.New()
h.Write(note.Address)
h.Write(note.Value.Bytes())
h.Write(note.AssetID[:])
h.Write(note.Randomness)
h.Write(spendingKey)
return h.Sum(nil)
}
// ComputeCommitment computes a note commitment
func ComputeCommitment(note *Note) []byte {
h := sha256.New()
h.Write(note.Value.Bytes())
h.Write(note.Address)
h.Write(note.AssetID[:])
h.Write(note.Randomness)
return h.Sum(nil)
}
// EncryptNote encrypts a note for the recipient using ChaCha20-Poly1305.
// chainID and txID bind the encryption key to prevent cross-chain/cross-tx reuse.
func EncryptNote(note *Note, recipientPubKey []byte, ephemeralPrivKey []byte, chainID ids.ID, txID ids.ID) ([]byte, []byte, error) {
// Derive shared secret using ECDH
sharedSecret, err := deriveSharedSecret(ephemeralPrivKey, recipientPubKey)
if err != nil {
return nil, nil, fmt.Errorf("failed to derive shared secret: %w", err)
}
// Derive encryption key from shared secret
encryptionKey := deriveEncryptionKey(sharedSecret, chainID, txID)
// Serialize note plaintext
plaintext, err := serializeNote(note)
if err != nil {
return nil, nil, fmt.Errorf("failed to serialize note: %w", err)
}
// Encrypt using ChaCha20-Poly1305
ciphertext, err := encryptChaCha20Poly1305(plaintext, encryptionKey)
if err != nil {
return nil, nil, fmt.Errorf("encryption failed: %w", err)
}
// Derive ephemeral public key
ephemeralPubKey, err := derivePublicKey(ephemeralPrivKey)
if err != nil {
return nil, nil, fmt.Errorf("failed to derive ephemeral public key: %w", err)
}
return ciphertext, ephemeralPubKey, nil
}
// DecryptNote decrypts a note using the recipient's key and ChaCha20-Poly1305.
// chainID and txID must match the values used during encryption.
func DecryptNote(encryptedNote []byte, ephemeralPubKey []byte, recipientPrivKey []byte, chainID ids.ID, txID ids.ID) (*Note, error) {
// Derive shared secret using ECDH
sharedSecret, err := deriveSharedSecret(recipientPrivKey, ephemeralPubKey)
if err != nil {
return nil, fmt.Errorf("failed to derive shared secret: %w", err)
}
// Derive decryption key from shared secret
decryptionKey := deriveEncryptionKey(sharedSecret, chainID, txID)
// Decrypt using ChaCha20-Poly1305
plaintext, err := decryptChaCha20Poly1305(encryptedNote, decryptionKey)
if err != nil {
return nil, fmt.Errorf("decryption failed: %w", err)
}
// Deserialize note
note, err := deserializeNote(plaintext)
if err != nil {
return nil, fmt.Errorf("failed to deserialize note: %w", err)
}
return note, nil
}
// derivePublicKey derives a Curve25519 public key from private key
func derivePublicKey(privKey []byte) ([]byte, error) {
if len(privKey) != 32 {
return nil, errors.New("zkvm: invalid private key length")
}
pubKey, err := curve25519.X25519(privKey, curve25519.Basepoint)
if err != nil {
return nil, fmt.Errorf("zkvm: X25519 base mult failed: %w", err)
}
return pubKey, nil
}
// deriveSharedSecret performs ECDH key exchange using Curve25519
func deriveSharedSecret(privKey, pubKey []byte) ([]byte, error) {
if len(privKey) != 32 {
return nil, errors.New("private key must be 32 bytes")
}
if len(pubKey) != 32 {
return nil, errors.New("public key must be 32 bytes")
}
sharedSecret, err := curve25519.X25519(privKey, pubKey)
if err != nil {
return nil, fmt.Errorf("zkvm: X25519 key exchange failed: %w", err)
}
return sharedSecret, nil
}
// deriveEncryptionKey derives a ChaCha20-Poly1305 key from shared secret using HKDF.
// chainID and txID are bound into the salt and info to prevent cross-chain and cross-tx key reuse.
func deriveEncryptionKey(sharedSecret []byte, chainID ids.ID, txID ids.ID) []byte {
salt := make([]byte, 0, len(chainID)+len("zkvm-v1"))
salt = append(salt, chainID[:]...)
salt = append(salt, []byte("zkvm-v1")...)
info := []byte("zkvm-note-encryption-" + txID.String())
kdf := hkdf.New(sha256.New, sharedSecret, salt, info)
key := make([]byte, chacha20poly1305.KeySize)
if _, err := kdf.Read(key); err != nil {
panic(fmt.Sprintf("hkdf read failed: %v", err))
}
return key
}
// encryptChaCha20Poly1305 encrypts data using ChaCha20-Poly1305 AEAD
func encryptChaCha20Poly1305(plaintext, key []byte) ([]byte, error) {
aead, err := chacha20poly1305.NewX(key)
if err != nil {
return nil, fmt.Errorf("failed to create cipher: %w", err)
}
// Generate random nonce (XChaCha20-Poly1305 uses 24-byte nonce)
nonce := make([]byte, aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, fmt.Errorf("failed to generate nonce: %w", err)
}
// Encrypt and authenticate
ciphertext := aead.Seal(nonce, nonce, plaintext, nil)
return ciphertext, nil
}
// decryptChaCha20Poly1305 decrypts data using ChaCha20-Poly1305 AEAD
func decryptChaCha20Poly1305(ciphertext, key []byte) ([]byte, error) {
aead, err := chacha20poly1305.NewX(key)
if err != nil {
return nil, fmt.Errorf("failed to create cipher: %w", err)
}
nonceSize := aead.NonceSize()
if len(ciphertext) < nonceSize {
return nil, errors.New("ciphertext too short")
}
// Extract nonce and encrypted data
nonce := ciphertext[:nonceSize]
encrypted := ciphertext[nonceSize:]
// Decrypt and verify authentication tag
plaintext, err := aead.Open(nil, nonce, encrypted, nil)
if err != nil {
return nil, fmt.Errorf("decryption or authentication failed: %w", err)
}
return plaintext, nil
}
// serializeNote serializes a note to bytes
func serializeNote(note *Note) ([]byte, error) {
// Format: [value_len(4)][value][address_len(4)][address][assetID(32)][randomness_len(4)][randomness]
valueBytes := note.Value.Bytes()
buf := make([]byte, 0, 4+len(valueBytes)+4+len(note.Address)+32+4+len(note.Randomness))
// Write value
lenBuf := make([]byte, 4)
binary.BigEndian.PutUint32(lenBuf, uint32(len(valueBytes)))
buf = append(buf, lenBuf...)
buf = append(buf, valueBytes...)
// Write address
binary.BigEndian.PutUint32(lenBuf, uint32(len(note.Address)))
buf = append(buf, lenBuf...)
buf = append(buf, note.Address...)
// Write asset ID (fixed 32 bytes)
buf = append(buf, note.AssetID[:]...)
// Write randomness
binary.BigEndian.PutUint32(lenBuf, uint32(len(note.Randomness)))
buf = append(buf, lenBuf...)
buf = append(buf, note.Randomness...)
return buf, nil
}
// deserializeNote deserializes a note from bytes
func deserializeNote(data []byte) (*Note, error) {
if len(data) < 12 { // Minimum: 4+0+4+0+32+4+0
return nil, errors.New("data too short")
}
pos := 0
// Read value
valueLen := binary.BigEndian.Uint32(data[pos : pos+4])
pos += 4
if pos+int(valueLen) > len(data) {
return nil, errors.New("invalid value length")
}
valueBytes := data[pos : pos+int(valueLen)]
value := new(big.Int).SetBytes(valueBytes)
pos += int(valueLen)
// Read address
if pos+4 > len(data) {
return nil, errors.New("data too short for address length")
}
addrLen := binary.BigEndian.Uint32(data[pos : pos+4])
pos += 4
if pos+int(addrLen) > len(data) {
return nil, errors.New("invalid address length")
}
address := make([]byte, addrLen)
copy(address, data[pos:pos+int(addrLen)])
pos += int(addrLen)
// Read asset ID
if pos+32 > len(data) {
return nil, errors.New("data too short for asset ID")
}
var assetID ids.ID
copy(assetID[:], data[pos:pos+32])
pos += 32
// Read randomness
if pos+4 > len(data) {
return nil, errors.New("data too short for randomness length")
}
randLen := binary.BigEndian.Uint32(data[pos : pos+4])
pos += 4
if pos+int(randLen) > len(data) {
return nil, errors.New("invalid randomness length")
}
randomness := make([]byte, randLen)
copy(randomness, data[pos:pos+int(randLen)])
return &Note{
Value: value,
Address: address,
AssetID: assetID,
Randomness: randomness,
}, nil
}
// Transaction validation errors
var (
errInvalidTransactionType = errors.New("invalid transaction type")
errNoInputs = errors.New("transaction has no inputs")
errNoOutputs = errors.New("transaction has no outputs")
errMissingProof = errors.New("transaction missing proof")
errNoExpiry = errors.New("transaction names no expiry height")
errExpired = errors.New("transaction has expired")
errInvalidTransferTransaction = errors.New("invalid transfer transaction")
errInvalidShieldTransaction = errors.New("invalid shield transaction")
errInvalidUnshieldTransaction = errors.New("invalid unshield transaction")
+16 -131
View File
@@ -6,6 +6,7 @@ package zkvm
import (
"bytes"
"errors"
"fmt"
"sync"
"github.com/luxfi/log"
@@ -17,6 +18,10 @@ import (
// Database prefix for UTXO records: utxoPrefix || commitment -> marshalled UTXO.
const utxoPrefix = 0x10
// errNoUTXO — the commitment names no unspent output. Distinct from a read
// that failed, which is reported as the failure it is.
var errNoUTXO = errors.New("zkvm: no such utxo")
// UTXO represents an unspent transaction output
type UTXO struct {
TxID ids.ID `json:"txId"`
@@ -39,19 +44,15 @@ type UTXODB struct {
// the records.
utxoCache map[string]uint64
// Indexes
heightIndex map[uint64][]string // height -> commitments
mu sync.RWMutex
}
// NewUTXODB creates a new UTXO database
func NewUTXODB(db database.Database, log log.Logger) (*UTXODB, error) {
udb := &UTXODB{
db: db,
log: log,
utxoCache: make(map[string]uint64),
heightIndex: make(map[uint64][]string),
db: db,
log: log,
utxoCache: make(map[string]uint64),
}
if err := udb.loadUTXOs(); err != nil {
@@ -88,7 +89,6 @@ func (udb *UTXODB) AddUTXO(utxo *UTXO) error {
// Update the set and the height index
udb.utxoCache[commitmentStr] = utxo.Height
udb.heightIndex[utxo.Height] = append(udb.heightIndex[utxo.Height], commitmentStr)
udb.log.Debug("Added UTXO",
log.String("txID", utxo.TxID.String()),
@@ -114,63 +114,6 @@ func (udb *UTXODB) GetUTXO(commitment []byte) (*UTXO, error) {
return udb.read(commitment)
}
// RemoveUTXO removes a UTXO from the set
func (udb *UTXODB) RemoveUTXO(commitment []byte) error {
udb.mu.Lock()
defer udb.mu.Unlock()
commitmentStr := string(commitment)
// The set holds every commitment, so absence here is absence on disk.
height, exists := udb.utxoCache[commitmentStr]
if !exists {
return errors.New("UTXO not found")
}
// Remove from database
key := makeUTXOKey(commitment)
if err := udb.db.Delete(key); err != nil {
return err
}
// Remove from the set
delete(udb.utxoCache, commitmentStr)
// Update height index
if heightUTXOs, exists := udb.heightIndex[height]; exists {
for i, c := range heightUTXOs {
if c == commitmentStr {
udb.heightIndex[height] = append(heightUTXOs[:i], heightUTXOs[i+1:]...)
break
}
}
}
return nil
}
// GetUTXOsByHeight returns all UTXOs created at a specific height
func (udb *UTXODB) GetUTXOsByHeight(height uint64) ([]*UTXO, error) {
udb.mu.RLock()
defer udb.mu.RUnlock()
commitments, exists := udb.heightIndex[height]
if !exists {
return nil, nil
}
utxos := make([]*UTXO, 0, len(commitments))
for _, commitmentStr := range commitments {
utxo, err := udb.read([]byte(commitmentStr))
if err != nil {
return nil, err
}
utxos = append(utxos, utxo)
}
return utxos, nil
}
// GetUTXOCount returns the total number of UTXOs.
//
// It counts the set rather than reading a running total kept beside it. A total
@@ -184,65 +127,6 @@ func (udb *UTXODB) GetUTXOCount() uint64 {
return uint64(len(udb.utxoCache))
}
// GetAllCommitments returns all UTXO commitments (for Merkle tree)
func (udb *UTXODB) GetAllCommitments() [][]byte {
udb.mu.RLock()
defer udb.mu.RUnlock()
commitments := make([][]byte, 0, len(udb.utxoCache))
for commitmentStr := range udb.utxoCache {
commitments = append(commitments, []byte(commitmentStr))
}
return commitments
}
// PruneOldUTXOs removes UTXOs older than a certain height
func (udb *UTXODB) PruneOldUTXOs(minHeight uint64) error {
udb.mu.Lock()
defer udb.mu.Unlock()
pruneCount := 0
// Find heights to prune
var heightsToPrune []uint64
for height := range udb.heightIndex {
if height < minHeight {
heightsToPrune = append(heightsToPrune, height)
}
}
// Prune UTXOs at each height
for _, height := range heightsToPrune {
commitments := udb.heightIndex[height]
for _, commitmentStr := range commitments {
commitment := []byte(commitmentStr)
// Remove from database
key := makeUTXOKey(commitment)
if err := udb.db.Delete(key); err != nil {
udb.log.Warn("Failed to prune UTXO", log.Reflect("error", err))
continue
}
// Remove from the set
delete(udb.utxoCache, commitmentStr)
pruneCount++
}
// Remove height index
delete(udb.heightIndex, height)
}
udb.log.Info("Pruned old UTXOs",
log.Int("pruneCount", pruneCount),
log.Uint64("minHeight", minHeight),
log.Int("remainingUTXOs", len(udb.utxoCache)),
)
return nil
}
// loadUTXOs rebuilds the set and the height index from the records.
//
// The records share a keyspace with other writers, so a key carrying the UTXO
@@ -269,7 +153,6 @@ func (udb *UTXODB) loadUTXOs() error {
commitmentStr := string(commitment)
udb.utxoCache[commitmentStr] = utxo.Height
udb.heightIndex[utxo.Height] = append(udb.heightIndex[utxo.Height], commitmentStr)
}
return it.Error()
@@ -282,17 +165,20 @@ func (udb *UTXODB) reload() error {
defer udb.mu.Unlock()
udb.utxoCache = make(map[string]uint64)
udb.heightIndex = make(map[uint64][]string)
return udb.loadUTXOs()
}
// read returns the record for a commitment. It touches no shared state, so it
// is safe under either lock.
func (udb *UTXODB) read(commitment []byte) (*UTXO, error) {
key := makeUTXOKey(commitment)
utxoBytes, err := udb.db.Get(key)
if err != nil {
return nil, errors.New("UTXO not found")
utxoBytes, err := udb.db.Get(makeUTXOKey(commitment))
switch {
case errors.Is(err, database.ErrNotFound):
return nil, errNoUTXO
case err != nil:
// A read that FAILED is not a UTXO that is absent. Reported as absent
// it says a note was never created, rather than that the disk is gone.
return nil, fmt.Errorf("zkvm: read utxo: %w", err)
}
var utxo UTXO
@@ -317,5 +203,4 @@ func (udb *UTXODB) Close() {
defer udb.mu.Unlock()
udb.utxoCache = nil
udb.heightIndex = nil
}
+19 -19
View File
@@ -4,6 +4,7 @@
package zkvm
import (
"reflect"
"sync"
"testing"
@@ -51,17 +52,19 @@ func TestUTXOCountIsReadOffTheRecords(t *testing.T) {
require.Equal(t, uint64(1), udb.GetUTXOCount(),
"the count is read off the records, so it cannot disagree with the set it describes")
require.NoError(t, udb.RemoveUTXO(utxo.Commitment))
require.Zero(t, udb.GetUTXOCount(),
"removing the last record must leave zero, not wrap to 2^64-1")
require.ErrorContains(t, udb.RemoveUTXO(utxo.Commitment), "not found")
require.Zero(t, udb.GetUTXOCount())
// There is no removal path: a spend is recorded by its nullifier, and
// deleting the output the note names would destroy the record of it.
typ := reflect.TypeOf(udb)
for _, name := range []string{"RemoveUTXO", "PruneOldUTXOs", "Delete", "Clear"} {
_, found := typ.MethodByName(name)
require.False(t, found, "%s must not exist", name)
}
}
// TestRestartRebuildsTheHeightIndex. Pruning and the Merkle view read the
// height index and the commitment list. A restart that left them empty had the
// node reporting a UTXO set it was still holding.
func TestRestartRebuildsTheHeightIndex(t *testing.T) {
// TestRestartRebuildsTheSet. The set decides whether a commitment already
// exists. It lives in memory, so a restart that left it empty had the node
// accepting an output it was already holding.
func TestRestartRebuildsTheSet(t *testing.T) {
db := memdb.New()
live, err := NewUTXODB(db, log.NoLog{})
require.NoError(t, err)
@@ -74,20 +77,18 @@ func TestRestartRebuildsTheHeightIndex(t *testing.T) {
restarted, err := NewUTXODB(db, log.NoLog{})
require.NoError(t, err)
require.Len(t, restarted.GetAllCommitments(), 3, "the commitment set is rebuilt from the records")
require.Equal(t, uint64(3), restarted.GetUTXOCount(), "the set is rebuilt from the records")
byHeight, err := restarted.GetUTXOsByHeight(5)
require.NoError(t, err)
require.Len(t, byHeight, 3, "and so is the height index")
for _, utxo := range byHeight {
for i := 0; i < 3; i++ {
utxo, err := restarted.GetUTXO([]byte{'c', byte(i)})
require.NoError(t, err)
require.Equal(t, uint64(5), utxo.Height)
require.Equal(t, []byte{'n', utxo.Commitment[1]}, utxo.Ciphertext,
require.Equal(t, []byte{'n', byte(i)}, utxo.Ciphertext,
"the body comes back whole, not just its key")
}
empty, err := restarted.GetUTXOsByHeight(6)
require.NoError(t, err)
require.Empty(t, empty, "a height nothing was created at holds nothing")
_, err = restarted.GetUTXO([]byte("never"))
require.ErrorIs(t, err, errNoUTXO)
}
// TestUTXOReadDoesNotWriteTheSet. GetUTXO must not memoise what it loads while
@@ -138,5 +139,4 @@ func TestForeignRecordIsNotAUTXO(t *testing.T) {
udb, err := NewUTXODB(db, log.NoLog{})
require.NoError(t, err)
require.Zero(t, udb.GetUTXOCount(), "someone else's key is not a commitment")
require.Empty(t, udb.GetAllCommitments())
}
+39 -54
View File
@@ -5,14 +5,12 @@ package zkvm
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"math/big"
"sync"
"github.com/luxfi/accel"
"github.com/luxfi/log"
"github.com/luxfi/precompile/starkfri"
@@ -42,6 +40,11 @@ type ProofVerifier struct {
config ZConfig
log log.Logger
// bind is sha256(ChainID ‖ NetworkID). It is the first public input every
// proof is checked against, so a proof made for another chain does not
// verify here even when the notes it names are unspent on both.
bind [32]byte
// Proof verification cache
proofCache *lru.Cache
@@ -58,7 +61,7 @@ type ProofVerifier struct {
}
// NewProofVerifier creates a new proof verifier
func NewProofVerifier(config ZConfig, log log.Logger) (*ProofVerifier, error) {
func NewProofVerifier(config ZConfig, bind [32]byte, log log.Logger) (*ProofVerifier, error) {
// Create LRU cache for proof verification results
cache, err := lru.New(int(config.ProofCacheSize))
if err != nil {
@@ -67,6 +70,7 @@ func NewProofVerifier(config ZConfig, log log.Logger) (*ProofVerifier, error) {
pv := &ProofVerifier{
config: config,
bind: bind,
log: log,
proofCache: cache,
verifyingKeys: make(map[string][]byte),
@@ -110,13 +114,23 @@ func (pv *ProofVerifier) VerifyTransactionProof(tx *Transaction) error {
return errors.New("zkvm: proof verification disabled — no real verifying keys loaded")
}
// Check cache first — include tx ID to bind proof to specific transaction
proofHash := pv.hashProof(tx)
// The cache is keyed on the transaction's CONTENT — ComputeID covers the
// nullifiers, the outputs and the proof — so a hit means this exact
// transaction verified before.
//
// It used to be keyed on tx.ID as READ OFF THE WIRE, plus the proof and
// its public inputs. All four were the peer's to choose: copy an accepted
// transaction's id, proof type, proof and public inputs onto a transaction
// spending entirely different notes, and the key matched. The hit returned
// nil from here — before verifyPublicInputs, the only thing tying the
// public inputs to what the transaction spends — and the shielded pool
// gained value backed by a proof that attests to nothing about it.
key := tx.ComputeID()
pv.mu.Lock()
pv.verifyCount++
if cached, ok := pv.proofCache.Get(string(proofHash)); ok {
if cached, ok := pv.proofCache.Get(key); ok {
pv.cacheHits++
pv.mu.Unlock()
@@ -142,7 +156,7 @@ func (pv *ProofVerifier) VerifyTransactionProof(tx *Transaction) error {
}
// Cache result
pv.proofCache.Add(string(proofHash), err == nil)
pv.proofCache.Add(key, err == nil)
return err
}
@@ -190,7 +204,8 @@ func (pv *ProofVerifier) refuseClassicalUnderStrictPQ(proofType string) error {
func (pv *ProofVerifier) verifySTARKProof(tx *Transaction) error {
// Bind the proof to this tx's shielded value via the public inputs:
// nullifiers (spends) ‖ output commitments (outputs).
pub := make([]byte, 0, 64)
pub := make([]byte, 0, 96)
pub = append(pub, pv.bind[:]...)
for _, n := range tx.Nullifiers {
pub = append(pub, n...)
}
@@ -214,24 +229,17 @@ func (pv *ProofVerifier) verifySTARKProof(tx *Transaction) error {
}
// VerifyBlockProof verifies an aggregated block proof.
// When GPU is available and multiple proofs exist, uses batch MSM acceleration.
//
// There is ONE verification path. It used to take a batch path when
// accel.Available() and more than one transaction — a second, inline copy of
// the Groth16 checks that consulted neither the proof cache nor the dummy-key
// refusal — so whether a node accepted a block turned on whether that node had
// an accelerator, and validators with and without one rejected each other.
func (pv *ProofVerifier) VerifyBlockProof(block *Block) error {
if block.BlockProof == nil {
return nil // Block proof is optional
}
// Batch verify when multiple transactions and GPU available
if len(block.Txs) > 1 && accel.Available() {
results := batchVerifyProofsGPU(pv, block.Txs)
for i, err := range results {
if err != nil {
return fmt.Errorf("tx %d proof verification failed: %w", i, err)
}
}
return nil
}
// Sequential fallback
for _, tx := range block.Txs {
if err := pv.VerifyTransactionProof(tx); err != nil {
return err
@@ -305,26 +313,32 @@ func (pv *ProofVerifier) verifyPLONKProof(tx *Transaction) error {
return nil
}
// verifyPublicInputs verifies that public inputs match transaction data
// verifyPublicInputs verifies that public inputs match transaction data.
//
// The first input is the chain binding, so a proof made for another chain does
// not verify here even when the notes it names happen to be unspent on both.
func (pv *ProofVerifier) verifyPublicInputs(tx *Transaction) error {
if len(tx.Proof.PublicInputs) == 0 {
return errors.New("no public inputs provided")
}
if !bytes.Equal(tx.Proof.PublicInputs[0], pv.bind[:]) {
return errors.New("public input mismatch for chain binding")
}
// Verify nullifiers are included in public inputs (exact byte comparison)
for i, nullifier := range tx.Nullifiers {
if i >= len(tx.Proof.PublicInputs) {
if i+1 >= len(tx.Proof.PublicInputs) {
return errors.New("missing public input for nullifier")
}
if !bytes.Equal(tx.Proof.PublicInputs[i], nullifier) {
if !bytes.Equal(tx.Proof.PublicInputs[i+1], nullifier) {
return errors.New("public input mismatch for nullifier")
}
}
// Verify output commitments are included (exact byte comparison)
outputCommitments := tx.GetOutputCommitments()
offset := len(tx.Nullifiers)
offset := len(tx.Nullifiers) + 1
for i, commitment := range outputCommitments {
idx := offset + i
@@ -376,7 +390,6 @@ func (pv *ProofVerifier) loadVerifyingKeys() error {
pv.log.Info("Loaded verifying keys",
log.Int("count", len(pv.verifyingKeys)),
log.String("proofSystem", pv.config.ProofSystem),
log.Bool("strictPQ", pv.config.StrictPQ),
)
@@ -388,22 +401,6 @@ func (pv *ProofVerifier) VerifyingKeysLoaded() bool {
return !pv.dummyKeys
}
// hashProof computes a hash of a proof for caching.
// Includes the transaction ID to bind the proof to a specific transaction,
// preventing a valid proof from being replayed for a different tx.
func (pv *ProofVerifier) hashProof(tx *Transaction) []byte {
h := sha256.New()
h.Write(tx.ID[:])
h.Write([]byte(tx.Proof.ProofType))
h.Write(tx.Proof.ProofData)
for _, input := range tx.Proof.PublicInputs {
h.Write(input)
}
return h.Sum(nil)
}
// GetCacheSize returns the current size of the proof cache
func (pv *ProofVerifier) GetCacheSize() int {
return pv.proofCache.Len()
@@ -417,18 +414,6 @@ func (pv *ProofVerifier) GetStats() (verifyCount, cacheHits, cacheMisses uint64)
return pv.verifyCount, pv.cacheHits, pv.cacheMisses
}
// ClearCache clears the proof verification cache
func (pv *ProofVerifier) ClearCache() {
pv.proofCache.Purge()
pv.mu.Lock()
pv.cacheHits = 0
pv.cacheMisses = 0
pv.mu.Unlock()
pv.log.Info("Cleared proof verification cache")
}
// Groth16Proof represents a Groth16 proof structure
type Groth16Proof struct {
Ar bn254.G1Affine // Proof component A
+17 -51
View File
@@ -6,7 +6,6 @@ package zkvm
import (
"encoding/binary"
"errors"
"math/big"
"strings"
"testing"
@@ -74,58 +73,18 @@ func plonkFrame() []byte {
return append(out, make([]byte, 160)...)
}
func keyedVerifier(t *testing.T, system string, keys map[string][]byte) *ProofVerifier {
func keyedVerifier(t *testing.T, keys map[string][]byte) *ProofVerifier {
t.Helper()
pv, err := NewProofVerifier(ZConfig{
ProofSystem: system,
ProofCacheSize: 16,
VerifyingKeys: keys,
}, log.NoLog{})
}, [32]byte{}, log.NoLog{})
if err != nil {
t.Fatalf("NewProofVerifier: %v", err)
}
return pv
}
// TestMSMAgreesWithRepeatedAddition. The multi-scalar multiplication carries
// the public-input linear combination, so everything a classical proof is
// judged on rests on it equalling the sum it stands for.
func TestMSMAgreesWithRepeatedAddition(t *testing.T) {
_, _, g1, _ := bn254.Generators()
scalars := make([]fr.Element, 4)
bases := make([]bn254.G1Affine, 4)
for i := range scalars {
scalars[i].SetUint64(uint64(i + 3))
bases[i].Set(&g1)
}
var want bn254.G1Affine
for i := range scalars {
var term bn254.G1Affine
term.ScalarMultiplication(&bases[i], scalars[i].BigInt(new(big.Int)))
want.Add(&want, &term)
}
defer func() {
if r := recover(); r != nil {
t.Fatalf("computing an MSM must not panic: %v", r)
}
}()
if got := msmCPU(scalars, bases); !got.Equal(&want) {
t.Fatalf("msmCPU = %v, want %v", got, want)
}
got, err := msmGPU(scalars, bases, log.NoLog{})
if err != nil {
t.Fatalf("msmGPU: %v", err)
}
if !got.Equal(&want) {
t.Fatalf("msmGPU = %v, want %v — the accelerated path disagrees with the plain one", got, want)
}
}
// TestWitnessMustFitTheVerifyingKey. A peer chooses how many public inputs its
// transaction carries; the key fixes how many the circuit has. K holds one
// point per input plus the constant term, so a witness of len(K) inputs reads
@@ -175,11 +134,12 @@ func TestWitnessMustFitTheVerifyingKey(t *testing.T) {
// supported deployment and its whole point is that a groth16 proof is accepted
// or rejected on the arithmetic. Any proof that decodes must produce a verdict.
func TestGroth16ReachesItsPairing(t *testing.T) {
// Three K points is a circuit taking two public inputs, which is what this
// Four K points is a circuit taking three public inputs — the chain
// binding, one nullifier and one commitment — which is what this
// transaction supplies. A key that spoke about a different number would be
// refused before the pairing, and this test is about the pairing.
pv := keyedVerifier(t, "groth16", map[string][]byte{
string(TransactionTypeTransfer): groth16Key(3),
pv := keyedVerifier(t, map[string][]byte{
string(TransactionTypeTransfer): groth16Key(4),
})
if !pv.VerifyingKeysLoaded() {
t.Fatal("precondition: the chain holds a real verifying key")
@@ -198,7 +158,7 @@ func TestGroth16ReachesItsPairing(t *testing.T) {
Proof: &ZKProof{
ProofType: "groth16",
ProofData: groth16Frame(),
PublicInputs: [][]byte{nullifier, commitment},
PublicInputs: [][]byte{testBind[:], nullifier, commitment},
},
}
tx.ID = tx.ComputeID()
@@ -224,7 +184,7 @@ func TestGroth16ReachesItsPairing(t *testing.T) {
// TestPLONKReachesItsPairing is the same property for the other classical
// system a non-strict chain may be configured with.
func TestPLONKIsRefusedForTheStatedReason(t *testing.T) {
pv := keyedVerifier(t, "plonk", map[string][]byte{
pv := keyedVerifier(t, map[string][]byte{
string(TransactionTypeTransfer): plonkKey(),
})
@@ -241,7 +201,7 @@ func TestPLONKIsRefusedForTheStatedReason(t *testing.T) {
Proof: &ZKProof{
ProofType: "plonk",
ProofData: plonkFrame(),
PublicInputs: [][]byte{nullifier, commitment},
PublicInputs: [][]byte{testBind[:], nullifier, commitment},
},
}
tx.ID = tx.ComputeID()
@@ -296,7 +256,7 @@ func TestInfinityIsNotAUsablePoint(t *testing.T) {
// empty — every proof valid, value minted from nothing. A circuit with no key
// must be refused instead.
func TestCircuitWithoutAKeyIsRefused(t *testing.T) {
pv := keyedVerifier(t, "plonk", map[string][]byte{
pv := keyedVerifier(t, map[string][]byte{
string(TransactionTypeTransfer): plonkKey(), // shield and unshield keyed with zeros
})
@@ -325,7 +285,7 @@ func TestCircuitWithoutAKeyIsRefused(t *testing.T) {
// the operator did not key is refused by name rather than judged against
// whatever stands in for a key.
func TestKeyingOneCircuitDoesNotEnableTheOthers(t *testing.T) {
pv := keyedVerifier(t, "groth16", map[string][]byte{
pv := keyedVerifier(t, map[string][]byte{
string(TransactionTypeTransfer): groth16Key(4),
})
@@ -431,3 +391,9 @@ func TestWitnessMustMatchWhatTheKeysCircuitTakes(t *testing.T) {
t.Fatalf("the count the key states must reach the pairing, got: %v", err)
}
}
// testBind is the chain binding a test verifier is built with: the zero bind,
// which is what NewProofVerifier(cfg, [32]byte{}, …) carries. Every proof's
// public inputs lead with it, so a proof made for one chain does not verify on
// another.
var testBind [32]byte
+117 -117
View File
@@ -5,6 +5,8 @@ package zkvm
import (
"context"
"crypto/sha256"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
@@ -35,22 +37,11 @@ var (
Minor: 0,
Patch: 0,
}
errNotImplemented = errors.New("not implemented")
)
// ZConfig contains VM configuration
// ZConfig contains VM configuration. Every field here is read; a knob that
// changes nothing reads as a control and is not one.
type ZConfig struct {
// Privacy configuration
EnableConfidentialTransfers bool `json:"enableConfidentialTransfers"`
EnablePrivateAddresses bool `json:"enablePrivateAddresses"`
// ZK proof configuration
ProofSystem string `json:"proofSystem"` // groth16, plonk, etc.
CircuitType string `json:"circuitType"` // transfer, mint, burn
VerifyingKeyPath string `json:"verifyingKeyPath"`
TrustedSetupPath string `json:"trustedSetupPath"`
// VerifyingKeys supplies real (non-dummy) verifying keys per circuit
// type (keyed by the TransactionType string), in-memory at genesis.
// When empty, loadVerifyingKeys installs all-zero dummy keys (proof
@@ -69,17 +60,23 @@ type ZConfig struct {
// shield/unshield proof to mint or steal shielded value.
StrictPQ bool `json:"strictPQ"`
// FHE configuration
EnableFHE bool `json:"enableFHE"`
FHEScheme string `json:"fheScheme"` // BFV, CKKS, etc.
SecurityLevel uint32 `json:"securityLevel"` // 128, 192, 256
// MaxTxPerBlock bounds a block from either direction: what a proposer
// assembles and what Verify accepts off the wire. One number, so a peer
// cannot send a block larger than this node would ever build.
MaxTxPerBlock uint32 `json:"maxTxPerBlock"`
// Performance
MaxUTXOsPerBlock uint32 `json:"maxUtxosPerBlock"`
ProofVerificationTimeout time.Duration `json:"proofVerificationTimeout"`
ProofCacheSize uint32 `json:"proofCacheSize"`
// ProofCacheSize bounds the verified-proof cache.
ProofCacheSize uint32 `json:"proofCacheSize"`
}
// Defaults for a config that names no bound. Zero would mean a block of
// unbounded size and a cache of unbounded growth, which is not "no limit
// configured" but "no limit".
const (
defaultMaxTxPerBlock = 100
defaultProofCacheSize = 1000
)
// VM implements the Zero-Knowledge UTXO Chain VM
type VM struct {
rt *runtime.Runtime
@@ -96,12 +93,10 @@ type VM struct {
chain *chain.Store[chain.Block]
utxoDB *UTXODB
nullifierDB *NullifierDB
stateTree *StateTree
root *Root
// Privacy components
proofVerifier *ProofVerifier
fheProcessor *FHEProcessor
addressManager *AddressManager
proofVerifier *ProofVerifier
// zkPrecompiles holds the Z-Chain ZK verifier precompiles. It is
// populated in Initialize by precompiles.RegisterZKPrecompiles, with
@@ -128,6 +123,13 @@ type VM struct {
// HTTP entry, before it reaches the mempool. Consensus-internal paths
// bypass it.
fee chain.Fee
// bind is sha256(ChainID ‖ NetworkID). It is hashed into every block and
// vertex id and into the public inputs every shielded proof is checked
// against, and it is NOT on the wire — so a block or a proof made for
// another chain does not name a block of this one and does not verify
// here, rather than passing a check someone could forget to write.
bind [32]byte
}
// Initialize initializes the VM
@@ -153,6 +155,12 @@ func (vm *VM) Initialize(
return errors.New("invalid logger type")
}
}
// Bind this chain's identity into every id and every proof preimage.
bind := sha256.New()
bind.Write(vm.rt.ChainID[:])
binary.Write(bind, binary.BigEndian, vm.rt.NetworkID)
copy(vm.bind[:], bind.Sum(nil))
vm.chain = chain.New[chain.Block](init.DB, vm.reload)
// Parse configuration or use defaults
@@ -169,20 +177,17 @@ func (vm *VM) Initialize(
// permissive deployment MUST set StrictPQ=false explicitly in
// genesis; it is never the default for this chain.
vm.config = ZConfig{
EnableConfidentialTransfers: true,
EnablePrivateAddresses: true,
ProofSystem: "stark",
CircuitType: "transfer",
StrictPQ: true,
EnableFHE: false,
MaxUTXOsPerBlock: 100,
ProofCacheSize: 1000,
StrictPQ: true,
MaxTxPerBlock: defaultMaxTxPerBlock,
ProofCacheSize: defaultProofCacheSize,
}
}
// Ensure ProofCacheSize is positive
if vm.config.ProofCacheSize <= 0 {
vm.config.ProofCacheSize = 1000
if vm.config.ProofCacheSize == 0 {
vm.config.ProofCacheSize = defaultProofCacheSize
}
if vm.config.MaxTxPerBlock == 0 {
vm.config.MaxTxPerBlock = defaultMaxTxPerBlock
}
// Initialize UTXO database
@@ -200,14 +205,14 @@ func (vm *VM) Initialize(
vm.nullifierDB = nullifierDB
// Initialize state tree
stateTree, err := NewStateTree(vm.chain.View(), vm.log)
root, err := NewRoot(vm.chain.View(), vm.log)
if err != nil {
return fmt.Errorf("failed to initialize state tree: %w", err)
return fmt.Errorf("failed to initialize state root: %w", err)
}
vm.stateTree = stateTree
vm.root = root
// Initialize proof verifier
proofVerifier, err := NewProofVerifier(vm.config, vm.log)
proofVerifier, err := NewProofVerifier(vm.config, vm.bind, vm.log)
if err != nil {
return fmt.Errorf("failed to initialize proof verifier: %w", err)
}
@@ -227,22 +232,6 @@ func (vm *VM) Initialize(
log.Bool("strictPQ", vm.config.StrictPQ),
)
// Initialize FHE processor if enabled
if vm.config.EnableFHE {
fheProcessor, err := NewFHEProcessor(vm.config, vm.log)
if err != nil {
return fmt.Errorf("failed to initialize FHE processor: %w", err)
}
vm.fheProcessor = fheProcessor
}
// Initialize address manager
addressManager, err := NewAddressManager(vm.chain.Base(), vm.config.EnablePrivateAddresses, vm.log)
if err != nil {
return fmt.Errorf("failed to initialize address manager: %w", err)
}
vm.addressManager = addressManager
// Initialize mempool
vm.mempool = NewMempool(1000, vm.log) // Max 1000 pending txs
@@ -271,7 +260,7 @@ func (vm *VM) Initialize(
}
vm.genesisBlock.ID_ = vm.genesisBlock.computeID()
_, fresh, err := vm.chain.Open(vm.genesisBlock, vm.parseBlock)
_, fresh, err := vm.chain.Open(vm.genesisBlock, func(raw []byte) (chain.Block, error) { return vm.parseBlock(raw) })
if err != nil {
return err
}
@@ -288,10 +277,7 @@ func (vm *VM) Initialize(
vm.log.Info("ZK UTXO VM initialized",
log.String("version", Version.String()),
log.Bool("confidentialTransfers", vm.config.EnableConfidentialTransfers),
log.Bool("privateAddresses", vm.config.EnablePrivateAddresses),
log.String("proofSystem", vm.config.ProofSystem),
log.Bool("fheEnabled", vm.config.EnableFHE),
log.Bool("strictPQ", vm.config.StrictPQ),
)
return nil
@@ -313,32 +299,47 @@ func (vm *VM) StrictPQ() bool { return vm.config.StrictPQ }
func (vm *VM) BuildBlock(ctx context.Context) (vmchain.Block, error) {
built, err := vm.chain.Propose(func(parent chain.Block) (chain.Block, error) {
// Get transactions from mempool
txs := vm.mempool.GetPendingTransactions(int(vm.config.MaxUTXOsPerBlock))
txs := vm.mempool.GetPendingTransactions(int(vm.config.MaxTxPerBlock))
if len(txs) == 0 {
return nil, errors.New("no transactions to include in block")
return nil, errNoTransactions
}
// Verify all transactions
// Assembly runs the SAME predicate Verify runs, and drops what it
// cannot build. It used to skip ValidateBasic, so a transaction with
// an out-of-range Type — which the parser reads straight off the wire
// — was assembled into every block and then refused by every node's
// Verify, including the proposer's. Nothing evicted it, so that
// proposer never produced another block.
validTxs := make([]*Transaction, 0, len(txs))
for _, tx := range txs {
if err := vm.verifyTransaction(tx); err != nil {
vm.log.Debug("Transaction verification failed",
if err := vm.admit(tx, parent.Height()+1); err != nil {
vm.log.Debug("Transaction not admitted",
log.String("txID", tx.ID.String()),
log.Reflect("error", err),
)
vm.mempool.RemoveTransaction(tx.ID)
continue
}
validTxs = append(validTxs, tx)
}
if len(validTxs) == 0 {
return nil, errors.New("no valid transactions to include in block")
return nil, errNoTransactions
}
// Chain time only moves forward, and Verify refuses a block below its
// parent. A parent may legally be up to maxClockSkew ahead of this
// node's clock, so an unclamped time.Now() here builds a block this
// node's own Verify then refuses.
timestamp := time.Now().Unix()
if parentBlock, ok := parent.(*Block); ok && timestamp < parentBlock.BlockTimestamp {
timestamp = parentBlock.BlockTimestamp
}
block := &Block{
ParentID_: parent.ID(),
BlockHeight: parent.Height() + 1,
BlockTimestamp: time.Now().Unix(),
BlockTimestamp: timestamp,
Txs: validTxs,
vm: vm,
}
@@ -358,23 +359,21 @@ func (vm *VM) BuildBlock(ctx context.Context) (vmchain.Block, error) {
return built.(*Block), nil
}
// ParseBlock parses a block from bytes
// ParseBlock parses a block from bytes.
func (vm *VM) ParseBlock(ctx context.Context, blockBytes []byte) (vmchain.Block, error) {
block := &Block{vm: vm}
if err := parseBlockBytes(blockBytes, block); err != nil {
return nil, err
}
block.ID_ = block.computeID()
return block, nil
return vm.parseBlock(blockBytes)
}
// parseBlock decodes a block belonging to this VM. The store reads accepted
// blocks back through it, so there is one decoder rather than one per caller.
func (vm *VM) parseBlock(raw []byte) (chain.Block, error) {
// blocks back through it, and ParseBlock hands peer bytes to it, so there is
// one decoder rather than one per caller — and one place where the id is
// derived from the content rather than read off the wire.
func (vm *VM) parseBlock(raw []byte) (*Block, error) {
block := &Block{vm: vm}
if err := parseBlockBytes(raw, block); err != nil {
return nil, err
}
block.bytes = raw
block.ID_ = block.computeID()
return block, nil
}
@@ -384,7 +383,7 @@ func (vm *VM) GetBlock(ctx context.Context, blkID ids.ID) (vmchain.Block, error)
if blkID == vm.genesisBlock.ID() {
return vm.genesisBlock, nil
}
decided, err := vm.chain.Block(blkID, vm.parseBlock)
decided, err := vm.chain.Block(blkID, func(raw []byte) (chain.Block, error) { return vm.parseBlock(raw) })
if err != nil {
return nil, err
}
@@ -408,7 +407,7 @@ func (vm *VM) reload() error {
if err := vm.utxoDB.reload(); err != nil {
return err
}
return vm.stateTree.reload()
return vm.root.reload()
}
// SetState sets the VM state
@@ -430,12 +429,8 @@ func (vm *VM) Shutdown(ctx context.Context) error {
vm.nullifierDB.Close()
}
if vm.stateTree != nil {
vm.stateTree.Close()
}
if vm.addressManager != nil {
vm.addressManager.Close()
if vm.root != nil {
vm.root.Close()
}
if err := vm.chain.Close(); err != nil {
@@ -464,29 +459,18 @@ func (vm *VM) HealthCheck(ctx context.Context) (vmchain.HealthResult, error) {
}, nil
}
// Health represents VM health status
type Health struct {
DatabaseHealthy bool `json:"databaseHealthy"`
UTXOCount uint64 `json:"utxoCount"`
NullifierCount uint64 `json:"nullifierCount"`
LastBlockHeight uint64 `json:"lastBlockHeight"`
PendingBlockCount int `json:"pendingBlockCount"`
MempoolSize int `json:"mempoolSize"`
ProofCacheSize int `json:"proofCacheSize"`
}
// CreateHandlers returns the VM handlers
// CreateHandlers returns the VM handlers, one per route. See endpoints.
func (vm *VM) CreateHandlers(context.Context) (map[string]http.Handler, error) {
return map[string]http.Handler{
"/rpc": NewRPCHandler(vm),
"/privacy": NewPrivacyHandler(vm),
"/proof": NewProofHandler(vm),
}, nil
return vm.endpoints(), nil
}
// NewHTTPHandler returns HTTP handlers for the VM
// NewHTTPHandler mounts the same routes by path.
func (vm *VM) NewHTTPHandler(ctx context.Context) (http.Handler, error) {
return NewRPCHandler(vm), nil
mux := http.NewServeMux()
for route, h := range vm.endpoints() {
mux.Handle(route, h)
}
return mux, nil
}
// WaitForEvent blocks until there is a transaction to build a block from, or
@@ -497,12 +481,32 @@ func (vm *VM) WaitForEvent(ctx context.Context) (vmcore.Message, error) {
return vm.mempool.WaitForEvent(ctx)
}
// admit is the ONE predicate. Assembly (BuildBlock, BuildVertex) and consensus
// (Block.Verify, Vertex.Verify) both ask it, so a proposer cannot assemble a
// transaction its own peers refuse — which is a halt, free, for whoever sends
// the transaction.
func (vm *VM) admit(tx *Transaction, height uint64) error {
if err := tx.ValidateBasic(); err != nil {
return err
}
if tx.Expiry < height {
return errExpired
}
return vm.verifyTransaction(tx)
}
// verifyTransaction verifies a transaction including ZK proofs
func (vm *VM) verifyTransaction(tx *Transaction) error {
// Check nullifiers aren't already spent
// Check nullifiers aren't already spent. A read that FAILED refuses the
// transaction: reporting "not spent" for a set that could not be read is
// how an already-spent note gets spent again.
for _, nullifier := range tx.Nullifiers {
if vm.nullifierDB.IsNullifierSpent(nullifier) {
return errors.New("nullifier already spent")
_, spent, err := vm.nullifierDB.Spent(nullifier)
if err != nil {
return fmt.Errorf("zkvm: read spent set: %w", err)
}
if spent {
return errNullifierSpent
}
}
@@ -511,13 +515,6 @@ func (vm *VM) verifyTransaction(tx *Transaction) error {
return fmt.Errorf("proof verification failed: %w", err)
}
// Verify FHE operations if enabled
if vm.config.EnableFHE && tx.HasFHEOperations() {
if err := vm.fheProcessor.VerifyFHEOperations(tx); err != nil {
return fmt.Errorf("FHE verification failed: %w", err)
}
}
return nil
}
@@ -525,7 +522,7 @@ func (vm *VM) verifyTransaction(tx *Transaction) error {
// reads the tree without mutating it, so BuildBlock and Verify agree and a
// rejected block leaves nothing behind.
func (vm *VM) computeStateRoot(txs []*Transaction) []byte {
return vm.stateTree.RootAfter(txs)
return vm.root.After(txs)
}
// processGenesisTransactions processes initial transactions from genesis
@@ -549,11 +546,14 @@ func (vm *VM) processGenesisTransactions(genesis *Genesis) error {
// Commit genesis to the root so block 1 builds on it, rather than leaving
// genesis to be re-folded into every later block's root.
return vm.stateTree.Finalize(vm.stateTree.RootAfter(genesis.InitialTxs))
return vm.root.Finalize(vm.root.After(genesis.InitialTxs))
}
// Additional interface implementations
// SetPreference records the block the engine wants the next one built on.
// Dropping it meant Propose always built on the accepted tip, so a node with
// two blocks in flight re-proposed a height it had already proposed.
func (vm *VM) SetPreference(ctx context.Context, blkID ids.ID) error {
vm.chain.Prefer(blkID)
return nil
}
+10 -78
View File
@@ -4,17 +4,17 @@
package zkvm
import (
"encoding/json"
"context"
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
"github.com/luxfi/vm"
"github.com/luxfi/runtime"
"github.com/luxfi/database/memdb"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/runtime"
"github.com/luxfi/vm"
)
func TestVMInitialize(t *testing.T) {
@@ -58,13 +58,8 @@ func TestVMInitialize(t *testing.T) {
// Create config
config := ZConfig{
EnableConfidentialTransfers: true,
EnablePrivateAddresses: true,
ProofSystem: "groth16",
CircuitType: "transfer",
EnableFHE: false,
MaxUTXOsPerBlock: 100,
ProofCacheSize: 1000,
MaxTxPerBlock: 100,
ProofCacheSize: 1000,
}
configBytes, err := json.Marshal(config)
@@ -86,9 +81,8 @@ func TestVMInitialize(t *testing.T) {
// Verify initialization
require.NotNil(vmImpl.utxoDB)
require.NotNil(vmImpl.nullifierDB)
require.NotNil(vmImpl.stateTree)
require.NotNil(vmImpl.root)
require.NotNil(vmImpl.proofVerifier)
require.NotNil(vmImpl.addressManager)
require.NotNil(vmImpl.mempool)
// Test health check
@@ -126,12 +120,13 @@ func TestShieldedTransaction(t *testing.T) {
ProofType: "groth16",
ProofData: make([]byte, 256),
PublicInputs: [][]byte{
testBind[:], // chain binding
make([]byte, 32), // nullifier
make([]byte, 32), // output commitment
},
},
Fee: 1000,
Expiry: 0,
Expiry: 1 << 20,
}
// Compute transaction ID
@@ -148,31 +143,6 @@ func TestShieldedTransaction(t *testing.T) {
require.Equal(1, vmImpl.mempool.Size())
}
func TestPrivateAddress(t *testing.T) {
require := require.New(t)
// Setup VM with privacy enabled
vmImpl := setupTestVMWithPrivacy(t)
defer vmImpl.Shutdown(context.Background())
// Generate a private address
addr, err := vmImpl.addressManager.GenerateAddress()
require.NoError(err)
require.NotNil(addr)
// Verify address components
require.Len(addr.Address, 32)
require.Len(addr.ViewingKey, 32)
require.Len(addr.SpendingKey, 32)
require.Len(addr.Diversifier, 11)
require.Len(addr.IncomingViewKey, 32)
// Test address retrieval
retrieved, err := vmImpl.addressManager.GetAddress(addr.Address)
require.NoError(err)
require.Equal(addr.Address, retrieved.Address)
}
// Helper functions
func setupTestVM(t *testing.T) *VM {
@@ -191,46 +161,8 @@ func setupTestVM(t *testing.T) *VM {
genesisBytes, _ := json.Marshal(genesis)
config := ZConfig{
ProofSystem: "groth16",
MaxUTXOsPerBlock: 100,
ProofCacheSize: 1000,
}
configBytes, _ := json.Marshal(config)
vmImpl := &VM{}
toEngine := make(chan vm.Message, 1)
require.NoError(t, vmImpl.Initialize(ctx, vm.Init{
Runtime: chainRuntime,
DB: db,
Genesis: genesisBytes,
Config: configBytes,
ToEngine: toEngine,
}))
return vmImpl
}
func setupTestVMWithPrivacy(t *testing.T) *VM {
ctx := context.Background()
chainRuntime := &runtime.Runtime{
ChainID: ids.GenerateTestID(),
Log: log.NoLog{},
}
db := memdb.New()
genesis := &Genesis{
Timestamp: 1607144400,
InitialTxs: []*Transaction{},
}
genesisBytes, _ := json.Marshal(genesis)
config := ZConfig{
EnablePrivateAddresses: true,
ProofSystem: "groth16",
MaxUTXOsPerBlock: 100,
ProofCacheSize: 1000,
MaxTxPerBlock: 100,
ProofCacheSize: 1000,
}
configBytes, _ := json.Marshal(config)
+39 -113
View File
@@ -14,9 +14,9 @@ import (
// Native-ZAP struct-is-wire for Z-Chain. No pcodecs, no reflection, no codec
// registry. Each type owns Marshal() []byte over a zap object; nested slices
// ([]*Sub) are packed as a u32-length list + concatenated sub Marshal() blobs;
// optional pointers (*ZKProof/*FHEData) are a single bytes field that is empty
// iff nil; [][]byte is a length list + concat blob. Parse rejects trailing
// bytes (canonical). Re-genesis authorized.
// an optional pointer (*ZKProof) is a single bytes field that is empty iff nil;
// [][]byte is a length list + concat blob. Parse rejects trailing bytes
// (canonical). Re-genesis authorized.
var (
// errTrailingBytes — the frame declares fewer bytes than were handed to the
@@ -257,42 +257,6 @@ func parseZKProof(data []byte) (*ZKProof, error) {
return &ZKProof{ProofType: string(o.Bytes(0)), ProofData: cp(o.Bytes(8)), PublicInputs: pub}, nil
}
// ================= FHEData: EncInLens list@0, EncInBlob bytes@8, CircuitID bytes@16, EncResult bytes@24, CompProof bytes@32 =================
const fheSize = 40
func marshalFHEData(f *FHEData) []byte {
if f == nil {
return nil
}
lens, blob := packBytesList(f.EncryptedInputs)
b := zap.NewBuilder(zap.HeaderSize + fheSize + len(blob) + 4*len(lens) + len(f.CircuitID) + len(f.EncryptedResult) + len(f.ComputationProof) + 64)
lensOff := writeU32List(b, lens)
ob := b.StartObject(fheSize)
ob.SetList(0, lensOff, len(lens))
ob.SetBytes(8, blob)
ob.SetBytes(16, []byte(f.CircuitID))
ob.SetBytes(24, f.EncryptedResult)
ob.SetBytes(32, f.ComputationProof)
ob.FinishAsRoot()
return b.Finish()
}
func parseFHEData(data []byte) (*FHEData, error) {
if len(data) == 0 {
return nil, nil
}
o, err := parseFrame(data)
if err != nil {
return nil, err
}
in, err := unpackBytesList(readU32List(o, 0), o.Bytes(8))
if err != nil {
return nil, err
}
return &FHEData{EncryptedInputs: in, CircuitID: string(o.Bytes(16)), EncryptedResult: cp(o.Bytes(24)), ComputationProof: cp(o.Bytes(32))}, nil
}
// ================= UTXO: TxID32@0, OutputIndex u32@32, Height u64@36, Commitment bytes@44, Ciphertext bytes@52, EphemeralPK bytes@60 =================
const utxoSize = 68
@@ -324,45 +288,17 @@ func parseUTXO(data []byte, u *UTXO) error {
return nil
}
// ================= PrivateAddress: 5 byte fields @0/8/16/24/32, CreatedAt i64@40 =================
const paSize = 48
func (p *PrivateAddress) Marshal() ([]byte, error) {
b := zap.NewBuilder(zap.HeaderSize + paSize + len(p.Address) + len(p.ViewingKey) +
len(p.SpendingKey) + len(p.Diversifier) + len(p.IncomingViewKey) + 64)
ob := b.StartObject(paSize)
ob.SetBytes(0, p.Address)
ob.SetBytes(8, p.ViewingKey)
ob.SetBytes(16, p.SpendingKey)
ob.SetBytes(24, p.Diversifier)
ob.SetBytes(32, p.IncomingViewKey)
ob.SetInt64(40, p.CreatedAt)
ob.FinishAsRoot()
return b.Finish(), nil
}
func parsePrivateAddress(data []byte, p *PrivateAddress) error {
o, err := parseFrame(data)
if err != nil {
return err
}
p.Address = cp(o.Bytes(0))
p.ViewingKey = cp(o.Bytes(8))
p.SpendingKey = cp(o.Bytes(16))
p.Diversifier = cp(o.Bytes(24))
p.IncomingViewKey = cp(o.Bytes(32))
p.CreatedAt = o.Int64(40)
return nil
}
// ================= Transaction =================
//
// ID 32B@0, Type u8@32, Version u8@33, Fee u64@34, Expiry u64@42,
// TInLens list@50, TInBlob bytes@58, TOutLens list@66, TOutBlob bytes@74,
// NullLens list@82, NullBlob bytes@90, SOutLens list@98, SOutBlob bytes@106,
// Proof bytes@114, FHE bytes@122, Memo bytes@130, Signature bytes@138
const txSize = 146
// Type u8@0, Version u8@1, Fee u64@2, Expiry u64@10,
// TInLens list@18, TInBlob bytes@26, TOutLens list@34, TOutBlob bytes@42,
// NullLens list@50, NullBlob bytes@58, SOutLens list@66, SOutBlob bytes@74,
// Proof bytes@82, Memo bytes@90
//
// The id is NOT here. It is ComputeID() over these fields, so a peer cannot
// choose it, and the proof cache keyed on it cannot be reached by a
// transaction other than the one the proof was verified for.
const txSize = 98
func (tx *Transaction) Marshal() ([]byte, error) {
tinLens, tinBlob := packObjs(tx.TransparentInputs, marshalTransparentInput)
@@ -370,10 +306,9 @@ func (tx *Transaction) Marshal() ([]byte, error) {
nullLens, nullBlob := packBytesList(tx.Nullifiers)
soutLens, soutBlob := packObjs(tx.Outputs, marshalShieldedOutput)
proof := marshalZKProof(tx.Proof)
fhe := marshalFHEData(tx.FHEData)
b := zap.NewBuilder(zap.HeaderSize + txSize + len(tinBlob) + len(toutBlob) + len(nullBlob) +
len(soutBlob) + len(proof) + len(fhe) + len(tx.Memo) + len(tx.Signature) +
len(soutBlob) + len(proof) + len(tx.Memo) +
4*(len(tinLens)+len(toutLens)+len(nullLens)+len(soutLens)) + 512)
tinOff := writeU32List(b, tinLens)
toutOff := writeU32List(b, toutLens)
@@ -381,23 +316,20 @@ func (tx *Transaction) Marshal() ([]byte, error) {
soutOff := writeU32List(b, soutLens)
ob := b.StartObject(txSize)
ob.SetBytesFixed(0, tx.ID[:])
ob.SetUint8(32, uint8(tx.Type))
ob.SetUint8(33, tx.Version)
ob.SetUint64(34, tx.Fee)
ob.SetUint64(42, tx.Expiry)
ob.SetList(50, tinOff, len(tinLens))
ob.SetBytes(58, tinBlob)
ob.SetList(66, toutOff, len(toutLens))
ob.SetBytes(74, toutBlob)
ob.SetList(82, nullOff, len(nullLens))
ob.SetBytes(90, nullBlob)
ob.SetList(98, soutOff, len(soutLens))
ob.SetBytes(106, soutBlob)
ob.SetBytes(114, proof)
ob.SetBytes(122, fhe)
ob.SetBytes(130, tx.Memo)
ob.SetBytes(138, tx.Signature)
ob.SetUint8(0, uint8(tx.Type))
ob.SetUint8(1, tx.Version)
ob.SetUint64(2, tx.Fee)
ob.SetUint64(10, tx.Expiry)
ob.SetList(18, tinOff, len(tinLens))
ob.SetBytes(26, tinBlob)
ob.SetList(34, toutOff, len(toutLens))
ob.SetBytes(42, toutBlob)
ob.SetList(50, nullOff, len(nullLens))
ob.SetBytes(58, nullBlob)
ob.SetList(66, soutOff, len(soutLens))
ob.SetBytes(74, soutBlob)
ob.SetBytes(82, proof)
ob.SetBytes(90, tx.Memo)
ob.FinishAsRoot()
return b.Finish(), nil
}
@@ -408,38 +340,32 @@ func parseTransaction(data []byte) (*Transaction, error) {
return nil, err
}
tx := &Transaction{
ID: readID(o, 0),
Type: TransactionType(o.Uint8(32)),
Version: o.Uint8(33),
Fee: o.Uint64(34),
Expiry: o.Uint64(42),
Memo: cp(o.Bytes(130)),
Signature: cp(o.Bytes(138)),
Type: TransactionType(o.Uint8(0)),
Version: o.Uint8(1),
Fee: o.Uint64(2),
Expiry: o.Uint64(10),
Memo: cp(o.Bytes(90)),
}
if tx.TransparentInputs, err = unpackObjs(readU32List(o, 50), o.Bytes(58), parseTransparentInput); err != nil {
if tx.TransparentInputs, err = unpackObjs(readU32List(o, 18), o.Bytes(26), parseTransparentInput); err != nil {
return nil, err
}
if tx.TransparentOutputs, err = unpackObjs(readU32List(o, 66), o.Bytes(74), parseTransparentOutput); err != nil {
if tx.TransparentOutputs, err = unpackObjs(readU32List(o, 34), o.Bytes(42), parseTransparentOutput); err != nil {
return nil, err
}
if tx.Nullifiers, err = unpackBytesList(readU32List(o, 82), o.Bytes(90)); err != nil {
if tx.Nullifiers, err = unpackBytesList(readU32List(o, 50), o.Bytes(58)); err != nil {
return nil, err
}
if tx.Outputs, err = unpackObjs(readU32List(o, 98), o.Bytes(106), parseShieldedOutput); err != nil {
if tx.Outputs, err = unpackObjs(readU32List(o, 66), o.Bytes(74), parseShieldedOutput); err != nil {
return nil, err
}
if tx.Proof, err = parseZKProof(o.Bytes(114)); err != nil {
return nil, err
}
if tx.FHEData, err = parseFHEData(o.Bytes(122)); err != nil {
if tx.Proof, err = parseZKProof(o.Bytes(82)); err != nil {
return nil, err
}
// The identity is derived, never read. See ComputeID.
tx.ID = tx.ComputeID()
return tx, nil
}
// MarshalTx / ParseTx are the exported tx wire entry points.
func (tx *Transaction) MarshalTx() ([]byte, error) { return tx.Marshal() }
// ================= Block =================
//
// ParentID 32B@0, Height u64@32, Timestamp i64@40, TxLens list@48,
+30 -23
View File
@@ -34,7 +34,6 @@ func TestWireRoundTrip_UTXO(t *testing.T) {
func TestWireRoundTrip_Transaction(t *testing.T) {
require := require.New(t)
tx := &Transaction{
ID: ids.ID{9},
Type: TransactionTypeShield,
Version: 1,
Fee: 100,
@@ -49,17 +48,28 @@ func TestWireRoundTrip_Transaction(t *testing.T) {
Outputs: []*ShieldedOutput{
{Commitment: []byte("c"), EncryptedNote: []byte("n"), EphemeralPubKey: []byte("e"), OutputProof: []byte("p")},
},
Proof: &ZKProof{ProofType: "groth16", ProofData: []byte("pd"), PublicInputs: [][]byte{[]byte("pi1"), []byte("pi2")}},
FHEData: &FHEData{EncryptedInputs: [][]byte{[]byte("ei")}, CircuitID: "circ", EncryptedResult: []byte("er"), ComputationProof: []byte("cp")},
Memo: []byte("memo"),
Signature: []byte("sig"),
Proof: &ZKProof{ProofType: "groth16", ProofData: []byte("pd"), PublicInputs: [][]byte{[]byte("pi1"), []byte("pi2")}},
Memo: []byte("memo"),
}
b, err := tx.Marshal()
require.NoError(err)
got, err := parseTransaction(b)
require.NoError(err)
// The id is DERIVED on the way in, not carried. A peer that chooses it
// chooses the proof-cache key, and the cache answers before anything binds
// the proof to what the transaction spends.
require.Equal(tx.ComputeID(), got.ID)
tx.ID = got.ID
require.Equal(tx, got)
// Whatever id the sender puts on the value, the wire carries none, so the
// bytes and the parsed identity are the same either way.
tx.ID = ids.ID{0xFF}
forged, err := tx.Marshal()
require.NoError(err)
require.Equal(b, forged, "the id must not reach the wire")
// Canonical: one transaction has one byte string, so bytes appended to a
// valid frame are refused rather than ignored. Ignoring them would give a
// peer unlimited fresh encodings of a transaction the node already holds.
@@ -78,24 +88,20 @@ func txFrame(nullLens []uint32, nullBlob []byte) []byte {
nullOff := writeU32List(b, nullLens)
ob := b.StartObject(txSize)
var id ids.ID
ob.SetBytesFixed(0, id[:])
ob.SetUint8(32, uint8(TransactionTypeTransfer))
ob.SetUint8(33, 1)
ob.SetUint64(34, 1)
ob.SetUint64(42, 0)
ob.SetList(50, empty, 0)
ob.SetBytes(58, nil)
ob.SetUint8(0, uint8(TransactionTypeTransfer))
ob.SetUint8(1, 1)
ob.SetUint64(2, 1)
ob.SetUint64(10, 0)
ob.SetList(18, empty, 0)
ob.SetBytes(26, nil)
ob.SetList(34, empty, 0)
ob.SetBytes(42, nil)
ob.SetList(50, nullOff, len(nullLens))
ob.SetBytes(58, nullBlob)
ob.SetList(66, empty, 0)
ob.SetBytes(74, nil)
ob.SetList(82, nullOff, len(nullLens))
ob.SetBytes(90, nullBlob)
ob.SetList(98, empty, 0)
ob.SetBytes(106, nil)
ob.SetBytes(114, nil)
ob.SetBytes(122, nil)
ob.SetBytes(130, nil)
ob.SetBytes(138, nil)
ob.SetBytes(82, nil)
ob.SetBytes(90, nil)
ob.FinishAsRoot()
return b.Finish()
}
@@ -137,7 +143,8 @@ func TestDeclaredLengthNeverSizesAnAllocation(t *testing.T) {
func TestWireRoundTrip_Block(t *testing.T) {
require := require.New(t)
tx := &Transaction{ID: ids.ID{7}, Type: TransactionTypeTransfer, Fee: 1, Nullifiers: [][]byte{[]byte("x")}}
tx := &Transaction{Type: TransactionTypeTransfer, Fee: 1, Nullifiers: [][]byte{[]byte("x")}}
tx.ID = tx.ComputeID()
blk := &Block{
ParentID_: ids.ID{2},
BlockHeight: 5,
@@ -155,6 +162,6 @@ func TestWireRoundTrip_Block(t *testing.T) {
require.Equal(blk.BlockTimestamp, got.BlockTimestamp)
require.Equal(blk.StateRoot, got.StateRoot)
require.Len(got.Txs, 1)
require.Equal(tx.ID, got.Txs[0].ID)
require.Equal(tx.ComputeID(), got.Txs[0].ID, "a nested transaction's id is derived from its content too")
require.Equal(blk.BlockProof, got.BlockProof)
}
-107
View File
@@ -1,107 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//
// Z-Chain MLDSAStark witness producer for Quasar parallel-witness
// finality (LP-020 §2 Consensus Modes, witness set bit WitnessZ).
//
// The Z-Chain VM is the parallel finality witness for the Z lane. Each
// consensus round, the Quasar driver pushes a 32-byte round digest plus
// the canonical validator ML-DSA-65 public-key list (rooted in
// pchain_validator_root) to this adapter. The adapter collects per-
// validator ML-DSA-65 signatures over the digest and produces a single
// post-quantum STARK / FRI proof attesting:
//
// ∀ i ∈ [N]: ML-DSA.Verify(pk_i, digest, σ_i) = 1
//
// The proof is a strict-PQ STARK (a Plonky3 fork: cSHAKE256 Merkle
// commitments over the Goldilocks 64-bit prime field, FRI low-degree
// test — NO KZG, NO pairings, NO trusted setup). It is verified on-chain
// by the STARK verifier (chains/zkvm/precompiles/zk_verifiers.go
// STARKVerifier, which delegates to precompile/starkfri), NOT by the
// pairing-based Groth16 precompile. This keeps the Z-lane witness
// quantum-safe: a quantum adversary that breaks bn254 cannot forge the
// aggregation proof, whose soundness rests only on cSHAKE256 collision
// resistance and the Reed–Solomon proximity gap.
//
// PROVER STATUS — NOT YET WIRED (the honest remaining step).
// ----------------------------------------------------------
// Producing real witnesses requires:
//
// (1) the MLDSA-rollup AIR/circuit: an arithmetization of
// "∀ i: ML-DSA.Verify(pk_i, digest, σ_i) = 1" over the Goldilocks
// field. This circuit does NOT exist in ~/work/lux/p3q today
// (the p3q-zchain crate has DKG / committee / rotation logic but
// no validator-signature-aggregation AIR), and
// (2) a prover C ABI: the p3q workspace exposes a Rust-level prover
// (p3q-stark::Prover trait, p3q-fri), but its C ABI (p3q-c-abi)
// exports ONLY `p3q_verify` — there is NO `p3q_prove` entry point
// for the Go consensus layer to invoke over cgo.
//
// Until both land, Witness returns ErrZWitnessNotImplemented and the
// round driver finalizes at the next-lower witness level (PolicyQuorum
// or PolicyPQ). The VERIFIER side and the proof-type plumbing are
// already STARK/FRI (see STARKVerifier); only the prover binding is
// outstanding. We intentionally do NOT fake a prover: emitting bytes
// that "look like" a proof but carry no soundness would be a forgery
// oracle on the finality path. See LP-020 §6 and
// proofs/quasar-cert-soundness.tex App. B for the AIR constraint-count
// and prover-cost analysis.
//
// The method signature matches consensus/protocol/quasar.ZWitnessProducer
// (Witness(ctx, [32]byte, [][]byte) ([]byte, error)) so this adapter
// satisfies that interface structurally once the consensus dependency
// is bumped to ship the new type.
package zkvm
import (
"context"
"errors"
)
// ZWitnessAdapter adapts the Z-Chain MLDSAStark prover to the consensus
// ZWitnessProducer interface used by the Quasar round driver.
//
// TODO(pqz-circuit): implement the MLDSAStark rollup AIR (Goldilocks)
// and add a `p3q_prove` C ABI to ~/work/lux/p3q, then integrate the
// prover here. Until then Witness returns ErrZWitnessNotImplemented and
// the round driver finalizes at the next lower witness level
// (PolicyQuorum or PolicyPQ). The verifier side is already STARK/FRI.
type ZWitnessAdapter struct {
vm *VM
}
// NewZWitnessAdapter constructs a Z-witness adapter backed by the given
// Z-Chain VM.
func NewZWitnessAdapter(vm *VM) *ZWitnessAdapter {
return &ZWitnessAdapter{vm: vm}
}
// ErrZWitnessNotImplemented is returned by ZWitnessAdapter.Witness until
// the MLDSAStark rollup AIR and the p3q prover C ABI ship. The verifier
// side (STARKVerifier → precompile/starkfri) is already wired; this is
// the prover-binding gap only.
var ErrZWitnessNotImplemented = errors.New("Z-Chain MLDSAStark prover not implemented: needs the MLDSA-rollup AIR + a p3q_prove C ABI (LP-020 §6, paper App. B)")
// Witness produces a post-quantum STARK / FRI proof aggregating per-
// validator ML-DSA-65 signatures over the round digest. Signature
// matches consensus/protocol/quasar.ZWitnessProducer.
//
// validatorMLDSAPubs is the canonical ML-DSA-65 public-key list rooted in
// pchain_validator_root for the round; the STARK circuit takes this list
// as a public input.
//
// Returns ErrZWitnessNotImplemented today (prover binding outstanding —
// see the package comment); the round driver treats this as the witness
// being unavailable and finalizes at the next-lower witness level. We do
// NOT return fabricated proof bytes: an unsound "proof" on the finality
// path would be a forgery oracle.
func (a *ZWitnessAdapter) Witness(ctx context.Context, digest [32]byte, validatorMLDSAPubs [][]byte) ([]byte, error) {
_ = ctx
_ = digest
_ = validatorMLDSAPubs
if a.vm == nil {
return nil, ErrZWitnessNotImplemented
}
return nil, ErrZWitnessNotImplemented
}