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:
2026-07-29 16:48:10 -07:00
co-authored by Hanzo Dev
parent ae28f35b97
commit df5ccabc99
2 changed files with 38 additions and 163 deletions
+29 -140
View File
@@ -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
View File
@@ -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