mirror of
https://github.com/luxfi/node.git
synced 2026-07-27 03:39:39 +00:00
Kills the bug CLASS behind the α-of-K finality wedge (v1.30.65), not just the one Gossip instance. The chain router (node/chain_router.go) forwards an inbound message only if message.ToConsensusOp maps it, then dispatches on the router op; the node op table and the consensus router op enum are two halves of ONE routing contract. When they diverged — router.Gossip existed but no node op mapped to it — every broadcast vote was dropped as "unhandled message op" and finality wedged, with nothing to catch it. - ToConsensusOp now returns the router constants (byte(router.Get) ...) instead of 0..11 magic numbers, so values cannot drift and the table reads as the correspondence it is. Bumps consensus v1.25.29 -> v1.25.30 (adds router.NumOps, the single source of truth for the op count). - message/ops_test.go TestToConsensusOp_TableAlignedWithRouter is now EXHAUSTIVE and BIDIRECTIONAL: it reconstructs the inverse mapping over the whole node op space and requires a bijection onto [0, router.NumOps). A router op with no node preimage (the original bug), a collision, a wrong target, or a stray mapping all go RED. Pinned to router.NumOps, so a future op added to one table but not the other fails the test instead of at runtime. Proven RED against the original bug (drop the GossipOp case -> router.Gossip loses its preimage -> fail); folds in the old Gossip-specific and hand-listed table checks. Build rc=0 (GOWORK=off go build ./...); message tests green; finality fix (GossipOp -> router.Gossip -> blockHandler.Gossip) unchanged and still covered.
116 lines
5.3 KiB
Go
116 lines
5.3 KiB
Go
// 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_TableAlignedWithRouter is the systemic guard for the
|
||
// finality-wedge bug CLASS, not just the one Gossip instance. The chain router
|
||
// (node/chain_router.go) forwards an inbound message only if ToConsensusOp maps
|
||
// it, then dispatches on handler.Op(value). The node op table and the consensus
|
||
// router op enum are therefore two halves of ONE routing contract: if they
|
||
// diverge, every message for the divergent op is silently dropped. That is what
|
||
// wedged α-of-K finality — router.Gossip existed but no node op mapped to it, so
|
||
// every broadcast vote vanished before reaching blockHandler.Gossip ->
|
||
// engine.HandleIncomingVote (blocks rode PutOp and verified; votes rode the
|
||
// unmapped GossipOp and disappeared; the cert never reached alpha).
|
||
//
|
||
// The test proves ToConsensusOp is a BIJECTION onto the router op space
|
||
// [0, router.NumOps), so divergence is un-shippable:
|
||
// - exhaustive/surjective: every router op has exactly one node-op preimage,
|
||
// so a router op added without a node mapping (the original bug) -> RED.
|
||
// - injective/well-typed: no two node ops collide onto one router op and
|
||
// nothing maps outside the op space -> RED.
|
||
//
|
||
// router.NumOps is the single source of truth for the op count, so a future op
|
||
// added to one table but not the other fails HERE, not at runtime.
|
||
func TestToConsensusOp_TableAlignedWithRouter(t *testing.T) {
|
||
require := require.New(t)
|
||
|
||
// want: the authoritative router-op <-> node-op correspondence.
|
||
want := map[router.Op]Op{
|
||
router.GetAcceptedFrontier: GetAcceptedFrontierOp,
|
||
router.AcceptedFrontier: AcceptedFrontierOp,
|
||
router.GetAccepted: GetAcceptedOp,
|
||
router.Accepted: AcceptedOp,
|
||
router.Get: GetOp,
|
||
router.Put: PutOp,
|
||
router.PushQuery: PushQueryOp,
|
||
router.PullQuery: PullQueryOp,
|
||
router.Vote: QbitOp, // votes ride the Qbit wire op
|
||
router.GetContext: GetAncestorsOp, // wire op is still GetAncestors
|
||
router.Context: AncestorsOp, // wire op is still Ancestors
|
||
router.Gossip: GossipOp, // α-of-K vote/cert transport
|
||
}
|
||
|
||
// EXHAUSTIVE: the table must name every router op exactly once. Pinned to
|
||
// router.NumOps, so a new router op with no entry here fails immediately.
|
||
require.Len(want, int(router.NumOps),
|
||
"node op table names %d ops but the router defines router.NumOps=%d — a "+
|
||
"router op with no node mapping is dropped by the chain router and "+
|
||
"finality wedges; add it to want and to ToConsensusOp",
|
||
len(want), int(router.NumOps))
|
||
|
||
// BIJECTION: rebuild the actual inverse mapping by sweeping the whole node op
|
||
// space (Op is a byte, so [0,256)) and require it to equal want. This one
|
||
// comparison catches a missing mapping (router op absent from got), a wrong
|
||
// target (got[r] != want[r]) and a stray mapping into the op space; the dup
|
||
// guard catches a collision masked by the later write winning the slot.
|
||
got := make(map[router.Op]Op, int(router.NumOps))
|
||
for i := 0; i < 256; i++ {
|
||
nodeOp := Op(i)
|
||
v, ok := ToConsensusOp(nodeOp)
|
||
if !ok {
|
||
continue
|
||
}
|
||
require.Less(int(v), int(router.NumOps),
|
||
"%s maps to router op %d outside [0, NumOps=%d)", nodeOp, v, int(router.NumOps))
|
||
routerOp := router.Op(v)
|
||
_, dup := got[routerOp]
|
||
require.False(dup,
|
||
"router op %d is mapped from two node ops (%s and %s) — ambiguous routing",
|
||
v, got[routerOp], nodeOp)
|
||
got[routerOp] = nodeOp
|
||
}
|
||
require.Equal(want, got,
|
||
"node op table diverged from the router op space: every router op "+
|
||
"[0, NumOps) must have exactly one node-op preimage and each node op must "+
|
||
"map to its assigned router op. A divergence here is the finality-wedge "+
|
||
"bug class — an op routed in one table and dropped in the other.")
|
||
}
|
||
|
||
// 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")
|
||
}
|