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.
This commit is contained in:
zeekay
2026-06-25 22:10:09 -07:00
parent f504b3424f
commit b72f9edb80
4 changed files with 93 additions and 68 deletions
+2 -1
View File
@@ -26,7 +26,7 @@ require (
github.com/huin/goupnp v1.3.0 github.com/huin/goupnp v1.3.0
github.com/jackpal/gateway v1.1.1 github.com/jackpal/gateway v1.1.1
github.com/jackpal/go-nat-pmp v1.0.2 github.com/jackpal/go-nat-pmp v1.0.2
github.com/luxfi/consensus v1.25.29 github.com/luxfi/consensus v1.25.30
github.com/luxfi/crypto v1.19.26 github.com/luxfi/crypto v1.19.26
github.com/luxfi/database v1.20.4 github.com/luxfi/database v1.20.4
github.com/luxfi/ids v1.2.15 github.com/luxfi/ids v1.2.15
@@ -188,6 +188,7 @@ require (
github.com/hanzos3/go-sdk v1.0.2 // indirect github.com/hanzos3/go-sdk v1.0.2 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect github.com/klauspost/crc32 v1.3.0 // indirect
github.com/luxfi/age v1.5.0 // indirect github.com/luxfi/age v1.5.0 // indirect
github.com/luxfi/bft v0.1.5 // indirect
github.com/luxfi/corona v0.7.9 // indirect github.com/luxfi/corona v0.7.9 // indirect
github.com/luxfi/crypto/ipa v1.2.4 // indirect github.com/luxfi/crypto/ipa v1.2.4 // indirect
github.com/luxfi/kms v1.11.7 // indirect github.com/luxfi/kms v1.11.7 // indirect
+4
View File
@@ -300,6 +300,8 @@ github.com/luxfi/api v1.0.15 h1:Q5ox3Ompw/AZNMfB9wpHZosrj9C+ZldAEHKtHEotFdY=
github.com/luxfi/api v1.0.15/go.mod h1:Eer59msIXMnOlFncG0XjEGH3TZML0Dd1bUu4GtB7f4Q= github.com/luxfi/api v1.0.15/go.mod h1:Eer59msIXMnOlFncG0XjEGH3TZML0Dd1bUu4GtB7f4Q=
github.com/luxfi/atomic v1.0.0 h1:xUV60MuzRvXngaQ1sM0yVC2v4TRoLlUGkkH7M9PS4yw= github.com/luxfi/atomic v1.0.0 h1:xUV60MuzRvXngaQ1sM0yVC2v4TRoLlUGkkH7M9PS4yw=
github.com/luxfi/atomic v1.0.0/go.mod h1:0G2mTlQ6TXWHICUHrUUPu1/qAiIyR4gSZ2tva9ci/bI= github.com/luxfi/atomic v1.0.0/go.mod h1:0G2mTlQ6TXWHICUHrUUPu1/qAiIyR4gSZ2tva9ci/bI=
github.com/luxfi/bft v0.1.5 h1:5xVLPkog4e5LTgaVlb9pgxA0EWE6tkrKwHPZVRz+RZw=
github.com/luxfi/bft v0.1.5/go.mod h1:5I8Ft8yA69xZlDe3RB0i4MgbqFKLZe65o/sha8JuKvU=
github.com/luxfi/cache v1.2.1 h1:kAzOS55/hmYeNKR+0HAKv4ma48Y6JjkI8UQeqdZ8bfI= github.com/luxfi/cache v1.2.1 h1:kAzOS55/hmYeNKR+0HAKv4ma48Y6JjkI8UQeqdZ8bfI=
github.com/luxfi/cache v1.2.1/go.mod h1:co7JTxZZHpKT31Yh01LFp5aZOxmoUg157FhBLQdQHVU= github.com/luxfi/cache v1.2.1/go.mod h1:co7JTxZZHpKT31Yh01LFp5aZOxmoUg157FhBLQdQHVU=
github.com/luxfi/chains v1.3.21 h1:Oe3/T0MnMKYBVzd+wK13R5qzRGQEMytysbs3PkCLWNc= github.com/luxfi/chains v1.3.21 h1:Oe3/T0MnMKYBVzd+wK13R5qzRGQEMytysbs3PkCLWNc=
@@ -312,6 +314,8 @@ github.com/luxfi/concurrent v0.0.3 h1:eJyv1fhaC0jMLMw6+QS774cUmp7GK+ouMgvLCqnC7c
github.com/luxfi/concurrent v0.0.3/go.mod h1:Aj/FR5NpM0cB2P4Nt3+tz9+dV6V+LUW4HuMgSjwq5hw= github.com/luxfi/concurrent v0.0.3/go.mod h1:Aj/FR5NpM0cB2P4Nt3+tz9+dV6V+LUW4HuMgSjwq5hw=
github.com/luxfi/consensus v1.25.29 h1:z9UWFnuBY7XtTJ6klA7u8UV8ZV5JjHqh+jfKqcRcUPU= github.com/luxfi/consensus v1.25.29 h1:z9UWFnuBY7XtTJ6klA7u8UV8ZV5JjHqh+jfKqcRcUPU=
github.com/luxfi/consensus v1.25.29/go.mod h1:cerfisfzmUJv8gbMcjcQUSdxlLpfn/+LdLY+4D95Nw0= github.com/luxfi/consensus v1.25.29/go.mod h1:cerfisfzmUJv8gbMcjcQUSdxlLpfn/+LdLY+4D95Nw0=
github.com/luxfi/consensus v1.25.30 h1:rxNFKKTJ7hMRGbePBi+XX51Nr1VUaeSTLIbSP3qdMac=
github.com/luxfi/consensus v1.25.30/go.mod h1:cerfisfzmUJv8gbMcjcQUSdxlLpfn/+LdLY+4D95Nw0=
github.com/luxfi/constants v1.5.8 h1:iNP9AWNUcM4Tps7jYnx49CwtCWAC9mYRxJfGou2za0g= github.com/luxfi/constants v1.5.8 h1:iNP9AWNUcM4Tps7jYnx49CwtCWAC9mYRxJfGou2za0g=
github.com/luxfi/constants v1.5.8/go.mod h1:Pu5jWHdnUtQRbWC43yTUjU/pbIIKMDOd2a2yroSfo48= github.com/luxfi/constants v1.5.8/go.mod h1:Pu5jWHdnUtQRbWC43yTUjU/pbIIKMDOd2a2yroSfo48=
github.com/luxfi/container v0.0.4 h1:BXhF82WyfqVP5mjlNcr7tP0Fcnvl0Ap1rkiu+rq5XuM= github.com/luxfi/container v0.0.4 h1:BXhF82WyfqVP5mjlNcr7tP0Fcnvl0Ap1rkiu+rq5XuM=
+21 -21
View File
@@ -8,6 +8,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/luxfi/consensus/core/router"
"github.com/luxfi/math/set" "github.com/luxfi/math/set"
"github.com/luxfi/node/proto/p2p" "github.com/luxfi/node/proto/p2p"
) )
@@ -246,41 +247,40 @@ func Unwrap(m *p2p.Message) (fmt.Stringer, error) {
} }
} }
// ToConsensusOp maps message.Op to consensus router Op values // ToConsensusOp maps a wire message Op to the consensus router op the chain
// Returns the consensus Op value and whether the mapping exists // router (node/chain_router.go) dispatches on, returning false for ops that are
// not consensus-routed (the router drops those as "unhandled message op"). The
// returned values ARE the router constants, so they cannot drift in value; the
// mapping must also stay a bijection onto the router op space [0, router.NumOps)
// — proven by message/ops_test.go. A missing case silently drops that op: that
// is the α-of-K finality wedge, where GossipOp had no case and every vote
// vanished before reaching blockHandler.Gossip -> engine.HandleIncomingVote.
func ToConsensusOp(op Op) (byte, bool) { func ToConsensusOp(op Op) (byte, bool) {
switch op { switch op {
case GetAcceptedFrontierOp: case GetAcceptedFrontierOp:
return 0, true // GetAcceptedFrontier return byte(router.GetAcceptedFrontier), true
case AcceptedFrontierOp: case AcceptedFrontierOp:
return 1, true // AcceptedFrontier return byte(router.AcceptedFrontier), true
case GetAcceptedOp: case GetAcceptedOp:
return 2, true // GetAccepted return byte(router.GetAccepted), true
case AcceptedOp: case AcceptedOp:
return 3, true // Accepted return byte(router.Accepted), true
case GetOp: case GetOp:
return 4, true // Get return byte(router.Get), true
case PutOp: case PutOp:
return 5, true // Put return byte(router.Put), true
case PushQueryOp: case PushQueryOp:
return 6, true // PushQuery return byte(router.PushQuery), true
case PullQueryOp: case PullQueryOp:
return 7, true // PullQuery return byte(router.PullQuery), true
case QbitOp: case QbitOp:
return 8, true // Qbit return byte(router.Vote), true // votes ride the Qbit wire op
case GetAncestorsOp: case GetAncestorsOp:
return 9, true // GetContext (wire protocol still uses GetAncestors) return byte(router.GetContext), true // wire op is still GetAncestors
case AncestorsOp: case AncestorsOp:
return 10, true // Context (wire protocol still uses Ancestors) return byte(router.Context), true // wire op is still Ancestors
case GossipOp: case GossipOp:
// Gossip (= router.Gossip). The α-of-K quorum vote/cert transport rides on return byte(router.Gossip), true // α-of-K vote/cert transport
// 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: default:
return 0, false return 0, false
} }
+66 -46
View File
@@ -12,59 +12,79 @@ import (
"github.com/luxfi/ids" "github.com/luxfi/ids"
) )
// TestToConsensusOp_GossipIsRouted is the regression guard for the finality // TestToConsensusOp_TableAlignedWithRouter is the systemic guard for the
// wedge: the α-of-K quorum vote/cert transport rides on app-gossip (GossipOp). // finality-wedge bug CLASS, not just the one Gossip instance. The chain router
// The chain router (node/chain_router.go) gates EVERY inbound message through // (node/chain_router.go) forwards an inbound message only if ToConsensusOp maps
// ToConsensusOp; an op that does not map is dropped as "unhandled message op" // it, then dispatches on handler.Op(value). The node op table and the consensus
// BEFORE it can reach the engine. GossipOp was missing from the table, so every // router op enum are therefore two halves of ONE routing contract: if they
// broadcast vote was silently dropped at the router — blocks (PutOp) propagated // diverge, every message for the divergent op is silently dropped. That is what
// and verified, but no vote ever reached HandleIncomingVote, the cert never // wedged α-of-K finality — router.Gossip existed but no node op mapped to it, so
// assembled, and the chain wedged at "verified but never accepted". // 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).
// //
// This asserts GossipOp maps to the consensus router's Gossip op so votes are // The test proves ToConsensusOp is a BIJECTION onto the router op space
// delivered to blockHandler.Gossip -> engine.HandleIncomingVote. // [0, router.NumOps), so divergence is un-shippable:
func TestToConsensusOp_GossipIsRouted(t *testing.T) { // - exhaustive/surjective: every router op has exactly one node-op preimage,
require := require.New(t) // 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
got, ok := ToConsensusOp(GossipOp) // nothing maps outside the op space -> RED.
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") // router.NumOps is the single source of truth for the op count, so a future op
require.Equal(byte(router.Gossip), got, // added to one table but not the other fails HERE, not at runtime.
"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) { func TestToConsensusOp_TableAlignedWithRouter(t *testing.T) {
require := require.New(t) require := require.New(t)
cases := []struct { // want: the authoritative router-op <-> node-op correspondence.
name string want := map[router.Op]Op{
in Op router.GetAcceptedFrontier: GetAcceptedFrontierOp,
want router.Op router.AcceptedFrontier: AcceptedFrontierOp,
}{ router.GetAccepted: GetAcceptedOp,
{"GetAcceptedFrontier", GetAcceptedFrontierOp, router.GetAcceptedFrontier}, router.Accepted: AcceptedOp,
{"AcceptedFrontier", AcceptedFrontierOp, router.AcceptedFrontier}, router.Get: GetOp,
{"GetAccepted", GetAcceptedOp, router.GetAccepted}, router.Put: PutOp,
{"Accepted", AcceptedOp, router.Accepted}, router.PushQuery: PushQueryOp,
{"Get", GetOp, router.Get}, router.PullQuery: PullQueryOp,
{"Put", PutOp, router.Put}, router.Vote: QbitOp, // votes ride the Qbit wire op
{"PushQuery", PushQueryOp, router.PushQuery}, router.GetContext: GetAncestorsOp, // wire op is still GetAncestors
{"PullQuery", PullQueryOp, router.PullQuery}, router.Context: AncestorsOp, // wire op is still Ancestors
{"Qbit/Vote", QbitOp, router.Vote}, router.Gossip: GossipOp, // α-of-K vote/cert transport
{"GetAncestors/GetContext", GetAncestorsOp, router.GetContext},
{"Ancestors/Context", AncestorsOp, router.Context},
{"Gossip", GossipOp, router.Gossip},
} }
for _, tc := range cases { // EXHAUSTIVE: the table must name every router op exactly once. Pinned to
got, ok := ToConsensusOp(tc.in) // router.NumOps, so a new router op with no entry here fails immediately.
require.True(ok, "%s must map to a consensus router op", tc.name) require.Len(want, int(router.NumOps),
require.Equal(byte(tc.want), got, "%s maps to the wrong router op", tc.name) "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 // TestInboundGossip_DeliveredNotDropped reproduces the chain router's inbound