mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
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:
+12
-32
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user