zkvm: count the spent set instead of keeping a total beside it

The spent-nullifier count was a running total persisted next to the records it
described. Two writes, so a node that died between them came back with a number
that disagreed with its own set — and RemoveNullifier decremented an unsigned
counter with no floor, so unwinding from there wrapped to 2^64-1 and every
dashboard reading it was wrong from then on.

Flooring the decrement would have hidden that; the count would still have been a
second thing that has to agree with a first. Every nullifier record is loaded at
startup and nullifiers are never pruned, so the cache is the whole set, and the
count is now read off it. It cannot disagree with what it describes, the stored
key is gone, and so is the arithmetic that could underflow.

Starts the test from the state that used to be unrecoverable: a record on disk
with no counter beside it.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
Hanzo AI
2026-08-25 12:09:09 -07:00
parent 0ee8686a59
commit 7e87683b45
3 changed files with 69 additions and 38 deletions
+12 -32
View File
@@ -16,7 +16,6 @@ import (
const (
// Database prefixes
nullifierPrefix = 0x20
nullifierCountKey = "nullifier_count"
nullifierHeightKey = "nullifier_height_"
)
@@ -25,9 +24,10 @@ type NullifierDB struct {
db database.Database
log log.Logger
// Caches
// 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
nullifierCount uint64
// Indexes
heightIndex map[uint64][]string // height -> nullifiers
@@ -44,16 +44,6 @@ func NewNullifierDB(db database.Database, log log.Logger) (*NullifierDB, error)
heightIndex: make(map[uint64][]string),
}
// Load nullifier count
countBytes, err := db.Get([]byte(nullifierCountKey))
if err == database.ErrNotFound {
ndb.nullifierCount = 0
} else if err != nil {
return nil, err
} else {
ndb.nullifierCount = binary.BigEndian.Uint64(countBytes)
}
if err := ndb.loadNullifiers(); err != nil {
return nil, err
}
@@ -88,17 +78,9 @@ func (ndb *NullifierDB) MarkNullifierSpent(nullifier []byte, height uint64) erro
// Update height index
ndb.heightIndex[height] = append(ndb.heightIndex[height], nullifierStr)
// Update count
ndb.nullifierCount++
countBytes := make([]byte, 8)
binary.BigEndian.PutUint64(countBytes, ndb.nullifierCount)
if err := ndb.db.Put([]byte(nullifierCountKey), countBytes); err != nil {
return err
}
ndb.log.Debug("Marked nullifier as spent",
log.Uint64("height", height),
log.Uint64("nullifierCount", ndb.nullifierCount),
log.Int("nullifiers", len(ndb.nullifierCache)),
)
return nil
@@ -167,11 +149,17 @@ func (ndb *NullifierDB) GetNullifiersByHeight(height uint64) [][]byte {
return nullifiers
}
// GetNullifierCount returns the total number of spent nullifiers
// GetNullifierCount returns the number of spent nullifiers.
//
// It counts the set rather than reading a running total kept beside it. A total
// is a second write, and a node that dies between the two comes back with a
// number that disagrees with its own records — from which one removal drives an
// unsigned counter below zero and reports 1.8e19 spent notes forever. Counting
// the set cannot disagree with the set.
func (ndb *NullifierDB) GetNullifierCount() uint64 {
ndb.mu.RLock()
defer ndb.mu.RUnlock()
return ndb.nullifierCount
return uint64(len(ndb.nullifierCache))
}
// RemoveNullifier removes a nullifier (used for reorg)
@@ -212,14 +200,6 @@ func (ndb *NullifierDB) RemoveNullifier(nullifier []byte) error {
}
}
// Update count
ndb.nullifierCount--
countBytes := make([]byte, 8)
binary.BigEndian.PutUint64(countBytes, ndb.nullifierCount)
if err := ndb.db.Put([]byte(nullifierCountKey), countBytes); err != nil {
return err
}
return nil
}
+57
View File
@@ -0,0 +1,57 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zkvm
import (
"testing"
"github.com/luxfi/database/memdb"
"github.com/luxfi/log"
"github.com/stretchr/testify/require"
)
// TestSpentCountIsReadOffTheRecords. The count used to be a running total
// written beside the records, so a node that died between the two writes came
// back with a number that disagreed with its own set — and one removal from
// there drove an unsigned counter below zero, reporting 1.8e19 spent notes to
// every dashboard from then on.
//
// Counting the set cannot disagree with the set, so this starts from the state
// that used to be unrecoverable: a record on disk with no counter beside it.
func TestSpentCountIsReadOffTheRecords(t *testing.T) {
db := memdb.New()
// The record landed; a counter write, had there been one, did not.
require.NoError(t, db.Put(makeNullifierKey([]byte("orphan")), make([]byte, 8)))
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.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.
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.Equal(t, uint64(2), ndb.GetNullifierCount())
require.NoError(t, ndb.RemoveNullifier([]byte("a")))
require.Equal(t, uint64(1), 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())
}
-6
View File
@@ -411,12 +411,6 @@ func TestRegressionL02_LoadNullifiersPopulatesCache(t *testing.T) {
if err := db.Put(key, heightBytes); err != nil {
t.Fatalf("db.Put: %v", err)
}
countBytes := make([]byte, 8)
countBytes[7] = 1
if err := db.Put([]byte(nullifierCountKey), countBytes); err != nil {
t.Fatalf("db.Put count: %v", err)
}
// Construct NullifierDB -- loadNullifiers runs during construction
ndb, err := NewNullifierDB(db, log.NoLog{})
if err != nil {