mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
Every adopter asked whether its block still extended the tip, released that reading, and only then asked the store for the lock. The tip moves in between and the commit lands anyway, writing the height index and the tip pointer for a block on an abandoned branch -- which every peer bootstrapping from that index is then served as canonical. The lock is not reentrant, so the check cannot come from the caller and be held across the commit: it comes from the store or it is not held at all. Parent joins Height on the Block interface because a place in the sequence takes both, and height alone is the reading that lets an old accepted block pass. Two concurrent accepts fall to the same move, not just the Verify-to-Accept window: the tip a block is judged against is now read inside the critical section that commits it, so the second is refused rather than serialised behind a reading taken before the first landed. The store also exempts nothing, where identityvm exempted height zero -- and its Verify skips every parent check at height zero, so a peer could hand a live chain a second genesis carrying records of its choosing and send the tip and the height back to the start. mpcvm keeps its root comparison, which answers a different question -- whether applying this block reaches the root the network agreed on -- and stays true of a node whose state moved for any reason. That a moved tip also showed up there is a consequence of that chain refusing empty blocks, not what the comparison is for. Seed records the tip in that same commit, because seeding is what makes a chain no longer fresh. Recording none left Open, for which a missing tip is a fresh chain, reporting a seeded node fresh on every boot and asking for the allocation again: a duplicate its own state refuses, and a node that cannot restart until it produces a block it cannot produce.
348 lines
11 KiB
Go
348 lines
11 KiB
Go
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package zkvm
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/luxfi/log"
|
|
|
|
"github.com/luxfi/chains/chain"
|
|
"github.com/luxfi/consensus/core/choices"
|
|
"github.com/luxfi/database"
|
|
"github.com/luxfi/ids"
|
|
)
|
|
|
|
var _ chain.Block = (*Block)(nil)
|
|
|
|
// Block represents a block in the ZK UTXO chain
|
|
type Block struct {
|
|
ParentID_ ids.ID `json:"parentId"`
|
|
BlockHeight uint64 `json:"height"`
|
|
BlockTimestamp int64 `json:"timestamp"`
|
|
Txs []*Transaction `json:"transactions"`
|
|
StateRoot []byte `json:"stateRoot"` // Merkle tree root of UTXO set
|
|
|
|
// There is no aggregate block proof. What stood here was carried on the
|
|
// wire, hashed into the id, and "verified" by re-verifying the very
|
|
// transactions Verify had already verified one line above — a second
|
|
// implementation of a check, standing where a reader would take an
|
|
// aggregate guarantee to be.
|
|
|
|
// Cached values
|
|
ID_ ids.ID
|
|
bytes []byte
|
|
status choices.Status
|
|
vm *VM
|
|
}
|
|
|
|
// ID returns the block ID
|
|
func (b *Block) ID() ids.ID {
|
|
if b.ID_ == ids.Empty {
|
|
b.ID_ = b.computeID()
|
|
}
|
|
return b.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)
|
|
|
|
for _, tx := range b.Txs {
|
|
txID := tx.ComputeID()
|
|
h.Write(txID[:])
|
|
}
|
|
|
|
// Include state root
|
|
h.Write(b.StateRoot)
|
|
|
|
return ids.ID(h.Sum(nil))
|
|
}
|
|
|
|
// ParentID returns the parent block ID
|
|
func (b *Block) ParentID() ids.ID {
|
|
return b.ParentID_
|
|
}
|
|
|
|
// Parent is an alias for ParentID for compatibility
|
|
func (b *Block) Parent() ids.ID {
|
|
return b.ParentID_
|
|
}
|
|
|
|
// Height returns the block height
|
|
func (b *Block) Height() uint64 {
|
|
return b.BlockHeight
|
|
}
|
|
|
|
// Timestamp returns the block timestamp
|
|
func (b *Block) Timestamp() time.Time {
|
|
return time.Unix(b.BlockTimestamp, 0)
|
|
}
|
|
|
|
// Status returns the block status
|
|
func (b *Block) Status() uint8 {
|
|
return uint8(b.status)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Block-level shape: every nullifier in the block must be distinct.
|
|
// verifyTransaction below only sees nullifiers already spent in ACCEPTED
|
|
// state, so without this two txs in one block — or one tx listing a nullifier
|
|
// twice — spend the same shielded note and inflate supply. Checked before the
|
|
// proofs because it is the cheaper gate.
|
|
spentHere := make(map[string]struct{}, len(b.Txs))
|
|
for _, tx := range b.Txs {
|
|
for _, nullifier := range tx.Nullifiers {
|
|
if _, dup := spentHere[string(nullifier)]; dup {
|
|
return errDuplicateNullifier
|
|
}
|
|
spentHere[string(nullifier)] = struct{}{}
|
|
}
|
|
}
|
|
|
|
// Verify each transaction against the SAME predicate assembly ran.
|
|
for _, tx := range b.Txs {
|
|
if err := b.vm.admit(tx, b.BlockHeight); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Verify against parent
|
|
if b.BlockHeight > 0 {
|
|
parentBlock, err := b.vm.block(b.ParentID_)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// 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",
|
|
chain.ErrNotOnTip, parentBlock.ID(), parentBlock.BlockHeight, tipHeight)
|
|
}
|
|
|
|
if b.BlockHeight != parentBlock.BlockHeight+1 {
|
|
return errInvalidHeight
|
|
}
|
|
|
|
if b.BlockTimestamp < parentBlock.BlockTimestamp {
|
|
return errInvalidTimestamp
|
|
}
|
|
}
|
|
|
|
// Verify state root
|
|
if !bytes.Equal(b.StateRoot, b.vm.computeStateRoot(b.Txs)) {
|
|
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
|
|
}
|
|
|
|
// Accept applies the block. Everything below is staged and committed in one
|
|
// batch with the block and the tip, so a spend that cannot be recorded takes
|
|
// the whole block with it.
|
|
//
|
|
// This used to mark the block accepted and move lastAccepted before writing
|
|
// anything, then issue a Put per nullifier and per output, each returning
|
|
// early. A failure partway left some notes spent and some outputs created,
|
|
// 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.
|
|
// Whether it still extends the tip is the store's to decide, under the lock
|
|
// that commits. Asking here read the tip, released it, and only then asked for
|
|
// the lock, so a tip that moved in between was answered with a reading taken
|
|
// before it moved.
|
|
func (b *Block) Accept(ctx context.Context) error { return b.vm.chain.Accept(b) }
|
|
|
|
// Write records the block's spends and its outputs, and advances the committed
|
|
// state root. The three stores were built over this same view at Initialize,
|
|
// so what they write here commits with the block or not at all.
|
|
func (b *Block) Write(database.Database) error {
|
|
for _, tx := range b.Txs {
|
|
for _, nullifier := range tx.Nullifiers {
|
|
if err := b.vm.nullifierDB.MarkNullifierSpent(nullifier, b.BlockHeight); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for i, output := range tx.Outputs {
|
|
if err := b.vm.utxoDB.AddUTXO(&UTXO{
|
|
TxID: tx.ID,
|
|
OutputIndex: uint32(i),
|
|
Commitment: output.Commitment,
|
|
Ciphertext: output.EncryptedNote,
|
|
EphemeralPK: output.EphemeralPubKey,
|
|
Height: b.BlockHeight,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return b.vm.root.Finalize(b.StateRoot)
|
|
}
|
|
|
|
// Publish marks the block accepted and releases the transactions it carried.
|
|
// It runs after the commit, so a transaction is only dropped from the mempool
|
|
// once the block that spends it is durable.
|
|
func (b *Block) Publish() {
|
|
b.status = choices.Accepted
|
|
for _, tx := range b.Txs {
|
|
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()),
|
|
log.Int("txCount", len(b.Txs)),
|
|
)
|
|
}
|
|
|
|
// Reject rejects the block
|
|
func (b *Block) Reject(ctx context.Context) error {
|
|
b.status = choices.Rejected
|
|
b.vm.chain.Drop(b.ID())
|
|
|
|
// Return transactions to mempool
|
|
for _, tx := range b.Txs {
|
|
b.vm.mempool.AddTransaction(tx)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Bytes returns the block's canonical encoding, computed once.
|
|
func (b *Block) Bytes() []byte {
|
|
if b.bytes == nil {
|
|
b.bytes = b.Marshal()
|
|
}
|
|
return b.bytes
|
|
}
|
|
|
|
// Genesis represents genesis data
|
|
type Genesis struct {
|
|
Timestamp int64 `json:"timestamp"`
|
|
InitialTxs []*Transaction `json:"initialTransactions,omitempty"`
|
|
|
|
// Initial setup parameters
|
|
SetupParams *SetupParams `json:"setupParams,omitempty"`
|
|
}
|
|
|
|
// SetupParams contains trusted setup parameters
|
|
type SetupParams struct {
|
|
// Groth16 CRS
|
|
PowersOfTau []byte `json:"powersOfTau,omitempty"`
|
|
VerifyingKey []byte `json:"verifyingKey,omitempty"`
|
|
|
|
// PLONK setup
|
|
PlonkSRS []byte `json:"plonkSRS,omitempty"`
|
|
|
|
// FHE parameters
|
|
FHEPublicParams []byte `json:"fhePublicParams,omitempty"`
|
|
}
|
|
|
|
// ParseGenesis parses genesis bytes (supports both JSON and Codec formats)
|
|
func ParseGenesis(genesisBytes []byte) (*Genesis, error) {
|
|
var genesis Genesis
|
|
if len(genesisBytes) > 0 {
|
|
// Try JSON first (simple genesis)
|
|
if err := json.Unmarshal(genesisBytes, &genesis); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// BlockSummary represents a lightweight block summary
|
|
type BlockSummary struct {
|
|
ID ids.ID `json:"id"`
|
|
Height uint64 `json:"height"`
|
|
Timestamp int64 `json:"timestamp"`
|
|
TxCount int `json:"txCount"`
|
|
StateRoot []byte `json:"stateRoot"`
|
|
}
|
|
|
|
// ToSummary converts a block to a summary
|
|
func (b *Block) ToSummary() *BlockSummary {
|
|
return &BlockSummary{
|
|
ID: b.ID(),
|
|
Height: b.BlockHeight,
|
|
Timestamp: b.BlockTimestamp,
|
|
TxCount: len(b.Txs),
|
|
StateRoot: b.StateRoot,
|
|
}
|
|
}
|
|
|
|
const (
|
|
maxClockSkew = 60 // seconds
|
|
)
|
|
|
|
var (
|
|
errInvalidBlock = errors.New("invalid block")
|
|
errFutureBlock = errors.New("block timestamp too far in future")
|
|
errInvalidHeight = errors.New("invalid block height")
|
|
errInvalidTimestamp = errors.New("invalid block timestamp")
|
|
errInvalidStateRoot = errors.New("invalid state root")
|
|
|
|
// 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")
|
|
)
|