mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
zkvm: make the state root one pure function, not a hardware-dependent one
Two defects in the same computation, both consensus-fatal. 1. StateTree.ComputeRoot returned a GPU Poseidon digest when accel.Available() and a SHA-256 digest otherwise, over inputs truncated to 8 bytes each on the GPU path. The root is committed in the block and hard-compared in Block.Verify, so a validator with an accelerator and one without computed different roots for identical inputs and rejected each other's blocks — a chain split decided by host hardware. 2. computeStateRoot mutated the tree it was measuring: Block.Verify called it, ApplyTransaction appended to pendingAdds/pendingRemoves, and only Accept -> Finalize cleared them. Verifying the same block twice, or verifying a block that loses a poll and then its winner, folded the earlier block's commitments into the later root. Proven: computing the root for one tx set twice in a row returns two different roots, so a valid block is rejected with errInvalidStateRoot and the node stalls. The root is now RootAfter: SHA-256 over currentRoot, then every output commitment in tx order, then every nullifier in tx order. It reads the tree and never writes it; Finalize is the only mutation and Accept its only caller. Genesis now finalizes its own root instead of leaving its txs to be re-folded into every later block. Also deletes the dead computeRootFromLeaves/updateLeafPath cluster, which ranged a map while sequentially rewriting st.currentRoot. Tests: TestComputeStateRootIsPure, TestStateRootHasOneDefinition, TestStateRootAdvancesOnlyOnFinalize. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
+29
-140
@@ -23,10 +23,6 @@ type StateTree struct {
|
||||
currentRoot []byte
|
||||
treeHeight int
|
||||
|
||||
// Pending changes
|
||||
pendingAdds [][]byte
|
||||
pendingRemoves [][]byte
|
||||
|
||||
// Merkle tree cache (path -> hash)
|
||||
nodeCache map[string][]byte
|
||||
|
||||
@@ -59,65 +55,48 @@ func NewStateTree(db database.Database, log log.Logger) (*StateTree, error) {
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// ApplyTransaction applies a transaction to the state tree
|
||||
func (st *StateTree) ApplyTransaction(tx *Transaction) error {
|
||||
st.mu.Lock()
|
||||
defer st.mu.Unlock()
|
||||
|
||||
// Remove spent UTXOs (nullifiers)
|
||||
for _, nullifier := range tx.Nullifiers {
|
||||
st.pendingRemoves = append(st.pendingRemoves, nullifier)
|
||||
}
|
||||
|
||||
// Add new UTXOs (output commitments)
|
||||
for _, output := range tx.Outputs {
|
||||
st.pendingAdds = append(st.pendingAdds, output.Commitment)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ComputeRoot computes the new Merkle root after pending changes.
|
||||
// Uses GPU-accelerated Poseidon hash when available for ZK-friendly hashing.
|
||||
// Falls back to SHA-256 when GPU is unavailable.
|
||||
func (st *StateTree) ComputeRoot() ([]byte, error) {
|
||||
// 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()
|
||||
|
||||
// Collect all inputs for hashing
|
||||
inputs := make([][]byte, 0, 1+len(st.pendingAdds)+len(st.pendingRemoves))
|
||||
inputs = append(inputs, st.currentRoot)
|
||||
inputs = append(inputs, st.pendingAdds...)
|
||||
inputs = append(inputs, st.pendingRemoves...)
|
||||
|
||||
// Try GPU Poseidon hash (ZK-friendly)
|
||||
if result, err := poseidonHashGPU(inputs); err == nil && len(result) > 0 {
|
||||
// Pad to 32 bytes for consistency
|
||||
root := make([]byte, 32)
|
||||
copy(root, result)
|
||||
return root, nil
|
||||
}
|
||||
|
||||
// CPU fallback: SHA-256
|
||||
h := sha256.New()
|
||||
for _, input := range inputs {
|
||||
h.Write(input)
|
||||
h.Write(st.currentRoot)
|
||||
for _, tx := range txs {
|
||||
for _, output := range tx.Outputs {
|
||||
h.Write(output.Commitment)
|
||||
}
|
||||
}
|
||||
return h.Sum(nil), nil
|
||||
for _, tx := range txs {
|
||||
for _, nullifier := range tx.Nullifiers {
|
||||
h.Write(nullifier)
|
||||
}
|
||||
}
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
// Finalize commits the pending changes and updates the root
|
||||
// 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()
|
||||
|
||||
// Update root
|
||||
st.currentRoot = newRoot
|
||||
|
||||
// Clear pending changes
|
||||
st.pendingAdds = nil
|
||||
st.pendingRemoves = nil
|
||||
|
||||
// Save root to database
|
||||
if err := st.db.Put([]byte("state_root"), newRoot); err != nil {
|
||||
return err
|
||||
@@ -125,8 +104,6 @@ func (st *StateTree) Finalize(newRoot []byte) error {
|
||||
|
||||
st.log.Debug("State tree finalized",
|
||||
log.String("root", fmt.Sprintf("%x", newRoot[:8])),
|
||||
log.Int("adds", len(st.pendingAdds)),
|
||||
log.Int("removes", len(st.pendingRemoves)),
|
||||
)
|
||||
|
||||
return nil
|
||||
@@ -214,8 +191,6 @@ func (st *StateTree) Close() {
|
||||
st.mu.Lock()
|
||||
defer st.mu.Unlock()
|
||||
|
||||
st.pendingAdds = nil
|
||||
st.pendingRemoves = nil
|
||||
st.nodeCache = nil
|
||||
}
|
||||
|
||||
@@ -278,89 +253,3 @@ func (st *StateTree) getNodeHash(path []byte) ([]byte, error) {
|
||||
st.nodeCache[pathKey] = hash
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
// setNodeHash stores a node hash in the database and cache
|
||||
func (st *StateTree) setNodeHash(path []byte, hash []byte) error {
|
||||
// Update cache
|
||||
pathKey := string(path)
|
||||
st.nodeCache[pathKey] = hash
|
||||
|
||||
// Store in database
|
||||
dbKey := append([]byte("smt_node_"), path...)
|
||||
return st.db.Put(dbKey, hash)
|
||||
}
|
||||
|
||||
// computeRoot computes the root hash after applying pending changes
|
||||
func (st *StateTree) computeRootFromLeaves(leaves map[string][]byte) ([]byte, error) {
|
||||
// Build tree bottom-up from all leaves
|
||||
|
||||
if len(leaves) == 0 {
|
||||
return make([]byte, 32), nil // Empty root
|
||||
}
|
||||
|
||||
// For each leaf, update its path to the root
|
||||
for leafPath, leafHash := range leaves {
|
||||
if err := st.updateLeafPath([]byte(leafPath), leafHash); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Root is at the top level
|
||||
return st.currentRoot, nil
|
||||
}
|
||||
|
||||
// updateLeafPath updates a leaf and propagates changes to the root
|
||||
func (st *StateTree) updateLeafPath(leafIndex []byte, leafHash []byte) error {
|
||||
currentHash := leafHash
|
||||
|
||||
for level := 0; level < st.treeHeight; level++ {
|
||||
bit := getBit(leafIndex, level)
|
||||
siblingPath := getSiblingPath(leafIndex, level)
|
||||
|
||||
// Get sibling hash
|
||||
siblingHash, err := st.getNodeHash(siblingPath)
|
||||
if err != nil {
|
||||
// Sibling doesn't exist, use empty hash
|
||||
siblingHash = make([]byte, 32)
|
||||
}
|
||||
|
||||
// Compute parent hash
|
||||
var parentHash []byte
|
||||
if bit == 0 {
|
||||
parentHash = hashPair(currentHash, siblingHash)
|
||||
} else {
|
||||
parentHash = hashPair(siblingHash, currentHash)
|
||||
}
|
||||
|
||||
// Get parent path by truncating to level+1 bits
|
||||
parentPath := getParentPath(leafIndex, level+1)
|
||||
|
||||
// Store parent hash
|
||||
if err := st.setNodeHash(parentPath, parentHash); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
currentHash = parentHash
|
||||
}
|
||||
|
||||
// Update root
|
||||
st.currentRoot = currentHash
|
||||
return nil
|
||||
}
|
||||
|
||||
// getParentPath gets the path to a node's parent
|
||||
func getParentPath(path []byte, bitsToKeep int) []byte {
|
||||
result := make([]byte, len(path))
|
||||
copy(result, path)
|
||||
|
||||
// Zero out bits beyond bitsToKeep
|
||||
for i := bitsToKeep; i < len(result)*8; i++ {
|
||||
byteIndex := i / 8
|
||||
bitIndex := i % 8
|
||||
if byteIndex < len(result) {
|
||||
result[byteIndex] &^= (1 << (7 - bitIndex))
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
+9
-23
@@ -352,12 +352,7 @@ func (vm *VM) BuildBlock(ctx context.Context) (chain.Block, error) {
|
||||
vm: vm,
|
||||
}
|
||||
|
||||
// Compute state root after applying transactions
|
||||
stateRoot, err := vm.computeStateRoot(validTxs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block.StateRoot = stateRoot
|
||||
block.StateRoot = vm.computeStateRoot(validTxs)
|
||||
|
||||
// Compute block ID
|
||||
block.ID_ = block.computeID()
|
||||
@@ -520,17 +515,11 @@ func (vm *VM) verifyTransaction(tx *Transaction) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// computeStateRoot computes the new state root after applying transactions
|
||||
func (vm *VM) computeStateRoot(txs []*Transaction) ([]byte, error) {
|
||||
// Apply transactions to state tree
|
||||
for _, tx := range txs {
|
||||
if err := vm.stateTree.ApplyTransaction(tx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Compute and return new root
|
||||
return vm.stateTree.ComputeRoot()
|
||||
// computeStateRoot returns the state root a block carrying txs commits to. It
|
||||
// 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)
|
||||
}
|
||||
|
||||
// processGenesisTransactions processes initial transactions from genesis
|
||||
@@ -550,14 +539,11 @@ func (vm *VM) processGenesisTransactions(genesis *Genesis) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Add to state tree
|
||||
if err := vm.stateTree.ApplyTransaction(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
// 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))
|
||||
}
|
||||
|
||||
// Additional interface implementations
|
||||
|
||||
Reference in New Issue
Block a user