fix(consensus): route GossipOp votes to the engine — un-wedge α-of-K finality

The α-of-K quorum vote/cert transport rides on app-gossip (GossipOp), but
GossipOp was never added to the chain router's consensus-op table
(message.ToConsensusOp) nor to blockHandler.HandleInbound's switch. So every
inbound vote was dropped at node/chain_router.go as "unhandled message op"
before it could reach blockHandler.Gossip -> engine.HandleIncomingVote. Blocks
ride PutOp (mapped) and propagated+verified on all validators; votes ride
GossipOp (unmapped) and vanished. Each node held only its own self-vote, the
cert never reached alpha, and every chain wedged at height N: "verified but
never accepted", no vote/cert/accept activity.

This is the fork divergence from avalanchego, where votes are Chits — a
first-class consensus op always in the router table (snow/engine/snowman
engine.go Chits -> voter -> topological.RecordPoll -> accept). Lux moved votes
onto app-gossip but left the node-side op routing incomplete; the consensus
repo already reserved router.Gossip=11 'routed via the blockHandler Gossip
method' (core/router/router.go) — only the node wiring was missing.

Fix (node-only; safety core untouched — this is purely liveness):
  - message.ToConsensusOp: map GossipOp -> 11 (router.Gossip)
  - blockHandler.HandleInbound: add case handler.Gossip -> b.Gossip(...)

b.Gossip already demuxes the quorum envelope (Mode-gated) into
HandleIncomingVote / HandleIncomingCert; the signed-vote verify + verified-2/3
-stake cert assembly are unchanged, so honest votes now reach the assembler
without weakening VerifyWeighted or the unforgeable cert.

