consensus: require cryptographic quorum evidence

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-18 22:21:49 -07:00
co-authored by Hanzo Dev
parent 4abf37c952
commit 9c24b9541e
6 changed files with 113 additions and 144 deletions
+10 -73
View File
@@ -3,80 +3,17 @@
package chain
import (
"time"
import "github.com/luxfi/ids"
"github.com/luxfi/ids"
)
// ReceiveAuthenticatedVote counts a peer's preference toward the NOVA tally when
// the peer's identity came from the authenticated transport.
// ReceiveAuthenticatedVote is retained as a source-compatible tombstone for node
// versions that once promoted Chits into votes. It always refuses the input.
//
// THE PROBLEM IT SOLVES. A Chits message carries no signature field, so on a K>1
// chain every inbound preference was dropped at the engine's signature gate and α
// was unreachable BY CONSTRUCTION: blocks were built, verified, and preferred by
// every node while the tally stayed at zero. Testnet sat at 16820 and devnet at
// 7323 in exactly this state, each node logging "vote is UNSIGNED and cannot be
// counted" once per peer per poll, forever.
//
// WHY IT IS SAFE, WHEN COUNTING A PAYLOAD-SUPPLIED Vote{NodeID} WOULD NOT BE.
// The distinction is where origin comes from, and it is the same one the reference implementation
// relies on: its engine receives the voter as `Chits(nodeID, requestID, …)`, a
// PARAMETER the router fills in from the authenticated connection, so a peer
// cannot claim to be someone else. Lux's Vote carries NodeID as a struct FIELD,
// which any peer can set to any value — counting that would make finality
// forgeable by anyone who can send 32 bytes.
//
// So origin is a PARAMETER here too. The caller must be the inbound-message path
// that already authenticated the sender; the identity is not taken from anything
// the sender serialized. `transportAuthenticated` is unexported, so no decoded
// value can carry it.
//
// WHAT IT DELIBERATELY DOES NOT DO. It contributes to the Nova (liveness) tally
// only. It never reaches recordCertVoteLocked, so it can never become part of a
// QuorumCert: a cert is a PORTABLE claim that travels to nodes which did not
// authenticate this connection, and for those nodes only a signature carries
// meaning. Quasar therefore still requires signatures, unchanged — local liveness
// rests on transport authentication, exported finality rests on cryptography.
// An authenticated connection proves who sent a packet, but a Chits packet contains
// no signature over the consensus position or execution result. Counting it created
// a second, non-portable acceptance authority and even let a receiver manufacture the
// decision bit from its own re-Verify result. Multi-validator progress now comes from
// signed BroadcastVote messages; sole-validator progress uses the ordinary K==1
// ReceiveVote path.
func (t *Transitive) ReceiveAuthenticatedVote(origin ids.NodeID, blockID ids.ID, accept bool) bool {
if origin == ids.EmptyNodeID || blockID == ids.Empty {
return false
}
if !t.holdsStake(origin, blockID) {
return false
}
return t.ReceiveVote(Vote{
BlockID: blockID,
NodeID: origin,
Accept: accept,
SignedAt: time.Now(),
transportAuthenticated: true,
})
}
// holdsStake answers whether origin is a validator of this chain at the block's
// epoch.
//
// The signed path never has to ask. VerifyVote resolves the voter's key out of
// the set at that epoch, so an id that is not in the set has no key and its vote
// dies there — membership is answered as a side effect of checking the
// signature. This path has no signature to resolve, and the tallies are keyed by
// NodeID, so without asking directly α would count distinct PEERS. A peer need
// not be a validator to connect, and its NodeID is a hash of a certificate it
// generates for itself, so distinct peers are free to mint.
//
// A chain with no stake model has no membership to check against, and none to
// protect: α there is a plain count over a fixed set whose admission is enforced
// before a peer can connect at all. Refusing on its behalf would cost a
// single-validator or equal-stake chain every vote it has. The check earns its
// keep exactly where a stake model exists, which is every chain where a stranger
// could connect without being a validator.
func (t *Transitive) holdsStake(origin ids.NodeID, blockID ids.ID) bool {
t.mu.RLock()
defer t.mu.RUnlock()
if t.stakeSource == nil {
return true
}
return t.stakeSource.Weight(origin, t.epochHeightLocked(t.pendingBlocks[blockID])) > 0
return false
}
+17 -31
View File
@@ -5,17 +5,13 @@ package chain
import (
"testing"
"time"
"github.com/luxfi/ids"
)
// TestAuthenticatedVote_ReachesAlphaAndFinalizes is the LIVENESS regression for
// the halt. A Chits carries no signature; before ReceiveAuthenticatedVote existed
// every inbound preference on a K>1 chain died at the signature gate and α was
// unreachable no matter how many honest peers agreed. Testnet held at 16820 and
// devnet at 7323 in exactly this state.
func TestAuthenticatedVote_ReachesAlphaAndFinalizes(t *testing.T) {
// TestAuthenticatedVote_IsRefused pins the tombstone: transport identity does not
// turn an unsigned Chits preference into a cryptographic vote.
func TestAuthenticatedVote_IsRefused(t *testing.T) {
vs := newTestValidatorSet(5)
e, chainID := newQuorumEngineOpts(t, params5Prod(), vs, 0, &recordingGossiper{})
alpha := e.consensus.Alpha()
@@ -27,24 +23,19 @@ func TestAuthenticatedVote_ReachesAlphaAndFinalizes(t *testing.T) {
trackFollowedBlock(e, chainID, blk)
for i := 0; i < alpha; i++ {
if !e.ReceiveAuthenticatedVote(vs.nodeID(i), blk.id, true) {
t.Fatalf("validator %d's authenticated preference was not queued", i)
if e.ReceiveAuthenticatedVote(vs.nodeID(i), blk.id, true) {
t.Fatalf("validator %d's unsigned preference was queued as a vote", i)
}
}
if !waitFor(2*time.Second, func() bool { return readVoteTally(e, blk).acceptVotes >= alpha }) {
t.Fatalf("HALT REPRODUCED: %d peers, each authenticated by the transport, agreed on the "+
"block and the tally is %+v. A Chits has no signature, so if these are dropped α is "+
"unreachable BY CONSTRUCTION and the chain stops — which is what testnet 16820 and "+
"devnet 7323 were doing.", alpha, readVoteTally(e, blk))
if got := readVoteTally(e, blk); got.acceptVotes != 0 || got.voteCount != 0 || got.certVotes != 0 {
t.Fatalf("unsigned transport preferences moved the quorum tally: %+v", got)
}
}
// TestAuthenticatedVote_IsNovaOnly_NeverBuildsACert is the SAFETY half. Transport
// authentication is meaningful only to the node that performed it; a QuorumCert is
// a portable claim that travels to nodes which did not. So these votes must never
// become cert witnesses, no matter how many arrive.
func TestAuthenticatedVote_IsNovaOnly_NeverBuildsACert(t *testing.T) {
// TestAuthenticatedVote_NeverBuildsACert is the portable-proof half: no number of
// unsigned transport messages can produce a certificate witness.
func TestAuthenticatedVote_NeverBuildsACert(t *testing.T) {
vs := newTestValidatorSet(5)
e, chainID := newQuorumEngineOpts(t, params5Prod(), vs, 0, &recordingGossiper{})
@@ -55,10 +46,8 @@ func TestAuthenticatedVote_IsNovaOnly_NeverBuildsACert(t *testing.T) {
e.ReceiveAuthenticatedVote(vs.nodeID(i), blk.id, true)
}
if got := readVoteTally(e, blk); got.certVotes != 0 {
t.Fatalf("EXPORTABLE FINALITY FROM UNSIGNED VOTES: %d cert witnesses were assembled from "+
"transport-authenticated preferences (%+v). A cert must be verifiable by a node that "+
"never saw this connection, so only signatures may enter it.", got.certVotes, got)
if got := readVoteTally(e, blk); got.acceptVotes != 0 || got.certVotes != 0 {
t.Fatalf("unsigned transport preferences entered finality state: %+v", got)
}
}
@@ -86,10 +75,9 @@ func TestAuthenticatedVote_PayloadOriginStillRefused(t *testing.T) {
}
}
// TestAuthenticatedVote_OneVotePerValidator: transport authentication proves WHO
// sent a message, not how many times they may be counted. A peer answering the
// same poll repeatedly must move the tally once.
func TestAuthenticatedVote_OneVotePerValidator(t *testing.T) {
// TestAuthenticatedVote_ReplaysRemainRefused ensures retries cannot revive the
// removed unsigned acceptance path.
func TestAuthenticatedVote_ReplaysRemainRefused(t *testing.T) {
vs := newTestValidatorSet(5)
e, chainID := newQuorumEngineOpts(t, params5Prod(), vs, 0, &recordingGossiper{})
alpha := e.consensus.Alpha()
@@ -101,9 +89,7 @@ func TestAuthenticatedVote_OneVotePerValidator(t *testing.T) {
e.ReceiveAuthenticatedVote(vs.nodeID(0), blk.id, true)
}
if got := readVoteTally(e, blk); got.acceptVotes > 1 {
t.Fatalf("ONE PEER REACHED α ALONE: %d authenticated replays from a single validator moved "+
"the tally to %d (%+v). Authentication names the sender; it does not entitle them to "+
"vote α times.", alpha+3, got.acceptVotes, got)
if got := readVoteTally(e, blk); got.acceptVotes != 0 || got.voteCount != 0 {
t.Fatalf("%d unsigned replays moved the tally: %+v", alpha+3, got)
}
}
+50
View File
@@ -201,6 +201,56 @@ func (t *Transitive) CertForBlock(blockID ids.ID) ([]byte, bool) {
return certs.Get(blockID)
}
// VerifyCatchupCertificate verifies that certBytes is a portable quorum proof for
// blockBytes without tracking, finalizing, or otherwise mutating consensus state.
// Bootstrap frontier discovery uses this read-only predicate to distinguish a peer's
// unsigned claim about its tip from a tip backed by the same cryptographic evidence
// that the live finalizer accepts.
//
// The block is parsed only to bind the proof to the local chain, height, canonical
// execution commitment, and validator-set epoch. Its state transition is deliberately
// not executed here; ordered bootstrap execution still verifies each block when it is
// applied by AcceptCatchupBlock.
func (rt *Runtime) VerifyCatchupCertificate(ctx context.Context, blockBytes, certBytes []byte) error {
if rt.config.VM == nil || len(certBytes) == 0 {
return ErrCatchupCertRejected
}
blk, err := rt.config.VM.ParseBlock(ctx, blockBytes)
if err != nil {
return errors.Join(ErrCatchupCertRejected, err)
}
cert, err := UnmarshalQuorumCert(certBytes)
if err != nil {
return errors.Join(ErrCatchupCertRejected, err)
}
t := rt.Transitive
t.mu.RLock()
chainID := t.chainID
floor := t.consensus.Alpha()
if cert.Tier == Nova {
floor = NovaSignerFloor(t.consensus.K())
}
setRootSource := t.setRootSource
t.mu.RUnlock()
if cert.Position.ChainID != chainID ||
cert.Position.Height != blk.Height() ||
certCanonical(cert) != canonicalIDOf(blk) ||
(floor > 0 && cert.Threshold < uint32(floor)) {
return ErrCatchupCertRejected
}
epochHeight := pChainHeightOf(blk)
if setRootSource != nil && setRootSource.ValidatorSetRoot(epochHeight) != cert.Position.ValidatorSetRoot {
return ErrCatchupCertRejected
}
if err := t.verifyCert(cert, epochHeight); err != nil {
return errors.Join(ErrCatchupCertRejected, err)
}
return nil
}
// AcceptCatchupBlock finalizes ONE gap block from a (blockBytes, certBytes) pair
// fetched during frontier catch-up. It is the receive-side counterpart of
// CertForBlock: parse → local Verify → track → verified-cert finalize.
+26
View File
@@ -172,6 +172,32 @@ func buildGap(vm *catchupVM, tip *verifyOnceBlock, k int) []*verifyOnceBlock {
return gap
}
func TestVerifyCatchupCertificate_ReadOnlyCryptographicFrontierGate(t *testing.T) {
vs := newTestValidatorSet(5)
vm := newCatchupVM()
rt, chainID, _ := newCatchupRuntime(t, vs, 0, vm)
blk := newTestBlock(42, ids.GenerateTestID(), "certified-frontier")
vm.register(blk)
cert := catchupCertFor(t, vs, chainID, blk, []int{0, 1, 2, 3}, 3)
if err := rt.VerifyCatchupCertificate(context.Background(), blk.bytes, cert); err != nil {
t.Fatalf("valid 4-of-5 certified frontier rejected: %v", err)
}
if blk.VerifyCalls() != 0 || blk.AcceptCalled() != 0 || rt.IsAccepted(blk.id) {
t.Fatalf("frontier proof check mutated execution/finality: verify=%d accept=%d accepted=%v",
blk.VerifyCalls(), blk.AcceptCalled(), rt.IsAccepted(blk.id))
}
forged := append([]byte(nil), cert...)
forged[len(forged)-1] ^= 0xff
if err := rt.VerifyCatchupCertificate(context.Background(), blk.bytes, forged); err == nil {
t.Fatal("forged frontier certificate verified")
}
if blk.VerifyCalls() != 0 || blk.AcceptCalled() != 0 || rt.IsAccepted(blk.id) {
t.Fatal("rejected frontier proof mutated execution/finality")
}
}
// -----------------------------------------------------------------------------
// Liveness — a stranded node converges to the tip through the cert path, with no
// re-voting. The network will not re-vote an already-finalized height, so the only
+5 -34
View File
@@ -146,12 +146,6 @@ type Vote struct {
// is not separately tracking the block's parent.
ParentID ids.ID
// transportAuthenticated marks a vote whose ORIGIN was supplied as a
// PARAMETER by the authenticated transport, rather than read out of the
// payload. It is unexported ON PURPOSE: no decoder, no peer, and no caller
// outside this package can set it, so it cannot be forged over the wire.
// ReceiveAuthenticatedVote is the only constructor that sets it.
transportAuthenticated bool
// Round is the consensus round the vote was cast in (0 for the first round
// at a height). Bound into the signed position.
Round uint32
@@ -2262,38 +2256,15 @@ func (t *Transitive) handleVote(vote Vote) {
// flip is DELETED: vote.Accept is authoritative once the signature checks
// out. Single-validator engines (no verifier) skip authentication — the sole
// validator's self-vote is the quorum and carries no signature.
if t.voteVerifier != nil && !vote.transportAuthenticated {
if t.voteVerifier != nil {
pos := t.blockPositionLocked(pending, vote.BlockID)
msg := canonicalVoteMessageFor(pos, vote.Accept)
// Resolve the voter's pubkey at the block's P-CHAIN epoch height (RESIDUAL-B),
// the same height the position's set-root commits to.
// WHY THIS STAYS FOR PAYLOAD-SUPPLIED ORIGINS.
//
// the reference counts unsigned chits: its vote path carries
// ZERO, while vms/platformvm/warp signs aggregate BLS for anything that must
// travel. Signatures belong at the export layer, not the liveness loop, and
// this gate is a signature check one layer too far in. Removing it was tried
// here and REVERTED, because Lux cannot yet make that safety argument.
//
// Ava's engine receives the voter as `Chits(nodeID, requestID, …)` — a
// PARAMETER supplied by the router from the authenticated connection. The
// type guarantees provenance: a peer cannot claim to be someone else.
//
// Lux's engine receives `Vote{NodeID: …}` — a FIELD. Nothing at this call
// site distinguishes a NodeID read from an authenticated transport from one
// a caller copied out of message bytes. Counting unsigned votes here would
// therefore trust a value the engine cannot verify the origin of, which is
// exactly what vote_safety_invariants_test.go refuses: "counting it makes
// finality forgeable by any peer that can send a 32-byte preference."
//
// So the fix has an ORDER, and it is not this one first:
// 1. make voter provenance structural — origin as a parameter, so a
// self-declared source is unrepresentable rather than merely unlikely;
// 2. THEN unsigned transport-authenticated votes can count toward the Nova
// tally, as they do there;
// 3. Quasar certificates keep requiring signatures either way — that is
// the artifact that must convince a stranger.
// Doing (2) before (1) trades a liveness bug for a safety bug.
// Transport authentication proves who sent a packet; it does not prove that
// the peer signed this consensus position or execution result. There is no
// unsigned exemption on a quorum chain: Chits remains sampling input, while
// BroadcastVote supplies the cryptographic statement counted here.
if len(vote.Signature) == 0 || !t.voteVerifier.VerifyVote(vote.NodeID, msg, vote.Signature, t.epochHeightLocked(pending)) {
// Unsigned or invalid: not a real vote from this validator at this
// position/decision. Drop it — count nothing.
@@ -87,10 +87,9 @@ func TestAuthVote_StrangerMovesTheRejectTally(t *testing.T) {
}
}
// The control. The same call with real validator ids is the intended behavior and
// must keep working — this is the halt fix. If this fails the harness is wrong,
// not the engine.
func TestAuthVote_ValidatorsStillCount(t *testing.T) {
// Even a real validator id is insufficient without a signature over the exact
// consensus position. Transport identity is not a substitute for that proof.
func TestAuthVote_ValidatorsWithoutSignaturesAreRefused(t *testing.T) {
vs := newTestValidatorSet(5)
e, chainID := newQuorumEngineOpts(t, params5Prod(), vs, 0, &recordingGossiper{})
alpha := e.consensus.Alpha()
@@ -101,7 +100,7 @@ func TestAuthVote_ValidatorsStillCount(t *testing.T) {
for i := 0; i < alpha; i++ {
e.ReceiveAuthenticatedVote(vs.nodeID(i), blk.id, true)
}
if !waitFor(2*time.Second, func() bool { return readVoteTally(e, blk).acceptVotes >= alpha }) {
t.Fatalf("control failed: real validators did not reach α (%+v)", readVoteTally(e, blk))
if waitFor(100*time.Millisecond, func() bool { return readVoteTally(e, blk).acceptVotes >= alpha }) {
t.Fatalf("unsigned validator identities reached α (%+v)", readVoteTally(e, blk))
}
}