pulsar: epoch-prune the in-memory nonce ledger (RED LOW nit #2)

The single-use ledger map grew unbounded over a validator's lifetime. Add
epoch-based pruning that bounds it to a sliding retained-epoch window WITHOUT
reopening reuse inside the live window:

  - (*InMemoryNonceLedger).PruneBefore(minEpoch) drops reservations with
    binding.Epoch < minEpoch; entries in [minEpoch, inf) are kept, so reuse there
    is still refused (ErrNonceReused). Caller picks minEpoch >= finality/reorg
    depth (a nonce from a pruned, past-finality epoch cannot be usefully replayed).
  - NoncePruner — the optional pruning capability, kept OFF the base NonceLedger
    interface (reserve/check stays minimal); persistent ledgers may prune via TTL.
  - PruneShareLedgers(minEpoch) — operator lever: sweeps the process-global
    per-share registry, pruning every ledger that implements NoncePruner. Snapshots
    under the registry lock, prunes each under its own lock (never two locks at once).

Test: PruneBefore(8) frees epochs 1..7; reuse within retained [8,10] still
ErrNonceReused; pruned slots reclaimed. Registry sweep prunes across per-share
ledgers, retained-window reuse still refused. GATE A (nonce single-use default
path + cross-committee dedup) stays green.
This commit is contained in:
2026-06-27 23:34:20 -07:00
parent 8aad342dd7
commit a55e1d5c7f
2 changed files with 190 additions and 0 deletions
+72
View File
@@ -130,6 +130,22 @@ type NonceLedger interface {
CheckBinding(key [32]byte, binding NonceBinding) (reserved bool, err error)
}
// NoncePruner is the OPTIONAL epoch-pruning capability of a NonceLedger. The
// in-memory ledger implements it to bound its map to a sliding retained-epoch
// window (so the single-use store cannot grow unbounded over a validator's
// lifetime); a persistent (crash-safe) ledger may instead rely on storage-side
// retention/TTL. It is kept OFF the base NonceLedger interface — pruning is a
// distinct capability, not part of the reserve/check contract — and reached
// generically via PruneShareLedgers, which type-asserts to it.
type NoncePruner interface {
// PruneBefore drops reservations minted in an epoch STRICTLY OLDER than
// minEpoch (binding.Epoch < minEpoch) and returns the count dropped. It MUST
// NOT drop any reservation in the retained window [minEpoch, ∞): reuse there
// stays refused (ErrNonceReused). Pruning therefore frees memory WITHOUT
// reopening the nonce-reuse key-recovery vector inside the live window.
PruneBefore(minEpoch uint64) int
}
// InMemoryNonceLedger is the default mutex-guarded in-process ledger. It is
// correct for a single long-lived validator process; it does NOT persist
// across restarts (production must inject a persistent ledger — see residuals).
@@ -168,6 +184,32 @@ func (l *InMemoryNonceLedger) CheckBinding(key [32]byte, binding NonceBinding) (
return true, nil
}
// PruneBefore implements NoncePruner: it drops every reservation minted in an
// epoch STRICTLY OLDER than minEpoch (binding.Epoch < minEpoch) and returns the
// count dropped, bounding the in-memory map to a sliding retained-epoch window so
// the single-use store cannot grow unbounded over a validator's lifetime.
//
// SECURITY INVARIANT — pruning NEVER reopens nonce reuse inside the live window.
// It removes ONLY entries older than minEpoch; every reservation in the retained
// window [minEpoch, ∞) is kept, so a second use there is still refused
// (ErrNonceReused). The caller MUST pick minEpoch so the retained window is at
// least as deep as the protocol's finality / reorg horizon: a signature bound to
// a pruned (ancient) epoch is no longer acceptable on-chain, so a freed nonce
// cannot be usefully replayed. Reservations with Epoch == 0 (no SetNonceBinding
// domain context) sort as the lowest epoch and are pruned by any minEpoch > 0.
func (l *InMemoryNonceLedger) PruneBefore(minEpoch uint64) int {
l.mu.Lock()
defer l.mu.Unlock()
dropped := 0
for k, b := range l.seen {
if b.Epoch < minEpoch {
delete(l.seen, k)
dropped++
}
}
return dropped
}
// ── safe-by-construction per-share single-use ledger registry ─────────────
//
// The single-use guard MUST be shared across every signer instance holding the
@@ -216,6 +258,36 @@ func setShareLedger(identity [32]byte, l NonceLedger) {
}
}
// PruneShareLedgers epoch-prunes EVERY per-share single-use ledger in the
// process-global registry that supports pruning (implements NoncePruner),
// dropping reservations older than minEpoch, and returns the total count dropped.
// This is the operator lever that bounds the registry's lifetime memory growth:
// the consensus layer calls it once per epoch (or per finalized checkpoint) with
//
// minEpoch = currentEpoch − retainedWindow, retainedWindow ≥ finality/reorg depth
//
// Reservations in the retained window stay refused (single-use is never reopened
// inside the live window — see PruneBefore). Ledgers that do not implement
// NoncePruner (e.g. a storage-backed ledger that prunes itself via TTL) are
// skipped. The registry snapshot is taken under its own lock and each ledger is
// pruned under its own lock, so this never holds two locks at once.
func PruneShareLedgers(minEpoch uint64) int {
shareLedgerMu.Lock()
ledgers := make([]NonceLedger, 0, len(shareLedgerRegistry))
for _, l := range shareLedgerRegistry {
ledgers = append(ledgers, l)
}
shareLedgerMu.Unlock()
total := 0
for _, l := range ledgers {
if p, ok := l.(NoncePruner); ok {
total += p.PruneBefore(minEpoch)
}
}
return total
}
// shareIdentityKey is the committee-INDEPENDENT identity of a validator key-
// share: the SAME secret share (across any committee, session, or signer
// instance) hashes to the SAME key, so all of its signers resolve to ONE
@@ -0,0 +1,118 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pulsar
// nonce_ledger_prune_test.go — post-merge nit #2 (LOW): epoch-prune the in-memory
// single-use nonce ledger so it cannot grow unbounded over a validator's
// lifetime, WITHOUT reintroducing reuse inside the live (retained) window.
import (
"encoding/binary"
"testing"
)
// compile-time: the in-memory ledger is a NoncePruner (the optional capability).
var _ NoncePruner = (*InMemoryNonceLedger)(nil)
func pruneKey(epoch uint64) [32]byte {
var k [32]byte
binary.BigEndian.PutUint64(k[:8], epoch)
k[31] = 0xAA
return k
}
// PruneBefore frees old entries; reuse inside the retained window is still
// refused (ErrNonceReused); pruned slots are reclaimed.
func TestNonceLedger_EpochPruneFreesOldRetainsWindow(t *testing.T) {
l := NewInMemoryNonceLedger()
// Reserve one nonce per epoch 1..10.
for e := uint64(1); e <= 10; e++ {
if err := l.Reserve(pruneKey(e), NonceBinding{Epoch: e}); err != nil {
t.Fatalf("reserve epoch %d: %v", e, err)
}
}
// Single-use holds for ALL of them before pruning.
for e := uint64(1); e <= 10; e++ {
if err := l.Reserve(pruneKey(e), NonceBinding{Epoch: e}); err != ErrNonceReused {
t.Fatalf("pre-prune re-reserve epoch %d: got %v want ErrNonceReused", e, err)
}
}
// Prune everything strictly older than epoch 8 → drops epochs 1..7 (7 entries).
if dropped := l.PruneBefore(8); dropped != 7 {
t.Fatalf("PruneBefore(8) dropped %d, want 7", dropped)
}
// RETAINED window [8,10]: reuse is STILL refused — pruning must NOT reopen the
// live window (the nonce-reuse key-recovery vector stays closed).
for e := uint64(8); e <= 10; e++ {
if err := l.Reserve(pruneKey(e), NonceBinding{Epoch: e}); err != ErrNonceReused {
t.Fatalf("retained epoch %d reuse: got %v want ErrNonceReused — pruning REOPENED the live window!", e, err)
}
}
// PRUNED entries (1..7): the slots are freed, so they reserve fresh (memory was
// genuinely reclaimed). Acceptable: an ancient epoch's nonce is past finality
// and can no longer be usefully replayed on-chain.
for e := uint64(1); e <= 7; e++ {
if err := l.Reserve(pruneKey(e), NonceBinding{Epoch: e}); err != nil {
t.Fatalf("pruned epoch %d should reserve fresh after prune (slot not freed): %v", e, err)
}
}
// Idempotence / no-op: pruning below the floor drops nothing.
if dropped := l.PruneBefore(1); dropped != 0 {
t.Fatalf("PruneBefore(1) dropped %d, want 0 (epoch-0 entries only would qualify; none here)", dropped)
}
t.Logf("PASS: PruneBefore(8) freed 7 old entries; reuse within retained window [8,10] still refused (ErrNonceReused); pruned slots reclaimed")
}
// The registry sweep prunes EVERY per-share ledger; retained-window reuse stays
// refused on each. Asserted on this test's own share identities (the registry is
// process-global, so the global dropped count is only sanity-checked >=).
func TestNonceLedger_PruneShareLedgers_SweepsRegistry(t *testing.T) {
var idA, idB [32]byte
idA[0], idB[0] = 0x11, 0x22
la := shareLedgerFor(idA)
lb := shareLedgerFor(idB)
mk := func(seed byte, epoch uint64) ([32]byte, NonceBinding) {
var k [32]byte
k[0] = seed
binary.BigEndian.PutUint64(k[8:16], epoch)
return k, NonceBinding{Epoch: epoch}
}
// la: epochs 1, 2 (old) + 9 (retained); lb: epoch 3 (old).
for _, e := range []uint64{1, 2, 9} {
k, b := mk(0xA0, e)
if err := la.Reserve(k, b); err != nil {
t.Fatalf("la reserve epoch %d: %v", e, err)
}
}
if k, b := mk(0xB0, 3); lb.Reserve(k, b) != nil {
t.Fatalf("lb reserve epoch 3 failed")
}
// Sweep: prune < epoch 8 across the whole registry. At least MY 3 old entries
// (la{1,2} + lb{3}) drop; other tests' entries may add to the count.
if dropped := PruneShareLedgers(8); dropped < 3 {
t.Fatalf("PruneShareLedgers(8) dropped %d, want >= 3 (la{1,2}+lb{3})", dropped)
}
// la's retained epoch 9 is STILL refused after the sweep.
if k9, b9 := mk(0xA0, 9); la.Reserve(k9, b9) != ErrNonceReused {
t.Fatalf("retained epoch 9 reuse after sweep: want ErrNonceReused")
}
// la's pruned epoch 1 reserves fresh (freed by the sweep).
if k1, b1 := mk(0xA0, 1); la.Reserve(k1, b1) != nil {
t.Fatalf("pruned epoch 1 should reserve fresh after sweep")
}
// lb's pruned epoch 3 reserves fresh too.
if k3, b3 := mk(0xB0, 3); lb.Reserve(k3, b3) != nil {
t.Fatalf("lb pruned epoch 3 should reserve fresh after sweep")
}
t.Logf("PASS: PruneShareLedgers(8) swept the registry — old entries freed across per-share ledgers, retained-window reuse still refused (ErrNonceReused)")
}