Regression guard (message/ops_test.go): GossipOp must map to router.Gossip, the
full op table must stay aligned with the consensus router, and an inbound vote
gossip must survive router extraction (op + envelope bytes) intact.
This commit is contained in:
zeekay
2026-06-25 21:24:14 -07:00
parent f88033df9c
commit f504b3424f
3 changed files with 112 additions and 0 deletions
+8
View File
@@ -3103,6 +3103,14 @@ func (b *blockHandler) HandleInbound(ctx context.Context, msg handler.Message) e
case handler.Context:
// Context contains prerequisite blocks - process each one via Put
return b.handleContext(ctx, msg.NodeID, msg.RequestID, msg.Message)
case handler.Gossip:
// App-gossip envelope carrying an α-of-K quorum vote or finality cert.
// Route to Gossip, which demuxes the quorum envelope into
// engine.HandleIncomingVote / HandleIncomingCert (the vote TRANSPORT that
// drives finality). Without this case the router delivers the message here
// but the switch drops it, so no vote is ever counted and the chain wedges.
// Non-quorum gossip falls through inside Gossip to a plain block Put.
return b.Gossip(ctx, msg.NodeID, msg.Message)
}
return nil
}
+9
View File
@@ -272,6 +272,15 @@ func ToConsensusOp(op Op) (byte, bool) {
return 9, true // GetContext (wire protocol still uses GetAncestors)
case AncestorsOp:
return 10, true // Context (wire protocol still uses Ancestors)
case GossipOp:
// Gossip (= router.Gossip). The α-of-K quorum vote/cert transport rides on
// app-gossip. Without this mapping the chain router drops every inbound vote
// as "unhandled message op" before it reaches blockHandler.Gossip ->
// engine.HandleIncomingVote, so the finality cert never assembles and the
// chain wedges (blocks verify but never accept). Routed to blockHandler's
// Gossip method, which demuxes the quorum envelope. MUST match the value the
// consensus side decodes it as (TestToConsensusOp_TableAlignedWithRouter).
return 11, true // Gossip
default:
return 0, false
}
+95
View File
@@ -0,0 +1,95 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package message
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/luxfi/consensus/core/router"
"github.com/luxfi/ids"
)
// TestToConsensusOp_GossipIsRouted is the regression guard for the finality
// wedge: the α-of-K quorum vote/cert transport rides on app-gossip (GossipOp).
// The chain router (node/chain_router.go) gates EVERY inbound message through
// ToConsensusOp; an op that does not map is dropped as "unhandled message op"
// BEFORE it can reach the engine. GossipOp was missing from the table, so every
// broadcast vote was silently dropped at the router — blocks (PutOp) propagated
// and verified, but no vote ever reached HandleIncomingVote, the cert never
// assembled, and the chain wedged at "verified but never accepted".
//
// This asserts GossipOp maps to the consensus router's Gossip op so votes are
// delivered to blockHandler.Gossip -> engine.HandleIncomingVote.
func TestToConsensusOp_GossipIsRouted(t *testing.T) {
require := require.New(t)
got, ok := ToConsensusOp(GossipOp)
require.True(ok, "GossipOp MUST map to a consensus router op — votes ride app-gossip; "+
"an unmapped GossipOp is dropped by the chain router and finality wedges")
require.Equal(byte(router.Gossip), got,
"GossipOp must map to router.Gossip so blockHandler routes it to engine.HandleIncomingVote")
}
// TestToConsensusOp_TableAlignedWithRouter pins the full message-op -> router-op
// table. Every op the chain router forwards to a consensus handler must map to
// the SAME byte the consensus side decodes it as (handler.Op == router.Op). A
// drift here re-breaks routing for that op exactly as the missing GossipOp broke
// vote delivery.
func TestToConsensusOp_TableAlignedWithRouter(t *testing.T) {
require := require.New(t)
cases := []struct {
name string
in Op
want router.Op
}{
{"GetAcceptedFrontier", GetAcceptedFrontierOp, router.GetAcceptedFrontier},
{"AcceptedFrontier", AcceptedFrontierOp, router.AcceptedFrontier},
{"GetAccepted", GetAcceptedOp, router.GetAccepted},
{"Accepted", AcceptedOp, router.Accepted},
{"Get", GetOp, router.Get},
{"Put", PutOp, router.Put},
{"PushQuery", PushQueryOp, router.PushQuery},
{"PullQuery", PullQueryOp, router.PullQuery},
{"Qbit/Vote", QbitOp, router.Vote},
{"GetAncestors/GetContext", GetAncestorsOp, router.GetContext},
{"Ancestors/Context", AncestorsOp, router.Context},
{"Gossip", GossipOp, router.Gossip},
}
for _, tc := range cases {
got, ok := ToConsensusOp(tc.in)
require.True(ok, "%s must map to a consensus router op", tc.name)
require.Equal(byte(tc.want), got, "%s maps to the wrong router op", tc.name)
}
}
// TestInboundGossip_DeliveredNotDropped reproduces the chain router's inbound
// extraction for a quorum vote envelope (the exact steps node/chain_router.go
// performs before dispatch) and asserts the vote SURVIVES routing: the op maps
// to router.Gossip AND the envelope bytes are recovered intact as the container.
// This is the seam the finality wedge lived in — a vote that the router dropped
// here never reached the engine. The bytes that come out here are what
// blockHandler.Gossip demuxes into engine.HandleIncomingVote.
func TestInboundGossip_DeliveredNotDropped(t *testing.T) {
require := require.New(t)
chainID := ids.GenerateTestID()
nodeID := ids.GenerateTestNodeID()
// Stand-in for an encodeQuorumGossip envelope: magic + kind + blockID + vote.
envelope := []byte("LXQ\x01" + "<signed-vote-payload>")
inbound := InboundGossip(chainID, envelope, nodeID)
// 1) The router maps the op (else "unhandled message op" -> dropped).
consensusOp, ok := ToConsensusOp(inbound.Op())
require.True(ok, "inbound vote gossip must route to a consensus op")
require.Equal(byte(router.Gossip), consensusOp)
// 2) The router recovers the envelope as the handler container bytes.
got := GetContainerBytes(inbound.Message())
require.Equal(envelope, got, "the quorum envelope must survive router extraction intact")
}