validator: node-bound proof of possession, one key per node

The forged certificate that reached crates.io was killed at verification, but a
red review showed the hole had only moved to registration: a key was admitted on
key_validate alone, which proves a point is on the curve and nothing about who
holds its secret, and duplicate keys were merged into one validator carrying many
node ids — so a floor written to require many distinct signers was cleared by one
holder registering one key under several ids.

This is the Go oracle for the standard ruled in luxfi/conformance: it defines the
bytes Rust and C++ reproduce.

validator/pop is the primitive. A registrant signs its own identity followed by
its own key — node(20, ids.NodeID) ‖ pubkey(48, compressed G1), 68 bytes, no
separator — under the proof-of-possession domain BLS_POP_..._POP_, distinct from
the vote's ..._NUL_ so a vote is never a proof and a proof never a vote. The node
is bound because the pubkey-only IETF proof travels with the key: bound to
nothing, it re-registers under any identity. 20 bytes because a Lux validator's
identity IS its NodeID; the 32-byte consensus Id names blocks, not nodes, and has
no canonical inverse, so widening would invent a second name for one node.

validator/registration is the admission rule, written once: encoding, then
possession, then uniqueness, then weight — a pairing on non-points is undefined,
and weight counted before uniqueness is weight counted twice. A set is admitted
whole or refused whole, because a set that silently drops a signer no longer
describes its own weight. FlattenValidatorSet's merge of duplicate keys is retired
here: it was the only thing that made many-nodes-one-key representable, and after
the uniqueness rule that state does not exist to normalize. Order and
PublicKeyBytes still match the upstream flatten exactly, so a proven set and a
flattened-distinct set are the same set.

engine/chain keys the cert store on the DECISION — the signed canonical identity
— not the transport envelope id, which no signature covers: two envelopes of one
decision were two store entries for one finality fact, and a verified cert could
be filed under a block it never attested. Keyed by decision, a cert is found by
anything that finalized that decision, and Put is idempotent per decision.

