mirror of
https://github.com/luxfi/pulsar.git
synced 2026-09-24 00:25:52 +00:00
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:
@@ -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)")
|
||||
}
|
||||
Reference in New Issue
Block a user