Files
node/message/ops_test.go
T
zeekay b72f9edb80 message: prove the op table is a bijection onto the router op space
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.
2026-06-25 22:10:09 -07:00

116 lines
5.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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")
}