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