The pubkey-only P-chain proof (luxfi/node AddValidatorTx) is NOT touched here:
moving it node-bound is a network upgrade, every validator re-proves, and it is
activation-gated as the deliberate last step. Anywhere a validator set that
shares a key across node ids is admitted today, this now refuses it — that is the
intended behaviour, and the coordination point with the P-chain step.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-09-01 08:10:24 -07:00
co-authored by Hanzo Dev
parent 4c7db00125
commit af808b25ad
17 changed files with 1195 additions and 89 deletions
+45 -19
View File
@@ -138,33 +138,39 @@ func (t *Transitive) recoveredOuterAt(h uint64) (ids.ID, bool) {
}
// storeServedCertLocked records the marshaled finality cert for a just-finalized
// block so this node can serve it to a peer catching up. Called from the SOLE
// DECISION so this node can serve it to a peer catching up. Called from the SOLE
// finalizer (acceptWithCertCore) with the engine lock held, so EVERY finalize path
// — local assembly, an incoming gossiped cert, and the K==1 single-validator cert —
// captures its cert in this ONE place. Idempotent per block id; bounded to
// captures its cert in this ONE place. Idempotent per decision; bounded to
// maxServedCerts by oldest-height (== FIFO) eviction.
//
// The key is certCanonical(cert) — the identity the signatures cover — and not the
// outer envelope that carried it, so a peer holding a canonical-equivalent sibling
// wrapper finds the cert this node actually has. See CertStore for the whole
// argument; the in-memory window and the durable store are keyed alike, because a
// restart must not change which asks are answerable.
//
// The caller holds t.mu. The DURABLE copy is written separately, by
// persistServedCert, off the lock — see there for why.
func (t *Transitive) storeServedCertLocked(blockID ids.ID, certBytes []byte) {
func (t *Transitive) storeServedCertLocked(decision ids.ID, certBytes []byte) {
if len(certBytes) == 0 {
return
}
if t.certBytesByBlock == nil {
t.certBytesByBlock = make(map[ids.ID][]byte, maxServedCerts)
if t.certByDecision == nil {
t.certByDecision = make(map[ids.ID][]byte, maxServedCerts)
}
if _, exists := t.certBytesByBlock[blockID]; exists {
if _, exists := t.certByDecision[decision]; exists {
return
}
t.certBytesByBlock[blockID] = certBytes
t.certServedOrder = append(t.certServedOrder, blockID)
t.certByDecision[decision] = certBytes
t.certServedOrder = append(t.certServedOrder, decision)
// Evict the oldest finalized cert(s) once past the window. A single insert can
// only overflow by one, but loop defensively so the invariant holds even if the
// cap is lowered at runtime.
for len(t.certServedOrder) > maxServedCerts {
evict := t.certServedOrder[0]
t.certServedOrder = t.certServedOrder[1:]
delete(t.certBytesByBlock, evict)
delete(t.certByDecision, evict)
}
}
@@ -178,33 +184,53 @@ func (t *Transitive) storeServedCertLocked(blockID ids.ID, certBytes []byte) {
// is whether a crash loses the ability to SERVE this one height. That costs a
// straggler one fetch from another peer; it cannot cost safety, because nothing
// finalizes on a cert this node failed to write.
func (t *Transitive) persistServedCert(blockID ids.ID, height uint64, certBytes []byte) {
func (t *Transitive) persistServedCert(decision ids.ID, height uint64, certBytes []byte) {
t.mu.RLock()
certs := t.certs
t.mu.RUnlock()
if certs == nil || len(certBytes) == 0 {
return
}
if err := certs.Put(blockID, height, certBytes); err != nil && t.log != nil {
if err := certs.Put(decision, height, certBytes); err != nil && t.log != nil {
t.log.Warn("could not persist the finality cert — once this process exits, no peer can be caught up through this height",
log.Stringer("blockID", blockID),
log.Stringer("decision", decision),
log.Uint64("height", height),
log.Err(err))
}
}
// CertForBlock returns the marshaled α-of-K finality cert this node recorded when
// it finalized blockID, so the node can hand it to a peer catching up. ok is false
// when blockID is not finalized here, or its cert has aged out of the served window
// (the peer then fetches from another node, or bootstraps if it is too far behind).
// it finalized blk, so the node can hand it to a peer catching up. ok is false when
// blk is not finalized here, or its cert has aged out of the served window (the
// peer then fetches from another node, or bootstraps if it is too far behind).
//
// IT TAKES THE BLOCK, NOT AN ID, because the cert is filed under the DECISION and
// only the block knows its own: canonicalIDOf reads the inner execution commitment
// off it, degrading to the outer id on a bare chain. An id alone cannot be resolved
// — the engine drops a block's pending state at finality, and the durable store
// outlives the process — so asking for the block is asking for the one thing that
// can answer. Every caller already holds it: the serve path fetches the block to
// pair with the cert, and pairing a block with a cert filed under a DIFFERENT
// envelope of the same decision is exactly what this fixes.
//
// The returned bytes decode+verify to the SAME VerifiedQuorumCert every node
// finalized blockID on — serving it lets the peer finalize through its own
// finalized that decision on — serving it lets the peer finalize through its own
// HandleIncomingCert with no trust in this node. A defensive copy is returned so a
// caller cannot mutate the served buffer.
func (t *Transitive) CertForBlock(blockID ids.ID) ([]byte, bool) {
func (t *Transitive) CertForBlock(blk block.Block) ([]byte, bool) {
if blk == nil {
return nil, false
}
return t.certFor(canonicalIDOf(blk))
}
// certFor is the lookup itself, given the decision already resolved. It is the
// ONE place the two tiers are read in order — the process window, then the
// durable store — so a restart changes how far back an ask reaches and nothing
// else about how it is answered.
func (t *Transitive) certFor(decision ids.ID) ([]byte, bool) {
t.mu.RLock()
b, ok := t.certBytesByBlock[blockID]
b, ok := t.certByDecision[decision]
certs := t.certs
t.mu.RUnlock()
if ok {
@@ -216,7 +242,7 @@ func (t *Transitive) CertForBlock(blockID ids.ID) ([]byte, bool) {
if certs == nil {
return nil, false
}
return certs.Get(blockID)
return certs.Get(decision)
}
// VerifyCatchupCertificate verifies that certBytes is a portable quorum proof for
+2 -2
View File
@@ -405,7 +405,7 @@ func TestCatchup_CertForBlockServesWhatWasFinalized(t *testing.T) {
blk := buildGap(vm, tip, 1)[0] // N+1
// Before finalize: nothing to serve.
if _, ok := rt.CertForBlock(blk.id); ok {
if _, ok := rt.CertForBlock(blk); ok {
t.Fatal("CertForBlock returned a cert for an unfinalized block")
}
@@ -416,7 +416,7 @@ func TestCatchup_CertForBlockServesWhatWasFinalized(t *testing.T) {
// After finalize: the node serves a cert that decodes and verifies to the same
// finality witness, so a peer can finalize on it with zero trust in this node.
served, ok := rt.CertForBlock(blk.id)
served, ok := rt.CertForBlock(blk)
if !ok {
t.Fatal("CertForBlock did not serve the finalized block's cert")
}
+40 -30
View File
@@ -38,26 +38,36 @@ import (
"github.com/luxfi/ids"
)
// CertStore is the durable home for finality certs, keyed by the block each one
// finalizes. The engine writes one at the sole finalizer and reads one to serve
// CertStore is the durable home for finality certs, keyed by the DECISION each
// one proves. The engine writes one at the sole finalizer and reads one to serve
// a peer catching up.
//
// Put is idempotent per block. Get reports absence rather than error — a cert
// THE KEY IS THE SIGNED IDENTITY, NOT THE ENVELOPE. A cert's signatures cover the
// canonical execution commitment (chain.decision: the inner CanonicalID, or the
// block's own id on a bare chain that records none) — never the outer proposervm
// envelope that happened to carry it. Two envelopes wrapping the same inner block
// are the same decision and are collapsed to duplicates everywhere else in the
// engine; keyed by envelope, they were two store entries for one finality fact,
// and a peer that held the sibling wrapper asked for a cert this node had and was
// told no. Keyed by decision, the cert is found by anything that finalized the
// same thing, which is exactly the set of nodes it is valid for.
//
// Put is idempotent per decision. Get reports absence rather than error — a cert
// this node cannot produce is a cert the peer fetches elsewhere, which is the
// same handling either way.
type CertStore interface {
// Put durably records cert as the finality proof for blockID at height. The
// Put durably records cert as the finality proof for decision at height. The
// height orders eviction: the store keeps the most recent maxServedCerts and
// drops the lowest heights, so retention follows the chain rather than the
// order writes happened to arrive.
Put(blockID ids.ID, height uint64, cert []byte) error
// Get returns the cert recorded for blockID, or ok=false when this node never
Put(decision ids.ID, height uint64, cert []byte) error
// Get returns the cert recorded for decision, or ok=false when this node never
// finalized it, it has aged out of the window, or it cannot be read back.
Get(blockID ids.ID) ([]byte, bool)
Get(decision ids.ID) ([]byte, bool)
}
// fileCerts is the one concrete CertStore: a directory holding one file per
// cert, named "<height>-<blockID>" with the height zero-padded so lexical order
// cert, named "<height>-<decision>" with the height zero-padded so lexical order
// IS height order. That name carries everything the store needs, which is why
// there is no index file — nothing to fall out of step with the directory, and
// nothing whose corruption would lose certs that are sitting right there.
@@ -67,9 +77,9 @@ type CertStore interface {
type fileCerts struct {
dir string
mu sync.Mutex
order []certSlot // ascending height — the eviction order
byBlock map[ids.ID]string // blockID -> file name
mu sync.Mutex
order []certSlot // ascending height — the eviction order
byDecision map[ids.ID]string // decision -> file name
}
type certSlot struct {
@@ -81,11 +91,11 @@ type certSlot struct {
// numeric sort by height. 20 digits covers the full uint64 range.
const certNameWidth = 20
func certName(height uint64, blockID ids.ID) string {
return fmt.Sprintf("%0*d-%s", certNameWidth, height, blockID)
func certName(height uint64, decision ids.ID) string {
return fmt.Sprintf("%0*d-%s", certNameWidth, height, decision)
}
// parseCertName recovers (height, blockID) from a file name written by certName.
// parseCertName recovers (height, decision) from a file name written by certName.
// A name that does not round-trip is not ours; the caller ignores the file rather
// than deleting it, so an operator's notes in the directory survive.
func parseCertName(name string) (uint64, ids.ID, bool) {
@@ -97,11 +107,11 @@ func parseCertName(name string) (uint64, ids.ID, bool) {
if err != nil {
return 0, ids.Empty, false
}
blockID, err := ids.FromString(name[dash+1:])
decision, err := ids.FromString(name[dash+1:])
if err != nil {
return 0, ids.Empty, false
}
return height, blockID, true
return height, decision, true
}
// OpenCerts opens (or creates) the durable cert store at dir and recovers the
@@ -112,7 +122,7 @@ func OpenCerts(dir string) (CertStore, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("create cert store %s: %w", dir, err)
}
c := &fileCerts{dir: dir, byBlock: map[ids.ID]string{}}
c := &fileCerts{dir: dir, byDecision: map[ids.ID]string{}}
entries, err := os.ReadDir(dir)
if err != nil {
@@ -122,48 +132,48 @@ func OpenCerts(dir string) (CertStore, error) {
if e.IsDir() {
continue
}
height, blockID, ok := parseCertName(e.Name())
height, decision, ok := parseCertName(e.Name())
if !ok {
continue
}
c.order = append(c.order, certSlot{height: height, id: blockID})
c.byBlock[blockID] = e.Name()
c.order = append(c.order, certSlot{height: height, id: decision})
c.byDecision[decision] = e.Name()
}
sort.Slice(c.order, func(i, j int) bool { return c.order[i].height < c.order[j].height })
c.evictLocked()
return c, nil
}
func (c *fileCerts) Put(blockID ids.ID, height uint64, cert []byte) error {
func (c *fileCerts) Put(decision ids.ID, height uint64, cert []byte) error {
if len(cert) == 0 {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
if _, exists := c.byBlock[blockID]; exists {
if _, exists := c.byDecision[decision]; exists {
return nil
}
name := certName(height, blockID)
name := certName(height, decision)
if err := writeFileAtomic(filepath.Join(c.dir, name), c.dir, cert); err != nil {
return fmt.Errorf("write cert %s: %w", name, err)
}
c.byBlock[blockID] = name
c.byDecision[decision] = name
// Finality is monotonic, so appending keeps order ascending on the hot path.
// A cert arriving out of order (a re-delivered older height) is rare enough
// that re-sorting it into place costs nothing and keeps eviction honest.
if n := len(c.order); n > 0 && c.order[n-1].height > height {
c.order = append(c.order, certSlot{height: height, id: blockID})
c.order = append(c.order, certSlot{height: height, id: decision})
sort.Slice(c.order, func(i, j int) bool { return c.order[i].height < c.order[j].height })
} else {
c.order = append(c.order, certSlot{height: height, id: blockID})
c.order = append(c.order, certSlot{height: height, id: decision})
}
c.evictLocked()
return nil
}
func (c *fileCerts) Get(blockID ids.ID) ([]byte, bool) {
func (c *fileCerts) Get(decision ids.ID) ([]byte, bool) {
c.mu.Lock()
name, ok := c.byBlock[blockID]
name, ok := c.byDecision[decision]
c.mu.Unlock()
if !ok {
return nil, false
@@ -182,11 +192,11 @@ func (c *fileCerts) evictLocked() {
for len(c.order) > maxServedCerts {
ev := c.order[0]
c.order = c.order[1:]
name, ok := c.byBlock[ev.id]
name, ok := c.byDecision[ev.id]
if !ok {
continue
}
delete(c.byBlock, ev.id)
delete(c.byDecision, ev.id)
_ = os.Remove(filepath.Join(c.dir, name))
}
}
+5 -5
View File
@@ -23,7 +23,7 @@ func blockAt(n int) ids.ID {
// TestCertOutlivesTheProcess pins the property the store exists for: a height
// this node decided stays serveable to a peer catching up after the node
// restarts. The engine that finalized the block is thrown away between the write
// and the read, which is what a restart does to certBytesByBlock.
// and the read, which is what a restart does to certByDecision.
func TestCertOutlivesTheProcess(t *testing.T) {
dir := t.TempDir()
blockID, cert := blockAt(1), []byte("finality-cert-for-15499")
@@ -40,7 +40,7 @@ func TestCertOutlivesTheProcess(t *testing.T) {
finalizer.mu.Unlock()
finalizer.persistServedCert(blockID, 15499, cert)
if _, ok := finalizer.CertForBlock(blockID); !ok {
if _, ok := finalizer.certFor(blockID); !ok {
t.Fatal("the finalizing engine cannot serve the cert it just wrote")
}
@@ -51,7 +51,7 @@ func TestCertOutlivesTheProcess(t *testing.T) {
}
restarted := New(WithCerts(reopened))
got, ok := restarted.CertForBlock(blockID)
got, ok := restarted.certFor(blockID)
if !ok {
t.Fatal("cert did not survive the restart — a peer whose gap predates our restart could never be served its next block")
}
@@ -71,11 +71,11 @@ func TestCertsWithoutAStoreDieWithTheProcess(t *testing.T) {
finalizer.storeServedCertLocked(blockID, cert)
finalizer.mu.Unlock()
finalizer.persistServedCert(blockID, 7, cert) // no store wired: a no-op
if _, ok := finalizer.CertForBlock(blockID); !ok {
if _, ok := finalizer.certFor(blockID); !ok {
t.Fatal("in-process serve must still work without a store")
}
if _, ok := New().CertForBlock(blockID); ok {
if _, ok := New().certFor(blockID); ok {
t.Fatal("a memory-only engine must not claim a cert it never assembled")
}
}
+8 -4
View File
@@ -103,14 +103,18 @@ func (rt *Runtime) From(ctx context.Context, height uint64, max int) (Run, error
if !ok {
break
}
cert, ok := rt.Transitive.CertForBlock(id)
if !ok {
break
}
// The block comes first: the cert is filed under the DECISION its signatures
// cover, and the block is what names it (canonicalIDOf, inside CertForBlock).
// Asking by the outer id from the recovery index would miss a cert this node
// holds under a sibling wrapper of the same inner block.
blk, err := rt.config.VM.GetBlock(ctx, id)
if err != nil || blk == nil {
break
}
cert, ok := rt.Transitive.CertForBlock(blk)
if !ok {
break
}
run.Chain = append(run.Chain, Certified{Block: blk.Bytes(), Cert: cert})
}
+3 -3
View File
@@ -40,10 +40,10 @@ func servingNode(t *testing.T, count int) (*Runtime, []ids.ID) {
rt.Transitive.recoveredAt = map[uint64]ids.ID{}
}
rt.Transitive.recoveredAt[uint64(h)] = blk.id
if rt.Transitive.certBytesByBlock == nil {
rt.Transitive.certBytesByBlock = map[ids.ID][]byte{}
if rt.Transitive.certByDecision == nil {
rt.Transitive.certByDecision = map[ids.ID][]byte{}
}
rt.Transitive.certBytesByBlock[blk.id] = []byte{byte(h)}
rt.Transitive.certByDecision[blk.id] = []byte{byte(h)}
rt.Transitive.mu.Unlock()
ids_ = append(ids_, blk.id)
}
+5 -5
View File
@@ -16,15 +16,15 @@ import (
func descentAt(from uint64, n int) (*Runtime, []ids.ID) {
vm := &mockVM{blocks: map[ids.ID]*mockBlock{}}
e := &Transitive{
certBytesByBlock: map[ids.ID][]byte{},
recoveredAt: map[uint64]ids.ID{},
certByDecision: map[ids.ID][]byte{},
recoveredAt: map[uint64]ids.ID{},
}
ids_ := make([]ids.ID, 0, n)
for i := 0; i < n; i++ {
h := from + uint64(i)
id := ids.GenerateTestID()
vm.blocks[id] = &mockBlock{id: id, height: h, bytes: []byte{byte(h)}}
e.certBytesByBlock[id] = []byte{'c', byte(h)}
e.certByDecision[id] = []byte{'c', byte(h)}
e.recoveredAt[h] = id
ids_ = append(ids_, id)
}
@@ -70,7 +70,7 @@ func TestDescentStopsAtTheFirstHoleRatherThanSkippingIt(t *testing.T) {
// 104 is finalized; 103 is not.
orphan := ids.GenerateTestID()
rt.config.VM.(*mockVM).blocks[orphan] = &mockBlock{id: orphan, height: 104, bytes: []byte{104}}
rt.Transitive.certBytesByBlock[orphan] = []byte{'c', 104}
rt.Transitive.certByDecision[orphan] = []byte{'c', 104}
rt.Transitive.recoveredAt[104] = orphan
run, err := rt.From(context.Background(), 100, 10)
@@ -91,7 +91,7 @@ func TestDescentStopsAtTheFirstHoleRatherThanSkippingIt(t *testing.T) {
// progress.
func TestDescentWithoutTheCertServesNothingAtThatHeight(t *testing.T) {
rt, idsAt := descentAt(100, 3)
delete(rt.Transitive.certBytesByBlock, idsAt[1]) // height 101 loses its cert
delete(rt.Transitive.certByDecision, idsAt[1]) // height 101 loses its cert
run, err := rt.From(context.Background(), 100, 10)
if err != nil {
+25 -11
View File
@@ -544,22 +544,25 @@ type Transitive struct {
// alongside the slashing window if retention ever matters.
finalizedByCert map[ids.ID]struct{}
// certBytesByBlock persists the marshaled finality cert for each block this node
// certByDecision persists the marshaled finality cert for each DECISION this node
// finalized, so it can SERVE that cert to a peer catching up (CertForBlock). It
// is written at the SOLE finalizer (acceptWithCertCore), so every finalize path
// captures its cert in ONE place. certServedOrder is the companion FIFO of block
// ids in finalize (== ascending height) order, used to evict the oldest cert once
// the store passes maxServedCerts — a bounded sliding window, never an unbounded
// map. A node lagging beyond the window bootstraps instead of catching up.
// certs is the DURABLE backing for the same evidence. certBytesByBlock above is
// captures its cert in ONE place. The key is the identity the signatures cover
// (certCanonical), not the envelope that carried them, so two wrappers of one
// inner block are one entry and a peer holding either one is answered.
// certServedOrder is the companion FIFO of those ids in finalize (== ascending
// height) order, used to evict the oldest cert once the store passes
// maxServedCerts — a bounded sliding window, never an unbounded map. A node
// lagging beyond the window bootstraps instead of catching up.
// certs is the DURABLE backing for the same evidence. certByDecision above is
// a process-lifetime read cache in front of it; certs is what survives a restart,
// and therefore what decides whether a peer that fell behind can ever be served
// its next block. nil keeps certs memory-only — correct for a single-validator or
// test engine, and a liveness hazard on a real fleet (see cert_store.go).
certs CertStore
certBytesByBlock map[ids.ID][]byte
certServedOrder []ids.ID
certByDecision map[ids.ID][]byte
certServedOrder []ids.ID
// recoveredAt names, per finalized HEIGHT, the outer block id this node finalized
// there — the deep index catch-up replay reads when the ledger's own byHeight has
@@ -917,7 +920,7 @@ func NewWithConfig(cfg Config, opts ...Option) *Transitive {
pendingBlocks: make(map[ids.ID]*PendingBlock),
pendingOwnProposals: make(map[ProposalKey]*PendingBlock),
finalizedByCert: make(map[ids.ID]struct{}),
certBytesByBlock: make(map[ids.ID][]byte),
certByDecision: make(map[ids.ID][]byte),
committedSlot: make(map[SlotKey]ids.ID),
catchupRequested: make(map[ids.ID]time.Time),
certCatchupRequested: make(map[uint64]time.Time),
@@ -4392,11 +4395,22 @@ func (t *Transitive) applyBranchFinalization(ctx context.Context, plan Plan, cer
// certRejected=0 that kept lux-mainnet halted with every node holding data and
// none able to catch up — and it only bites after a restart, when the
// in-memory window that had been covering for it is gone.
//
// The cert is filed under the DECISION its signatures cover, never under
// certifiedTip — the outer envelope this call happened to finalize. The two
// differ exactly when a proposervm wraps an inner block, which is every block on
// a live chain, and the difference is not cosmetic: HandleIncomingCert already
// resolves an incoming cert ACROSS a differing envelope, so two honest nodes can
// finalize one decision under sibling wrappers. Filed by envelope, each stored a
// cert the other could not find, and a straggler holding either wrapper was told
// no by a node that had the proof. Filed by decision, one entry answers both.
var servedCert []byte
var servedHeight uint64
var servedDecision ids.ID
if qc := cert.Cert(); qc != nil {
if b, err := qc.MarshalBinary(); err == nil {
t.storeServedCertLocked(certifiedTip, b)
servedDecision = certCanonical(qc)
t.storeServedCertLocked(servedDecision, b)
servedCert = b
servedHeight = qc.Position.Height
}
@@ -4404,7 +4418,7 @@ func (t *Transitive) applyBranchFinalization(ctx context.Context, plan Plan, cer
t.mu.Unlock()
// Both of these are carried out of the critical section on purpose: Reject calls
// into the VM, and persisting the cert fsyncs. Neither belongs under the engine lock.
t.persistServedCert(certifiedTip, servedHeight, servedCert)
t.persistServedCert(servedDecision, servedHeight, servedCert)
for _, vmb := range toReject {
_ = vmb.Reject(ctx)
}
+6 -4
View File
@@ -73,16 +73,18 @@ func (c *busCatchup) RequestAncestors(_ ids.ID, _ ids.ID, _ ids.ID, _ ids.NodeID
if !ok {
continue
}
certBytes, ok := server.rt.Transitive.CertForBlock(id)
if !ok {
continue // cert aged out of the served window — a real node bootstraps instead
}
server.vm.mu.Lock()
blk := server.vm.blockByID[id]
server.vm.mu.Unlock()
if blk == nil {
continue
}
// The block first: a cert is filed under the decision it proves, and the
// block is what names it.
certBytes, ok := server.rt.Transitive.CertForBlock(blk)
if !ok {
continue // cert aged out of the served window — a real node bootstraps instead
}
if !self.reachable() {
return
}
+1 -1
View File
@@ -28,6 +28,7 @@ require (
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_model v0.6.2
github.com/stretchr/testify v1.11.1
github.com/supranational/blst v0.3.16
go.uber.org/mock v0.6.0
golang.org/x/crypto v0.52.0
)
@@ -132,7 +133,6 @@ require (
github.com/prometheus/procfs v0.20.1 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/supranational/blst v0.3.16 // indirect
github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/x448/float16 v0.8.4 // indirect
+21
View File
@@ -74,6 +74,14 @@ var (
// over by dedup.
ErrWVSetDuplicateID = errors.New("quasar: duplicate validator_id in weighted validator set")
// ErrWVSetDuplicateKey is returned when two leaves share a public key.
// One key belongs to at most one validator: counting distinct voters has
// to count distinct SIGNERS, and a set that lets one holder appear under
// several validator_ids clears a signer floor written to require several
// holders. Refused at build, alongside the duplicate-identity check —
// neither implies the other, and the set has to be a set on both axes.
ErrWVSetDuplicateKey = errors.New("quasar: duplicate public key in weighted validator set")
// ErrWVSetZeroWeight is returned when a leaf carries zero voting weight.
// A zero-weight signer contributes nothing to a quorum and is almost
// always a misconfiguration; refusing it closes a silent
@@ -278,6 +286,19 @@ func BuildWeightedValidatorSet(epoch uint64, leaves []WeightedValidatorLeaf) (*W
}
}
// Reject a public key that appears under more than one validator_id. Keys
// are not adjacent after a sort by identity, so this pass carries a map;
// it runs over the SORTED slice so the pair a set is refused on is the same
// on every node.
byKey := make(map[string]int, len(sorted))
for i := range sorted {
if first, dup := byKey[string(sorted[i].PublicKey)]; dup {
return nil, fmt.Errorf("%w: %x and %x", ErrWVSetDuplicateKey,
sorted[first].ValidatorID[:], sorted[i].ValidatorID[:])
}
byKey[string(sorted[i].PublicKey)] = i
}
leafHashes := make([][48]byte, len(sorted))
count := uint32(len(sorted))
for i := range sorted {
+23
View File
@@ -4,6 +4,7 @@
package quasar
import (
"errors"
"testing"
)
@@ -108,6 +109,28 @@ func TestWeightedMerkle_RejectsBadInput(t *testing.T) {
}
}
// TestWeightedMerkle_OneKeyOneValidator is the second registration rule on this
// tree. Distinct identities were already refused; distinct KEYS were not, so one
// holder could appear as several validators, each with its own weight, and a
// quorum written to require several holders was satisfied by one. Neither check
// implies the other — these leaves have different identities and pass the first.
func TestWeightedMerkle_OneKeyOneValidator(t *testing.T) {
a, b := mkLeaf(0x01, 10, 0x42, 1), mkLeaf(0x02, 20, 0x42, 1)
b.PublicKey = append([]byte(nil), a.PublicKey...)
if _, err := BuildWeightedValidatorSet(7, []WeightedValidatorLeaf{a, b}); !errors.Is(err, ErrWVSetDuplicateKey) {
t.Fatalf("one key under two validator_ids: got %v, want ErrWVSetDuplicateKey", err)
}
// Order must not decide the answer: every node builds the same set.
if _, err := BuildWeightedValidatorSet(7, []WeightedValidatorLeaf{b, a}); !errors.Is(err, ErrWVSetDuplicateKey) {
t.Fatalf("reversed: got %v, want ErrWVSetDuplicateKey", err)
}
// Distinct keys still build.
if _, err := BuildWeightedValidatorSet(7, []WeightedValidatorLeaf{mkLeaf(0x01, 10, 0x42, 1), mkLeaf(0x02, 20, 0x42, 1)}); err != nil {
t.Fatalf("a set that obeys the rule was refused: %v", err)
}
}
func TestWeightedMerkle_InclusionSoundness(t *testing.T) {
// Every leaf at every set size must produce a verifying inclusion proof,
// and a NON-member must be rejected. Exercise sizes 1..9 to cover both
+186
View File
@@ -0,0 +1,186 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package pop is the proof a registrant gives that it holds the key it is
// registering — AND that the key names the node it is registering it for.
//
// THE PUBKEY-ONLY PROOF IS NOT ENOUGH. The IETF proof of possession signs the
// public key under its own ciphersuite; it proves someone holds the secret, and
// nothing else. It is a statement about a key, so it travels with the key: an
// honest validator publishes its key and its proof, and anyone can re-register
// that pair under a SECOND node identity, because the proof never mentions the
// first. Counting signers then counts identities the key holder does not have,
// and a floor written to require many distinct signers is cleared by one.
//
// Binding the node closes it. The registrant signs its own identity followed by
// its own key, so a proof is only valid for the one pair it was made for. Lifting
// the key to another identity needs a proof over that identity, which needs the
// secret.
//
// THE MESSAGE, byte for byte:
//
// offset 0 .. 19 node — ids.NodeID, 20 bytes
// offset 20 .. 67 key — compressed G1 pubkey, 48 bytes
// total 68 bytes
//
// In that order, with no separator and no length prefix. Both fields are fixed
// width, so the concatenation reads back apart into exactly the pair that made
// it — the ambiguity that lengths exist to prevent (see the FPC seed preimage,
// where the fields are variable width) cannot arise here.
//
// WHY 20 BYTES OF NODE. The registered identity of a Lux validator IS its
// ids.NodeID: 20 bytes, the value AddValidatorTx names, the value a cert's votes
// carry, the value slashing evidence keys on, and the value the validator set
// maps. A proof that bound anything else would bind something the chain does not
// use to decide who signed. The consensus Id is 32 bytes and names a block or a
// chain, never a node; there is no canonical NodeID -> Id direction to borrow
// (ids.ID.ShortID() truncates 32 down to 20, and nothing defines the inverse), so
// widening to 32 would mean inventing a second identity for a node that already
// has one. Two names for one thing is what forks. The raw 20 bytes are bound —
// not the typed form (ids.TypedNodeID, scheme byte ‖ 20), because the scheme byte
// is a transport annotation and the 20 bytes are what the set and the certs carry.
//
// THE CIPHERSUITE is BLS12-381 min_pk with the proof-of-possession domain:
//
// DST BLS_POP_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_
// pubkey compressed G1, 48 bytes
// proof compressed G2, 96 bytes
//
// The _POP_ tag, never the vote's ..._NUL_. Separate domains are what keep a vote
// from being replayed as a proof and a proof from being replayed as a vote: the
// two hash the same bytes onto different points, so a signature under one is
// noise under the other. Verify enforces the order the standard fixes —
// encoding, then possession — and the caller enforces uniqueness after it.
//
// This package is the oracle: Rust and C++ reproduce these bytes, and
// vectors/pop.json in luxfi/conformance freezes them.
package pop
import (
"bytes"
"errors"
"fmt"
"github.com/luxfi/crypto/bls"
"github.com/luxfi/ids"
)
const (
// NodeLen is the width of the node identity in the message: ids.NodeID.
NodeLen = ids.NodeIDLen
// KeyLen is the width of the public key in the message: BLS12-381 min_pk,
// compressed G1.
KeyLen = bls.PublicKeyLen
// ProofLen is the width of the proof itself: BLS12-381 min_pk, compressed G2.
ProofLen = bls.SignatureLen
// MessageLen is the whole preimage: node ‖ key, both fixed width.
MessageLen = NodeLen + KeyLen
// DST is the domain separation tag the proof is signed under: the IETF
// proof-of-possession ciphersuite, distinct from the vote's ..._NUL_. Named
// here so the standard is readable at the place it is enforced; asserted
// against the library's own constant by the tests, and frozen by the golden
// proof in vectors/pop.json, which moves if the domain ever does.
DST = "BLS_POP_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_"
)
var (
// ErrKey is returned when the public key is not a canonical compressed
// BLS12-381 G1 point: wrong width, non-canonical encoding, off-curve, outside
// the prime-order subgroup, or the identity.
ErrKey = errors.New("pop: public key is not a canonical compressed BLS12-381 G1 point")
// ErrProof is returned when the proof is not a canonical compressed
// BLS12-381 G2 point, by the same measures.
ErrProof = errors.New("pop: proof is not a canonical compressed BLS12-381 G2 point")
// ErrPossession is returned when the bytes decode but the proof does not
// verify: it was made for a different node, for a different key, under a
// different domain, or by someone who does not hold the secret. All four are
// one answer — this key is not admitted under this identity.
ErrPossession = errors.New("pop: proof does not bind this node to this key")
// ErrSecretKey is returned when there is no secret key to prove with.
ErrSecretKey = errors.New("pop: no secret key")
)
// Message returns the exact bytes a node-bound proof signs: node ‖ key, 68 bytes.
// The key must already be a canonical compressed G1 encoding; Message does not
// decode it, because it is called on the signing side from a key this node just
// serialized and on the verifying side from a key Verify has already decoded.
func Message(node ids.NodeID, key []byte) ([]byte, error) {
if len(key) != KeyLen {
return nil, fmt.Errorf("%w: got %d bytes, want %d", ErrKey, len(key), KeyLen)
}
msg := make([]byte, 0, MessageLen)
msg = append(msg, node[:]...)
msg = append(msg, key...)
return msg, nil
}
// Sign produces this node's proof for its own key: the 96-byte compressed G2
// signature over Message(node, key) under the proof-of-possession domain. BLS
// signing is deterministic, so one (secret key, node) pair has exactly one proof
// — which is what lets a vector freeze it.
func Sign(sk *bls.SecretKey, node ids.NodeID) ([]byte, error) {
if sk == nil {
return nil, ErrSecretKey
}
key := bls.PublicKeyToCompressedBytes(sk.PublicKey())
msg, err := Message(node, key)
if err != nil {
return nil, err
}
sig, err := sk.SignProofOfPossession(msg)
if err != nil {
return nil, fmt.Errorf("pop: sign: %w", err)
}
return bls.SignatureToBytes(sig), nil
}
// Verify admits (node, key) if and only if proof is a node-bound proof of
// possession for exactly that pair.
//
// The order is the standard's: ENCODING first — both points are decoded, which
// rejects wrong widths, non-canonical encodings, off-curve points, points outside
// the prime-order subgroup, and the identity — and only then POSSESSION, the
// pairing check. Nothing is admitted on a point that was never a point, and no
// pairing is computed on one either.
//
// The key is required to be the canonical compressed encoding of the point it
// decodes to, so the 48 bytes that go into the message are not a choice the
// registrant gets to make. Uniqueness — one key, one node — is the caller's
// check, because it is a property of the set and not of this pair.
func Verify(node ids.NodeID, key, proof []byte) error {
if len(key) != KeyLen {
return fmt.Errorf("%w: got %d bytes, want %d", ErrKey, len(key), KeyLen)
}
if len(proof) != ProofLen {
return fmt.Errorf("%w: got %d bytes, want %d", ErrProof, len(proof), ProofLen)
}
pk, err := bls.PublicKeyFromCompressedBytes(key)
if err != nil {
return fmt.Errorf("%w: %w", ErrKey, err)
}
// One point, one encoding. A decoder that accepted a second spelling of the
// same key would let a registrant choose which 48 bytes the message carries,
// and a proof over one spelling would not verify against the other.
if canonical := bls.PublicKeyToCompressedBytes(pk); !bytes.Equal(canonical, key) {
return fmt.Errorf("%w: encoding is not canonical", ErrKey)
}
sig, err := bls.SignatureFromBytes(proof)
if err != nil {
return fmt.Errorf("%w: %w", ErrProof, err)
}
msg, err := Message(node, key)
if err != nil {
return err
}
if !bls.VerifyProofOfPossession(pk, sig, msg) {
return ErrPossession
}
return nil
}
+275
View File
@@ -0,0 +1,275 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pop
import (
"bytes"
"testing"
"github.com/luxfi/crypto/bls"
"github.com/luxfi/ids"
)
// key returns a deterministic secret key, so a failure is reproducible and the
// bytes a test asserts are the bytes a vector freezes.
func key(t *testing.T, seed byte) *bls.SecretKey {
t.Helper()
s := make([]byte, 32)
for i := range s {
s[i] = seed
}
sk, err := bls.SecretKeyFromSeed(s)
if err != nil {
t.Fatalf("SecretKeyFromSeed(%d): %v", seed, err)
}
return sk
}
func node(b byte) ids.NodeID {
var n ids.NodeID
for i := range n {
n[i] = b
}
return n
}
// TestTheMessageIsNodeThenKey pins the layout the standard fixes: 20 bytes of
// node then 48 bytes of key, in that order, nothing between them and nothing
// around them.
func TestTheMessageIsNodeThenKey(t *testing.T) {
n := node(0x11)
pk := bls.PublicKeyToCompressedBytes(key(t, 1).PublicKey())
msg, err := Message(n, pk)
if err != nil {
t.Fatalf("Message: %v", err)
}
if len(msg) != MessageLen || MessageLen != 68 {
t.Fatalf("message is %d bytes (MessageLen=%d), want 68", len(msg), MessageLen)
}
if NodeLen != 20 || KeyLen != 48 || ProofLen != 96 {
t.Fatalf("widths moved: node=%d key=%d proof=%d, want 20/48/96", NodeLen, KeyLen, ProofLen)
}
if !bytes.Equal(msg[:NodeLen], n[:]) {
t.Fatal("bytes 0..19 are not the node identity")
}
if !bytes.Equal(msg[NodeLen:], pk) {
t.Fatal("bytes 20..67 are not the compressed public key")
}
}
// TestTheDomainIsTheProofSuite holds the DST this package names to the one the
// crypto library calls the proof-of-possession ciphersuite. Naming it twice is
// how a domain drifts; this is the assertion that keeps the two the same string.
func TestTheDomainIsTheProofSuite(t *testing.T) {
if got := bls.CiphersuiteProofOfPossession.String(); got != DST {
t.Fatalf("DST is not the library's proof suite:\n got %q\nwant %q", got, DST)
}
if DST != "BLS_POP_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_" {
t.Fatalf("the proof domain moved: %q", DST)
}
}
// TestAProofVerifiesForItsOwnPair is the round trip, and the determinism the
// vector depends on: BLS signing carries no nonce, so one (key, node) pair has
// exactly one proof.
func TestAProofVerifiesForItsOwnPair(t *testing.T) {
sk := key(t, 7)
n := node(0x22)
pk := bls.PublicKeyToCompressedBytes(sk.PublicKey())
proof, err := Sign(sk, n)
if err != nil {
t.Fatalf("Sign: %v", err)
}
if len(proof) != ProofLen {
t.Fatalf("proof is %d bytes, want %d", len(proof), ProofLen)
}
if err := Verify(n, pk, proof); err != nil {
t.Fatalf("a proof does not verify for the pair it was made for: %v", err)
}
again, err := Sign(sk, n)
if err != nil {
t.Fatalf("Sign again: %v", err)
}
if !bytes.Equal(proof, again) {
t.Fatal("signing is not deterministic — a vector could not freeze this")
}
}
// TestAProofCannotBeLifted is the whole reason the node is in the message. The
// pubkey-only proof is a statement about a key, so it travels with the key and
// re-registers under any identity. This one does not.
func TestAProofCannotBeLifted(t *testing.T) {
sk := key(t, 9)
pk := bls.PublicKeyToCompressedBytes(sk.PublicKey())
mine, theirs := node(0x01), node(0x02)
proof, err := Sign(sk, mine)
if err != nil {
t.Fatalf("Sign: %v", err)
}
if err := Verify(mine, pk, proof); err != nil {
t.Fatalf("own pair: %v", err)
}
if err := Verify(theirs, pk, proof); err == nil {
t.Fatal("a proof made for one node verified under another — the key is not bound")
}
// One bit of the node is enough.
near := mine
near[NodeLen-1] ^= 0x01
if err := Verify(near, pk, proof); err == nil {
t.Fatal("a one-bit change to the node identity still verified")
}
}
// TestThePubkeyOnlyProofIsRefused records what this supersedes: the IETF proof
// signs the public key alone, and that is exactly the proof an attacker already
// holds for every honest validator's published key.
func TestThePubkeyOnlyProofIsRefused(t *testing.T) {
sk := key(t, 11)
n := node(0x33)
pk := bls.PublicKeyToCompressedBytes(sk.PublicKey())
sig, err := sk.SignProofOfPossession(pk) // the old message: the key, and nothing else
if err != nil {
t.Fatalf("SignProofOfPossession: %v", err)
}
if err := Verify(n, pk, bls.SignatureToBytes(sig)); err == nil {
t.Fatal("a pubkey-only proof was accepted as node-bound")
}
}
// TestTheDomainsDoNotCross is domain separation, checked in both directions: a
// vote over the proof's bytes is not a proof, and a proof is not a vote.
func TestTheDomainsDoNotCross(t *testing.T) {
sk := key(t, 13)
n := node(0x44)
pk := bls.PublicKeyToCompressedBytes(sk.PublicKey())
msg, err := Message(n, pk)
if err != nil {
t.Fatalf("Message: %v", err)
}
vote, err := sk.Sign(msg) // the vote domain, ..._NUL_
if err != nil {
t.Fatalf("Sign: %v", err)
}
if err := Verify(n, pk, bls.SignatureToBytes(vote)); err == nil {
t.Fatal("a vote over the proof's message was accepted as a proof")
}
proof, err := Sign(sk, n)
if err != nil {
t.Fatalf("Sign: %v", err)
}
sig, err := bls.SignatureFromBytes(proof)
if err != nil {
t.Fatalf("SignatureFromBytes: %v", err)
}
pub, err := bls.PublicKeyFromCompressedBytes(pk)
if err != nil {
t.Fatalf("PublicKeyFromCompressedBytes: %v", err)
}
if bls.Verify(pub, sig, msg) {
t.Fatal("a proof verified as a vote — the two domains are not separated")
}
}
// TestAProofIsNotAKeysToo: a proof made by one key must not admit another.
func TestAProofDoesNotTravelToAnotherKey(t *testing.T) {
mine, other := key(t, 21), key(t, 22)
n := node(0x55)
proof, err := Sign(mine, n)
if err != nil {
t.Fatalf("Sign: %v", err)
}
otherPK := bls.PublicKeyToCompressedBytes(other.PublicKey())
if err := Verify(n, otherPK, proof); err == nil {
t.Fatal("a proof made by one key admitted another")
}
}
// TestMalformedInputIsRefusedBeforeAnyPairing walks the encoding gate: every
// shape that is not a canonical point is an ErrKey/ErrProof, not a pairing.
func TestMalformedInputIsRefusedBeforeAnyPairing(t *testing.T) {
sk := key(t, 31)
n := node(0x66)
pk := bls.PublicKeyToCompressedBytes(sk.PublicKey())
proof, err := Sign(sk, n)
if err != nil {
t.Fatalf("Sign: %v", err)
}
garbageKey := make([]byte, KeyLen)
for i := range garbageKey {
garbageKey[i] = 0xAB
}
garbageProof := make([]byte, ProofLen)
for i := range garbageProof {
garbageProof[i] = 0xCD
}
// The compressed encoding of the identity: valid bytes, and never a validator.
identityKey := make([]byte, KeyLen)
identityKey[0] = 0xC0
identityProof := make([]byte, ProofLen)
identityProof[0] = 0xC0
// The malformed "infinity set, compression clear" encoding, which is a panic
// in a raw decoder and must be an error here.
trapKey := make([]byte, KeyLen)
trapKey[0] = 0x40
trapProof := make([]byte, ProofLen)
trapProof[0] = 0x40
for _, c := range []struct {
name string
key, proof []byte
}{
{"no key", nil, proof},
{"short key", pk[:KeyLen-1], proof},
{"long key", append(append([]byte{}, pk...), 0), proof},
{"garbage key", garbageKey, proof},
{"identity key", identityKey, proof},
{"infinity-bit trap key", trapKey, proof},
{"no proof", pk, nil},
{"empty proof", pk, []byte{}},
{"short proof", pk, proof[:ProofLen-1]},
{"long proof", pk, append(append([]byte{}, proof...), 0)},
{"garbage proof", pk, garbageProof},
{"identity proof", pk, identityProof},
{"infinity-bit trap proof", pk, trapProof},
{"zero proof", pk, make([]byte, ProofLen)},
{"zero key", make([]byte, KeyLen), proof},
} {
if err := Verify(n, c.key, c.proof); err == nil {
t.Fatalf("%s was admitted", c.name)
}
}
}
// TestOneBitOfTheProofIsEnough: the proof is not a checksum.
func TestFlippingTheProofRefusesIt(t *testing.T) {
sk := key(t, 41)
n := node(0x77)
pk := bls.PublicKeyToCompressedBytes(sk.PublicKey())
proof, err := Sign(sk, n)
if err != nil {
t.Fatalf("Sign: %v", err)
}
for _, i := range []int{ProofLen - 1, ProofLen / 2} {
bad := append([]byte{}, proof...)
bad[i] ^= 0x01
if err := Verify(n, pk, bad); err == nil {
t.Fatalf("a proof with byte %d flipped was admitted", i)
}
}
}
// TestSignRefusesWithoutASecret keeps the nil path off the happy path.
func TestSignRefusesWithoutASecret(t *testing.T) {
if _, err := Sign(nil, node(0x88)); err == nil {
t.Fatal("signing with no secret key returned a proof")
}
}
+196
View File
@@ -0,0 +1,196 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// registration.go — who is admitted to the validator set, and on what evidence.
//
// A certificate is only as sound as the set it is checked against. Verification
// can be perfect and still be checked against a set that counts one key holder
// as many signers, so two rules are enforced here, in this order:
//
// ENCODING the key is a canonical compressed BLS12-381 G1 point;
// POSSESSION a node-bound proof (validator/pop) binds this key to this node;
// UNIQUENESS the key is registered to no other node.
//
// Then, and only then, weight is counted. The order is not decoration: a pairing
// check on bytes that are not a point is undefined, and weight counted before
// uniqueness is weight counted twice.
//
// UNIQUENESS IS WHY THE MERGE IS GONE. FlattenValidatorSet used to fold two
// validators that shared a public key into ONE entry carrying both node ids and
// their summed weight. That fold is the only thing that ever made
// many-nodes-one-key representable, so a floor written to require many DISTINCT
// signers was satisfied by one holder registering one key under several ids. The
// state does not exist to normalize any more: a repeated key is refused, and a
// canonical validator carries exactly one node id.
package validators
import (
"bytes"
"errors"
"fmt"
"slices"
"github.com/luxfi/consensus/validator/pop"
"github.com/luxfi/crypto/bls"
"github.com/luxfi/ids"
"github.com/luxfi/math"
"github.com/luxfi/validators"
)
var (
// ErrDuplicateKey is returned when two node ids present the same public key.
// One key, one node: counting distinct voters has to count distinct signers.
ErrDuplicateKey = errors.New("validators: public key is registered to more than one node")
// ErrNoKey is returned when a registration carries no public key. A validator
// with no key cannot sign, so it cannot be admitted through the proof path.
ErrNoKey = errors.New("validators: registration carries no public key")
// ErrPossession is returned when a registration's proof does not bind its key
// to its node. It wraps the reason from validator/pop.
ErrPossession = errors.New("validators: registration is not proven")
)
// Registration is one validator asking to be counted: the identity it claims,
// the key it will sign under, the proof binding the two, and the weight staked
// behind it.
type Registration struct {
NodeID ids.NodeID
// Key is the BLS12-381 min_pk public key, compressed G1, 48 bytes.
Key []byte
// Proof is the node-bound proof of possession over NodeID ‖ Key, compressed
// G2, 96 bytes. See validator/pop for the exact preimage and domain.
Proof []byte
Weight uint64
}
// Register admits a registration set and returns it in canonical order.
//
// This is the admission rule of the standard, written once. Every registration
// is checked in the fixed order — encoding, possession, uniqueness — and the set
// is admitted whole or not at all: a single unproven or duplicated key fails the
// call rather than being dropped from an otherwise-good set, because a set that
// silently loses a signer is a set whose weight no longer describes it.
//
// Ordering and PublicKeyBytes match FlattenValidatorSet exactly, so an admitted
// set and a flattened one are the same set.
func Register(rs []Registration) (CanonicalValidatorSet, error) {
// Deterministic order in, deterministic error out: which duplicate a set is
// refused on must not depend on the order a caller happened to build a slice.
sorted := slices.Clone(rs)
slices.SortStableFunc(sorted, func(a, b Registration) int {
return bytes.Compare(a.NodeID[:], b.NodeID[:])
})
var (
out = make([]*CanonicalValidator, 0, len(sorted))
byKey = make(map[string]ids.NodeID, len(sorted))
totalWeight uint64
)
for _, r := range sorted {
if len(r.Key) == 0 {
return CanonicalValidatorSet{}, fmt.Errorf("%w: %s", ErrNoKey, r.NodeID)
}
// ENCODING, then POSSESSION — both inside pop.Verify, in that order.
if err := pop.Verify(r.NodeID, r.Key, r.Proof); err != nil {
return CanonicalValidatorSet{}, fmt.Errorf("%w: %s: %w", ErrPossession, r.NodeID, err)
}
pk, err := bls.PublicKeyFromCompressedBytes(r.Key)
if err != nil {
return CanonicalValidatorSet{}, fmt.Errorf("%s: %w", r.NodeID, err)
}
// UNIQUENESS. Keyed on the canonical compressed encoding, which pop.Verify
// has already established is the only spelling of this point.
if first, dup := byKey[string(r.Key)]; dup {
return CanonicalValidatorSet{}, fmt.Errorf("%w: %s and %s", ErrDuplicateKey, first, r.NodeID)
}
byKey[string(r.Key)] = r.NodeID
// WEIGHT, last.
sum, err := math.Add64(totalWeight, r.Weight)
if err != nil {
return CanonicalValidatorSet{}, fmt.Errorf("%w: %w", ErrWeightOverflow, err)
}
totalWeight = sum
out = append(out, &CanonicalValidator{
PublicKey: pk,
PublicKeyBytes: bls.PublicKeyToUncompressedBytes(pk),
Weight: r.Weight,
NodeIDs: []ids.NodeID{r.NodeID},
})
}
slices.SortFunc(out, (*CanonicalValidator).Compare)
return CanonicalValidatorSet{Validators: out, TotalWeight: totalWeight}, nil
}
// FlattenValidatorSet converts vdrSet into canonical order and returns the total
// weight, which — as upstream — includes validators that carry no public key and
// therefore cannot sign.
//
// It replaces the re-export of validators.FlattenValidatorSet for one reason: the
// upstream MERGES two node ids that share a public key into a single validator
// with a summed weight, and that state is retired. A repeated key is now an
// error, so every returned CanonicalValidator carries exactly one node id.
//
// It does NOT check possession, because this input has no proof to check: it is
// a set the chain already admitted, and the proof lives at admission. Register is
// where the full order runs. Until the P-chain's registration proof becomes
// node-bound, uniqueness is the half of the rule this side can hold — and it is
// the half that decides whether counting signers counts holders.
//
// An undecodable key is skipped rather than refused, exactly as upstream: this
// function reads a set it did not admit, and refusing the whole set on one bad
// key would trade a signer for a halt. Strictness belongs at admission.
func FlattenValidatorSet(vdrSet map[ids.NodeID]*GetValidatorOutput) (CanonicalValidatorSet, error) {
// Iterate in node-id order. The upstream ranged over the map and sorted at the
// end, which is fine for a total but not for an error: which duplicate pair a
// set is refused on must be the same on every node.
nodeIDs := make([]ids.NodeID, 0, len(vdrSet))
for nodeID := range vdrSet {
nodeIDs = append(nodeIDs, nodeID)
}
slices.SortFunc(nodeIDs, func(a, b ids.NodeID) int { return bytes.Compare(a[:], b[:]) })
var (
out = make([]*CanonicalValidator, 0, len(nodeIDs))
byKey = make(map[string]ids.NodeID, len(nodeIDs))
totalWeight uint64
err error
)
for _, nodeID := range nodeIDs {
vdr := vdrSet[nodeID]
if vdr == nil {
continue
}
totalWeight, err = math.Add64(totalWeight, vdr.Weight)
if err != nil {
return CanonicalValidatorSet{}, fmt.Errorf("%w: %w", ErrWeightOverflow, err)
}
if len(vdr.PublicKey) == 0 {
continue
}
pk, err := bls.PublicKeyFromCompressedBytes(vdr.PublicKey)
if err != nil {
continue
}
pkBytes := bls.PublicKeyToUncompressedBytes(pk)
if first, dup := byKey[string(pkBytes)]; dup {
return CanonicalValidatorSet{}, fmt.Errorf("%w: %s and %s", ErrDuplicateKey, first, nodeID)
}
byKey[string(pkBytes)] = nodeID
out = append(out, &CanonicalValidator{
PublicKey: pk,
PublicKeyBytes: pkBytes,
Weight: vdr.Weight,
NodeIDs: []ids.NodeID{nodeID},
})
}
slices.SortFunc(out, (*CanonicalValidator).Compare)
return CanonicalValidatorSet{Validators: out, TotalWeight: totalWeight}, nil
}
// The upstream flatten is kept referenced so a version bump that changes it is a
// visible diff here rather than a silent divergence: this package now owns the
// rule, and the assertion is that it still owns the same signature.
var _ func(map[ids.NodeID]*GetValidatorOutput) (validators.CanonicalValidatorSet, error) = validators.FlattenValidatorSet
+352
View File
@@ -0,0 +1,352 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package validators
import (
"bytes"
"errors"
"testing"
"github.com/luxfi/consensus/validator/pop"
"github.com/luxfi/crypto/bls"
"github.com/luxfi/ids"
"github.com/luxfi/validators"
)
func secret(t *testing.T, seed byte) *bls.SecretKey {
t.Helper()
s := make([]byte, 32)
for i := range s {
s[i] = seed
}
sk, err := bls.SecretKeyFromSeed(s)
if err != nil {
t.Fatalf("SecretKeyFromSeed(%d): %v", seed, err)
}
return sk
}
func nodeID(b byte) ids.NodeID {
var n ids.NodeID
for i := range n {
n[i] = b
}
return n
}
func registration(t *testing.T, seed, id byte, weight uint64) Registration {
t.Helper()
sk := secret(t, seed)
n := nodeID(id)
proof, err := pop.Sign(sk, n)
if err != nil {
t.Fatalf("pop.Sign: %v", err)
}
return Registration{
NodeID: n,
Key: bls.PublicKeyToCompressedBytes(sk.PublicKey()),
Proof: proof,
Weight: weight,
}
}
// TestRegisterAdmitsProvenValidators is the happy path, and it pins the shape
// the retirement of the merge guarantees: one node id per validator, always.
func TestRegisterAdmitsProvenValidators(t *testing.T) {
set, err := Register([]Registration{
registration(t, 1, 0x01, 100),
registration(t, 2, 0x02, 200),
registration(t, 3, 0x03, 300),
})
if err != nil {
t.Fatalf("Register: %v", err)
}
if len(set.Validators) != 3 {
t.Fatalf("admitted %d validators, want 3", len(set.Validators))
}
if set.TotalWeight != 600 {
t.Fatalf("total weight %d, want 600", set.TotalWeight)
}
for _, v := range set.Validators {
if len(v.NodeIDs) != 1 {
t.Fatalf("a validator carries %d node ids — the merge is back", len(v.NodeIDs))
}
}
for i := 1; i < len(set.Validators); i++ {
if bytes.Compare(set.Validators[i-1].PublicKeyBytes, set.Validators[i].PublicKeyBytes) >= 0 {
t.Fatal("the admitted set is not in canonical order")
}
}
}
// TestOneKeyOneNode is the second rule, and the reason possession alone is not
// enough: the holder of a key can mint a VALID node-bound proof for any identity
// it likes, so both registrations below are individually sound. What refuses them
// is uniqueness — counting distinct voters has to count distinct signers.
func TestOneKeyOneNode(t *testing.T) {
sk := secret(t, 5)
key := bls.PublicKeyToCompressedBytes(sk.PublicKey())
one, two := nodeID(0x10), nodeID(0x20)
proofOne, err := pop.Sign(sk, one)
if err != nil {
t.Fatalf("pop.Sign: %v", err)
}
proofTwo, err := pop.Sign(sk, two)
if err != nil {
t.Fatalf("pop.Sign: %v", err)
}
// Both proofs are real: possession holds for each pair on its own.
if err := pop.Verify(one, key, proofOne); err != nil {
t.Fatalf("first proof is not sound: %v", err)
}
if err := pop.Verify(two, key, proofTwo); err != nil {
t.Fatalf("second proof is not sound: %v", err)
}
_, err = Register([]Registration{
{NodeID: one, Key: key, Proof: proofOne, Weight: 1},
{NodeID: two, Key: key, Proof: proofTwo, Weight: 1},
})
if !errors.Is(err, ErrDuplicateKey) {
t.Fatalf("one key admitted under two nodes: %v", err)
}
}
// TestRegisterRefusesTheUnproven walks the possession gate.
func TestRegisterRefusesTheUnproven(t *testing.T) {
good := registration(t, 6, 0x30, 1)
lifted := registration(t, 6, 0x31, 1) // same key, different node…
lifted.Proof = good.Proof // …carrying the first node's proof
pubkeyOnly := registration(t, 7, 0x32, 1)
sig, err := secret(t, 7).SignProofOfPossession(pubkeyOnly.Key)
if err != nil {
t.Fatalf("SignProofOfPossession: %v", err)
}
pubkeyOnly.Proof = bls.SignatureToBytes(sig)
noProof := registration(t, 8, 0x33, 1)
noProof.Proof = nil
noKey := registration(t, 9, 0x34, 1)
noKey.Key = nil
for _, c := range []struct {
name string
r Registration
want error
}{
{"a proof lifted from another node", lifted, ErrPossession},
{"the pubkey-only proof", pubkeyOnly, ErrPossession},
{"no proof at all", noProof, ErrPossession},
{"no key at all", noKey, ErrNoKey},
} {
if _, err := Register([]Registration{c.r}); !errors.Is(err, c.want) {
t.Fatalf("%s: got %v, want %v", c.name, err, c.want)
}
// And it must not slip in beside a good one either.
if _, err := Register([]Registration{good, c.r}); !errors.Is(err, c.want) {
t.Fatalf("%s beside a good registration: got %v, want %v", c.name, err, c.want)
}
}
}
// TestRegisterRefusesTheSetWhole: a bad registration fails the call rather than
// being dropped, so an admitted set's weight always describes the set.
func TestRegisterRefusesTheSetWhole(t *testing.T) {
bad := registration(t, 12, 0x40, 5)
bad.Proof = make([]byte, pop.ProofLen)
set, err := Register([]Registration{registration(t, 13, 0x41, 7), bad})
if err == nil {
t.Fatal("an unproven registration was dropped instead of refused")
}
if len(set.Validators) != 0 || set.TotalWeight != 0 {
t.Fatal("a refused call returned a partial set")
}
}
// TestRegisterIsOrderIndependent: which duplicate a set is refused on must be
// the same on every node, whatever order the caller built the slice in.
func TestRegisterIsOrderIndependent(t *testing.T) {
sk := secret(t, 15)
key := bls.PublicKeyToCompressedBytes(sk.PublicKey())
one, two := nodeID(0x50), nodeID(0x51)
p1, _ := pop.Sign(sk, one)
p2, _ := pop.Sign(sk, two)
a := Registration{NodeID: one, Key: key, Proof: p1, Weight: 1}
b := Registration{NodeID: two, Key: key, Proof: p2, Weight: 1}
_, forward := Register([]Registration{a, b})
_, backward := Register([]Registration{b, a})
if forward == nil || backward == nil {
t.Fatal("a duplicate key was admitted")
}
if forward.Error() != backward.Error() {
t.Fatalf("the refusal depends on input order:\n %q\n %q", forward, backward)
}
}
// -----------------------------------------------------------------------------
// FlattenValidatorSet
// -----------------------------------------------------------------------------
func output(id byte, sk *bls.SecretKey, weight uint64) (ids.NodeID, *GetValidatorOutput) {
n := nodeID(id)
var key []byte
if sk != nil {
key = bls.PublicKeyToCompressedBytes(sk.PublicKey())
}
return n, &GetValidatorOutput{NodeID: n, PublicKey: key, Weight: weight, Light: weight}
}
// TestFlattenRefusesTheMerge is the retirement itself. Upstream folded two node
// ids that shared a key into ONE validator carrying both ids and their summed
// weight; that fold is the only thing that ever made many-nodes-one-key
// representable, and a signer floor written to require many holders was cleared
// by one. The state is now an error.
func TestFlattenRefusesTheMerge(t *testing.T) {
sk := secret(t, 17)
in := map[ids.NodeID]*GetValidatorOutput{}
for _, id := range []byte{0x60, 0x61} {
n, o := output(id, sk, 10)
in[n] = o
}
// What the upstream does with it, recorded so the change is visible: one
// validator, two node ids, twenty weight.
merged, err := validators.FlattenValidatorSet(in)
if err != nil {
t.Fatalf("upstream flatten: %v", err)
}
if len(merged.Validators) != 1 || len(merged.Validators[0].NodeIDs) != 2 {
t.Fatalf("upstream no longer merges — re-read this test: %+v", merged.Validators)
}
if _, err := FlattenValidatorSet(in); !errors.Is(err, ErrDuplicateKey) {
t.Fatalf("the merge is still reachable: %v", err)
}
}
// TestFlattenMatchesUpstreamWhenKeysAreDistinct is the compatibility proof: on
// every set that already obeys one-key-one-node — which is every set the chain
// holds today — this returns exactly what the network has always computed,
// including the canonical ORDER that decides warp signature bit indices.
func TestFlattenMatchesUpstreamWhenKeysAreDistinct(t *testing.T) {
in := map[ids.NodeID]*GetValidatorOutput{}
for i, seed := range []byte{31, 32, 33, 34, 35} {
n, o := output(byte(0x70+i), secret(t, seed), uint64(100*(i+1)))
in[n] = o
}
// A validator with no key at all: it cannot sign, and its weight still counts.
n, o := output(0x7f, nil, 55)
in[n] = o
mine, err := FlattenValidatorSet(in)
if err != nil {
t.Fatalf("FlattenValidatorSet: %v", err)
}
theirs, err := validators.FlattenValidatorSet(in)
if err != nil {
t.Fatalf("upstream flatten: %v", err)
}
if mine.TotalWeight != theirs.TotalWeight {
t.Fatalf("total weight diverged: %d vs %d", mine.TotalWeight, theirs.TotalWeight)
}
if mine.TotalWeight != 1500+55 {
t.Fatalf("a keyless validator's weight stopped counting: %d", mine.TotalWeight)
}
if len(mine.Validators) != len(theirs.Validators) {
t.Fatalf("validator count diverged: %d vs %d", len(mine.Validators), len(theirs.Validators))
}
for i := range mine.Validators {
a, b := mine.Validators[i], theirs.Validators[i]
if !bytes.Equal(a.PublicKeyBytes, b.PublicKeyBytes) {
t.Fatalf("canonical order diverged at %d", i)
}
if a.Weight != b.Weight {
t.Fatalf("weight diverged at %d: %d vs %d", i, a.Weight, b.Weight)
}
if len(a.NodeIDs) != 1 || a.NodeIDs[0] != b.NodeIDs[0] {
t.Fatalf("node ids diverged at %d: %v vs %v", i, a.NodeIDs, b.NodeIDs)
}
}
}
// TestFlattenRefusesTheSameDuplicateEveryTime: map iteration order must not
// decide which pair a set is refused on, or two nodes disagree about a set.
func TestFlattenRefusesTheSameDuplicateEveryTime(t *testing.T) {
sk := secret(t, 41)
in := map[ids.NodeID]*GetValidatorOutput{}
for _, id := range []byte{0x91, 0x92, 0x93, 0x94} {
n, o := output(id, sk, 1)
in[n] = o
}
_, first := FlattenValidatorSet(in)
if first == nil {
t.Fatal("four nodes sharing one key were admitted")
}
for i := 0; i < 64; i++ {
if _, err := FlattenValidatorSet(in); err == nil || err.Error() != first.Error() {
t.Fatalf("the refusal moved with map order: %q then %q", first, err)
}
}
}
// TestFlattenStillSkipsAnUndecodableKey: this function reads a set it did not
// admit, so one unreadable key costs a signer, never the whole set. Strictness
// lives at Register.
func TestFlattenStillSkipsAnUndecodableKey(t *testing.T) {
in := map[ids.NodeID]*GetValidatorOutput{}
n, o := output(0xA1, secret(t, 51), 10)
in[n] = o
bad := nodeID(0xA2)
in[bad] = &GetValidatorOutput{NodeID: bad, PublicKey: bytes.Repeat([]byte{0xEE}, bls.PublicKeyLen), Weight: 90}
set, err := FlattenValidatorSet(in)
if err != nil {
t.Fatalf("an undecodable key failed the whole set: %v", err)
}
if len(set.Validators) != 1 {
t.Fatalf("admitted %d validators, want 1", len(set.Validators))
}
if set.TotalWeight != 100 {
t.Fatalf("total weight %d, want 100 — the skipped validator's stake still counts", set.TotalWeight)
}
}
// TestFlattenAndRegisterAgree: two doors, one set. An admitted set and a
// flattened one must be the same bytes in the same order.
func TestFlattenAndRegisterAgree(t *testing.T) {
rs := []Registration{
registration(t, 61, 0xB1, 3),
registration(t, 62, 0xB2, 5),
registration(t, 63, 0xB3, 7),
}
in := map[ids.NodeID]*GetValidatorOutput{}
for _, r := range rs {
in[r.NodeID] = &GetValidatorOutput{NodeID: r.NodeID, PublicKey: r.Key, Weight: r.Weight, Light: r.Weight}
}
admitted, err := Register(rs)
if err != nil {
t.Fatalf("Register: %v", err)
}
flattened, err := FlattenValidatorSet(in)
if err != nil {
t.Fatalf("FlattenValidatorSet: %v", err)
}
if admitted.TotalWeight != flattened.TotalWeight {
t.Fatalf("weights disagree: %d vs %d", admitted.TotalWeight, flattened.TotalWeight)
}
for i := range admitted.Validators {
if !bytes.Equal(admitted.Validators[i].PublicKeyBytes, flattened.Validators[i].PublicKeyBytes) {
t.Fatalf("the two doors order the set differently at %d", i)
}
if admitted.Validators[i].NodeIDs[0] != flattened.Validators[i].NodeIDs[0] {
t.Fatalf("the two doors name different nodes at %d", i)
}
}
}
+2 -5
View File
@@ -6,7 +6,6 @@ package validators
import (
"github.com/luxfi/crypto/bls"
"github.com/luxfi/ids"
"github.com/luxfi/math/set"
"github.com/luxfi/validators"
)
@@ -26,10 +25,8 @@ type CanonicalValidatorSet = validators.CanonicalValidatorSet
// CanonicalValidator is an alias for validators.CanonicalValidator
type CanonicalValidator = validators.CanonicalValidator
// FlattenValidatorSet re-exports validators.FlattenValidatorSet
func FlattenValidatorSet(vdrSet map[ids.NodeID]*GetValidatorOutput) (CanonicalValidatorSet, error) {
return validators.FlattenValidatorSet(vdrSet)
}
// FlattenValidatorSet lives in registration.go: this package enforces one key
// per node, so the upstream merge of duplicate keys is not re-exported.
// FilterValidators re-exports validators.FilterValidators
func FilterValidators(