2026-06-25 21:24:14 -07:00
|
|
|
|
// 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"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-25 22:10:09 -07:00
|
|
|
|
// 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).
|
2026-06-25 21:24:14 -07:00
|
|
|
|
//
|
2026-06-25 22:10:09 -07:00
|
|
|
|
// 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.
|
2026-06-25 21:24:14 -07:00
|
|
|
|
func TestToConsensusOp_TableAlignedWithRouter(t *testing.T) {
|
|
|
|
|
|
require := require.New(t)
|
|
|
|
|
|
|
2026-06-25 22:10:09 -07:00
|
|
|
|
// 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
|
2026-06-25 21:24:14 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-25 22:10:09 -07:00
|
|
|
|
// 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
|
2026-06-25 21:24:14 -07:00
|
|
|
|
}
|
2026-06-25 22:10:09 -07:00
|
|
|
|
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.")
|
2026-06-25 21:24:14 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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")
|
|
|
|
|
|
}
|