mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
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.
245 lines
7.3 KiB
Go
245 lines
7.3 KiB
Go
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package zkvm
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"errors"
|
|
|
|
"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
|
|
|
|
const (
|
|
TransactionTypeTransfer TransactionType = iota
|
|
TransactionTypeMint
|
|
TransactionTypeBurn
|
|
TransactionTypeShield // Convert transparent to shielded
|
|
TransactionTypeUnshield // Convert shielded to transparent
|
|
)
|
|
|
|
// Transaction represents a confidential transaction
|
|
type Transaction struct {
|
|
ID ids.ID `json:"id"`
|
|
Type TransactionType `json:"type"`
|
|
Version uint8 `json:"version"`
|
|
|
|
// Transparent inputs/outputs (for shield/unshield)
|
|
TransparentInputs []*TransparentInput `json:"transparentInputs,omitempty"`
|
|
TransparentOutputs []*TransparentOutput `json:"transparentOutputs,omitempty"`
|
|
|
|
// Shielded components
|
|
Nullifiers [][]byte `json:"nullifiers"` // Spent note nullifiers
|
|
Outputs []*ShieldedOutput `json:"outputs"` // New shielded outputs
|
|
|
|
// Zero-knowledge proof
|
|
Proof *ZKProof `json:"proof"`
|
|
|
|
// Transaction metadata
|
|
Fee uint64 `json:"fee"`
|
|
Expiry uint64 `json:"expiry"` // Block height
|
|
Memo []byte `json:"memo,omitempty"` // Encrypted memo
|
|
}
|
|
|
|
// TransparentInput represents an unshielded input
|
|
type TransparentInput struct {
|
|
TxID ids.ID `json:"txId"`
|
|
OutputIdx uint32 `json:"outputIdx"`
|
|
Amount uint64 `json:"amount"`
|
|
Address []byte `json:"address"`
|
|
}
|
|
|
|
// TransparentOutput represents an unshielded output
|
|
type TransparentOutput struct {
|
|
Amount uint64 `json:"amount"`
|
|
Address []byte `json:"address"`
|
|
AssetID ids.ID `json:"assetId"`
|
|
}
|
|
|
|
// ShieldedOutput represents a confidential output
|
|
type ShieldedOutput struct {
|
|
// Commitment to the note (amount and address)
|
|
Commitment []byte `json:"commitment"`
|
|
|
|
// Encrypted note ciphertext
|
|
EncryptedNote []byte `json:"encryptedNote"`
|
|
|
|
// Ephemeral public key for note encryption
|
|
EphemeralPubKey []byte `json:"ephemeralPubKey"`
|
|
|
|
// Output proof (rangeproof for amount)
|
|
OutputProof []byte `json:"outputProof"`
|
|
}
|
|
|
|
// ZKProof represents a zero-knowledge proof
|
|
type ZKProof struct {
|
|
ProofType string `json:"proofType"` // groth16, plonk, etc.
|
|
ProofData []byte `json:"proofData"`
|
|
PublicInputs [][]byte `json:"publicInputs"`
|
|
}
|
|
|
|
// ComputeID is the transaction's identity: a hash over everything the
|
|
// 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.
|
|
func (tx *Transaction) ComputeID() ids.ID {
|
|
h := sha256.New()
|
|
num := func(v uint64) { binary.Write(h, binary.BigEndian, v) }
|
|
blob := func(b []byte) { num(uint64(len(b))); h.Write(b) }
|
|
count := func(n int) { num(uint64(n)) }
|
|
present := func(yes bool) {
|
|
if yes {
|
|
h.Write([]byte{1})
|
|
return
|
|
}
|
|
h.Write([]byte{0})
|
|
}
|
|
|
|
h.Write([]byte{byte(tx.Type), tx.Version})
|
|
|
|
count(len(tx.TransparentInputs))
|
|
for _, in := range tx.TransparentInputs {
|
|
h.Write(in.TxID[:])
|
|
num(uint64(in.OutputIdx))
|
|
num(in.Amount)
|
|
blob(in.Address)
|
|
}
|
|
|
|
count(len(tx.TransparentOutputs))
|
|
for _, out := range tx.TransparentOutputs {
|
|
num(out.Amount)
|
|
h.Write(out.AssetID[:])
|
|
blob(out.Address)
|
|
}
|
|
|
|
count(len(tx.Nullifiers))
|
|
for _, nullifier := range tx.Nullifiers {
|
|
blob(nullifier)
|
|
}
|
|
|
|
count(len(tx.Outputs))
|
|
for _, output := range tx.Outputs {
|
|
blob(output.Commitment)
|
|
blob(output.EncryptedNote)
|
|
blob(output.EphemeralPubKey)
|
|
blob(output.OutputProof)
|
|
}
|
|
|
|
present(tx.Proof != nil)
|
|
if tx.Proof != nil {
|
|
blob([]byte(tx.Proof.ProofType))
|
|
blob(tx.Proof.ProofData)
|
|
count(len(tx.Proof.PublicInputs))
|
|
for _, input := range tx.Proof.PublicInputs {
|
|
blob(input)
|
|
}
|
|
}
|
|
|
|
num(tx.Fee)
|
|
num(tx.Expiry)
|
|
blob(tx.Memo)
|
|
|
|
return ids.ID(h.Sum(nil))
|
|
}
|
|
|
|
// GetNullifiers returns all nullifiers in the transaction
|
|
func (tx *Transaction) GetNullifiers() [][]byte {
|
|
return tx.Nullifiers
|
|
}
|
|
|
|
// GetOutputCommitments returns all output commitments
|
|
func (tx *Transaction) GetOutputCommitments() [][]byte {
|
|
commitments := make([][]byte, len(tx.Outputs))
|
|
for i, output := range tx.Outputs {
|
|
commitments[i] = output.Commitment
|
|
}
|
|
return commitments
|
|
}
|
|
|
|
// ValidateBasic performs basic validation
|
|
func (tx *Transaction) ValidateBasic() error {
|
|
// Check transaction type
|
|
if tx.Type > TransactionTypeUnshield {
|
|
return errInvalidTransactionType
|
|
}
|
|
|
|
// Check nullifiers and outputs
|
|
if len(tx.Nullifiers) == 0 && len(tx.TransparentInputs) == 0 {
|
|
return errNoInputs
|
|
}
|
|
|
|
if len(tx.Outputs) == 0 && len(tx.TransparentOutputs) == 0 {
|
|
return errNoOutputs
|
|
}
|
|
|
|
// Check proof
|
|
if tx.Proof == nil {
|
|
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:
|
|
// Must have shielded inputs and outputs
|
|
if len(tx.Nullifiers) == 0 || len(tx.Outputs) == 0 {
|
|
return errInvalidTransferTransaction
|
|
}
|
|
|
|
case TransactionTypeShield:
|
|
// Must have transparent inputs and shielded outputs
|
|
if len(tx.TransparentInputs) == 0 || len(tx.Outputs) == 0 {
|
|
return errInvalidShieldTransaction
|
|
}
|
|
|
|
case TransactionTypeUnshield:
|
|
// Must have shielded inputs and transparent outputs
|
|
if len(tx.Nullifiers) == 0 || len(tx.TransparentOutputs) == 0 {
|
|
return errInvalidUnshieldTransaction
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
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")
|
|
)
|