chains: complete proposervm wrap — define shouldWrapInProposerVM + test

The prior commit (9d9d2a8785) captured only manager.go, which CALLS
shouldWrapInProposerVM but did not include its definition, so HEAD did not
compile (chains/manager.go:1205: undefined: shouldWrapInProposerVM). This adds:

  - chains/quorum.go: shouldWrapInProposerVM(k, chainID, innerIsDAGNative) —
    the single pure policy gate (K>1 AND not P-Chain AND not DAG-native) that
    decides whether a linear chain.ChainVM is wrapped in proposervm for
    single-proposer-per-height block production.
  - chains/proposervm_wrap_test.go: table tests pinning the gate (C-Chain
    devnet/mainnet wrapped; P-Chain, X-Chain, K==1 excluded) and that the K
    flows from selectConsensusParams.
  - chains/manager.go: gofmt import ordering.

Restores a green build. No push, no tag.
This commit is contained in:
zeekay
2026-06-28 01:07:16 -07:00
parent 9d9d2a8785
commit 9b1b040aac
3 changed files with 157 additions and 1 deletions
+1 -1
View File
@@ -76,8 +76,8 @@ import (
"github.com/luxfi/utxo/mldsafx"
"github.com/luxfi/utxo/nftfx"
"github.com/luxfi/utxo/propertyfx"
"github.com/luxfi/node/vms/proposervm"
"github.com/luxfi/utxo/propertyfx"
"github.com/luxfi/utxo/schnorrfx"
"github.com/luxfi/utxo/secp256k1fx"
"github.com/luxfi/utxo/secp256r1fx"
+130
View File
@@ -0,0 +1,130 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// proposervm_wrap_test.go — pins the single policy gate that decides whether a
// linear chain.ChainVM is re-wrapped in proposervm for single-proposer-per-height
// block production (the consensus-safety fix for the equivocation crash). The
// gate is a pure function so the policy is verifiable without standing up a chain.
package chains
import (
"testing"
"github.com/luxfi/constants"
"github.com/luxfi/ids"
)
func TestShouldWrapInProposerVM(t *testing.T) {
// A native chain ID that is NOT the P-Chain (e.g. the C-Chain): first 31
// bytes zero, last byte the chain letter. Any non-platform ID exercises the
// chainID condition; this mirrors how native chain IDs are shaped.
cChainID := ids.ID{}
cChainID[ids.IDLen-1] = 'C'
xChainID := ids.ID{}
xChainID[ids.IDLen-1] = 'X'
tests := []struct {
name string
k int
chainID ids.ID
innerIsDAGNative bool
want bool
why string
}{
{
name: "C-Chain devnet K=4",
k: 4, // LocalBFTParams
chainID: cChainID,
innerIsDAGNative: false,
want: true,
why: "multi-validator EVM, not the P-Chain, not DAG — the chain that crashed; must be wrapped",
},
{
name: "C-Chain mainnet K=21",
k: 21, // MainnetParams
chainID: cChainID,
innerIsDAGNative: false,
want: true,
why: "large multi-validator EVM is wrapped exactly as avalanchego wraps it",
},
{
name: "P-Chain is excluded even at K>1",
k: 4,
chainID: constants.PlatformChainID,
innerIsDAGNative: false,
want: false,
why: "P-Chain validator state is published mid-create; its windower would be empty — keep newPChainHeightVM",
},
{
name: "X-Chain (DAG-native) is excluded",
k: 4,
chainID: xChainID,
innerIsDAGNative: true,
want: false,
why: "linearized DAG VM uses a push-notification bridge that does not compose with proposervm's window",
},
{
name: "single-node K=1 is not wrapped",
k: 1, // SingleValidatorParams
chainID: cChainID,
innerIsDAGNative: false,
want: false,
why: "one validator is trivially the sole proposer; no equivocation to prevent",
},
{
name: "K=1 P-Chain is not wrapped",
k: 1,
chainID: constants.PlatformChainID,
innerIsDAGNative: false,
want: false,
why: "K==1 short-circuits regardless of chain",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shouldWrapInProposerVM(tt.k, tt.chainID, tt.innerIsDAGNative)
if got != tt.want {
t.Fatalf("shouldWrapInProposerVM(k=%d, chainID=%s, dag=%v) = %v, want %v — %s",
tt.k, tt.chainID, tt.innerIsDAGNative, got, tt.want, tt.why)
}
})
}
}
// TestShouldWrapInProposerVM_FlowsFromSelectedParams proves the K the gate reads
// is the one selectConsensusParams produces per network, so the wrap decision is
// consistent with the BFT committee actually chosen: every sybil-protected
// network yields K>1 (C-Chain wrapped), and single-node yields K==1 (not wrapped).
func TestShouldWrapInProposerVM_FlowsFromSelectedParams(t *testing.T) {
cChainID := ids.ID{}
cChainID[ids.IDLen-1] = 'C'
cases := []struct {
name string
sybilProtection bool
networkID uint32
wantWrapCChain bool
}{
{"single-node dev", false, constants.LocalID, false},
{"devnet sybil", true, constants.DevnetID, true},
{"localnet sybil", true, constants.LocalID, true},
{"mainnet", true, constants.MainnetID, true},
{"testnet", true, constants.TestnetID, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
params := selectConsensusParams(c.sybilProtection, c.networkID)
got := shouldWrapInProposerVM(params.K, cChainID, false)
if got != c.wantWrapCChain {
t.Fatalf("network %s (sybil=%v): K=%d → wrap=%v, want %v",
c.name, c.sybilProtection, params.K, got, c.wantWrapCChain)
}
// The P-Chain is never wrapped, whatever the committee.
if shouldWrapInProposerVM(params.K, constants.PlatformChainID, false) {
t.Fatalf("network %s: P-Chain must never be wrapped (K=%d)", c.name, params.K)
}
})
}
}
+26
View File
@@ -87,6 +87,32 @@ func selectConsensusParams(sybilProtection bool, networkID uint32) consensusconf
}
}
// shouldWrapInProposerVM decides whether a linear chain.ChainVM is wrapped in
// proposervm to enforce single-proposer-per-height block production (the
// Snowman++ window). It is the SINGLE policy gate (the manager calls it once);
// keeping it a pure function makes the policy unit-testable without standing up
// a whole chain. All three conditions must hold:
//
// - k > 1: a multi-validator quorum. K==1 (single-node --dev) has exactly one
// proposer already, so there is no equivocation to prevent and no schedule
// to compute (proposervm would only add a wrapper with no safety value).
// - chainID is NOT the P-Chain: the P-Chain publishes its OWN height-indexed
// validators.State DURING its createChain, AFTER the chainRuntime snapshot
// proposervm's windower reads — so its windower would see an empty set and
// fall back to anyone-can-propose with a P-chain-height-0 stamp. The P-Chain
// keeps the existing newPChainHeightVM path (which DOES get the live state).
// - inner is NOT DAG-native (no Linearize): a linearized DAG VM (X-Chain) is
// driven by a push-notification bridge that does not compose with
// proposervm's pull/window model without avalanchego's initializeOnLinearizeVM
// machinery. It keeps the existing path.
//
// The C-Chain and sovereign-L1 EVM chains satisfy all three (multi-validator,
// not the P-Chain, not DAG) — they are exactly the chains that exhibited the
// equivocation crash, and exactly the chains avalanchego wraps in proposervm.
func shouldWrapInProposerVM(k int, chainID ids.ID, innerIsDAGNative bool) bool {
return k > 1 && chainID != constants.PlatformChainID && !innerIsDAGNative
}
// --- BLS vote verifier -------------------------------------------------------
// blsVoteVerifier verifies a validator's BLS signature over the canonical vote