mirror of
https://github.com/luxfi/node.git
synced 2026-10-30 15:24:05 +00:00
Two packages were named for the same word and meant different things. network/ dialed peers, exchanged keys, throttled and tracked them; nets/ tracked which chains share a validator set and when they have all bootstrapped. One letter apart, and neither name said which was which. The peer layer is mesh — the word this estate already uses for it, in lux-rs/node's own mesh.rs. That frees network for the thing that is actually a network: a set of chains sharing a validator set. A network holds chains; a chain belongs to one network. github.com/luxfi/p2p stays what it is, the gossip library the VMs use. Three concerns, three names, none of them borrowed from another. Co-authored-by: Hanzo Dev <[email protected]>
851 lines
31 KiB
Go
851 lines
31 KiB
Go
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package mesh
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"slices"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/sync/errgroup"
|
|
|
|
"github.com/luxfi/constants"
|
|
"github.com/luxfi/crypto/bls"
|
|
"github.com/luxfi/crypto/bls/signer/localsigner"
|
|
"github.com/luxfi/formatting"
|
|
genesisconfigs "github.com/luxfi/genesis/configs"
|
|
genesiscfg "github.com/luxfi/genesis/pkg/genesis"
|
|
"github.com/luxfi/ids"
|
|
"github.com/luxfi/log"
|
|
"github.com/luxfi/math/set"
|
|
"github.com/luxfi/metric"
|
|
"github.com/luxfi/net/endpoints"
|
|
"github.com/luxfi/node/version"
|
|
lux "github.com/luxfi/utxo"
|
|
"github.com/luxfi/utxo/secp256k1fx"
|
|
validators "github.com/luxfi/validators"
|
|
"github.com/luxfi/validators/uptime"
|
|
|
|
"github.com/luxfi/node/genesis/builder"
|
|
"github.com/luxfi/node/upgrade"
|
|
"github.com/luxfi/node/vms/platformvm/genesis"
|
|
"github.com/luxfi/node/vms/platformvm/signer"
|
|
"github.com/luxfi/node/vms/platformvm/txs"
|
|
)
|
|
|
|
// genesisBytesForValidator builds real P-chain genesis bytes declaring [nodeID]
|
|
// as a validator whose signer proves possession of [sk] — the shape the node is
|
|
// actually started with, parsed by the same genesis.Parse NewNetwork calls.
|
|
func genesisBytesForValidator(t *testing.T, nodeID ids.NodeID, sk bls.Signer) []byte {
|
|
t.Helper()
|
|
|
|
return genesisBytesFor(t, declaration{nodeID, popFor(t, sk)})
|
|
}
|
|
|
|
// runGenesisSeededPeer starts two real networks and lets them handshake.
|
|
//
|
|
// Node 0 is the observer: its validator set is seeded ONLY by NewNetwork from
|
|
// [observerGenesis], so whatever the genesis-seeding path computes is the key
|
|
// its peer.shouldDisconnect checks node 1's signed IP against. Node 1 is an
|
|
// ordinary peer signing its IP with its own BLS key. Reports whether node 0
|
|
// accepted node 1.
|
|
func runGenesisSeededPeer(t *testing.T, observerGenesis func(peerNodeID ids.NodeID, peerKey bls.Signer) []byte) bool {
|
|
t.Helper()
|
|
require := require.New(t)
|
|
|
|
dialer, listeners, nodeIDs, configs := newTestNetwork(t, 2)
|
|
|
|
connected := make(chan struct{})
|
|
var closeOnce bool
|
|
|
|
networks := make([]Network, len(configs))
|
|
for i, config := range configs {
|
|
config.Beacons = validators.NewManager()
|
|
config.Validators = validators.NewManager()
|
|
config.TrackedChains = set.NewSet[ids.ID](0)
|
|
config.UptimeCalculator = &uptime.NoOpCalculator{}
|
|
|
|
handler := &testHandler{}
|
|
if i == 0 {
|
|
// The observer. Its whole validator set comes from genesis seeding.
|
|
config.GenesisBytes = observerGenesis(nodeIDs[1], configs[1].BLSKey)
|
|
handler.ConnectedF = func(nodeID ids.NodeID, _ *version.Application, _ ids.ID) {
|
|
if nodeID == nodeIDs[1] && !closeOnce {
|
|
closeOnce = true
|
|
close(connected)
|
|
}
|
|
}
|
|
}
|
|
|
|
net, err := NewNetwork(
|
|
config,
|
|
upgrade.InitiallyActiveTime,
|
|
newMessageCreator(t),
|
|
metric.NewNoOpRegistry(),
|
|
log.NewNoOpLogger(),
|
|
listeners[i],
|
|
dialer,
|
|
handler,
|
|
)
|
|
require.NoError(err)
|
|
networks[i] = net
|
|
}
|
|
|
|
// The seeding ran inside NewNetwork. Confirm the observer is actually
|
|
// checking node 1 — a validator entry with no key is never checked at all,
|
|
// and this test would then be asserting nothing.
|
|
vdr, ok := networks[0].(*mesh).config.Validators.GetValidator(constants.PrimaryNetworkID, nodeIDs[1])
|
|
require.True(ok, "genesis seeding did not register the peer as a validator")
|
|
require.NotEmpty(vdr.PublicKey,
|
|
"genesis seeding registered the peer with NO key, so its signed IP is never verified")
|
|
|
|
eg := &errgroup.Group{}
|
|
for i, net := range networks {
|
|
if i == 0 {
|
|
net.ManuallyTrack(configs[1].MyNodeID, endpoints.NewIPEndpoint(configs[1].MyIPPort.Get()))
|
|
}
|
|
eg.Go(net.Dispatch)
|
|
}
|
|
defer func() {
|
|
for _, net := range networks {
|
|
net.StartClose()
|
|
}
|
|
require.NoError(eg.Wait())
|
|
}()
|
|
|
|
select {
|
|
case <-connected:
|
|
return true
|
|
case <-time.After(5 * time.Second):
|
|
return false
|
|
}
|
|
}
|
|
|
|
// TestGenesisValidatorKeyIsUsableByPeerVerification drives the seeded key
|
|
// through the consumer that reads it.
|
|
//
|
|
// NewNetwork registers the genesis validators into the node's validators.Manager;
|
|
// peer.shouldDisconnect reads that entry to check a peer's signed IP against its
|
|
// BLS key. The two halves have to agree on the key, and both of the seeding
|
|
// paths produced one shouldDisconnect could not use — so for the whole bootstrap
|
|
// window genesis validators went either unverified or dropped outright.
|
|
//
|
|
// Both cases below are load-bearing, and they fail for opposite reasons:
|
|
//
|
|
// - Registering the COMPRESSED form (48 bytes) where peer.go parses
|
|
// uncompressed makes the key unreadable, and the honest peer is dropped.
|
|
// - Registering NO key at all makes shouldDisconnect short-circuit before it
|
|
// verifies anything, and the forged peer is admitted.
|
|
func TestGenesisValidatorKeyIsUsableByPeerVerification(t *testing.T) {
|
|
t.Run("honest peer is not dropped", func(t *testing.T) {
|
|
// Genesis names the peer's real key, and the peer signs its IP with it.
|
|
accepted := runGenesisSeededPeer(t, func(peerNodeID ids.NodeID, peerKey bls.Signer) []byte {
|
|
return genesisBytesForValidator(t, peerNodeID, peerKey)
|
|
})
|
|
require.True(t, accepted,
|
|
"a genesis validator signing its IP with the key genesis declares was DROPPED")
|
|
})
|
|
|
|
t.Run("forged peer is dropped", func(t *testing.T) {
|
|
// Genesis names a different key than the peer holds, so the peer's
|
|
// signed IP is a forgery for the validator it claims to be.
|
|
accepted := runGenesisSeededPeer(t, func(peerNodeID ids.NodeID, _ bls.Signer) []byte {
|
|
attacker, err := localsigner.New()
|
|
require.NoError(t, err)
|
|
return genesisBytesForValidator(t, peerNodeID, attacker)
|
|
})
|
|
require.False(t, accepted,
|
|
"a peer whose signed IP does not match the key genesis declares was ADMITTED")
|
|
})
|
|
}
|
|
|
|
// TestCanonicalGenesisConfigKeyIsUsableByPeerVerification covers the other
|
|
// seeding path — the canonical config NewNetwork falls back to when the node is
|
|
// started without genesis bytes.
|
|
//
|
|
// That config carries the key as a 0x-prefixed hex string of the COMPRESSED
|
|
// key, the same field genesis/builder decodes with formatting.HexNC. Base64
|
|
// does not fail cleanly on hex — every character is in its alphabet — so
|
|
// decoding it that way returned a short read of garbage plus an error that was
|
|
// discarded. Garbage of non-zero length passes shouldDisconnect's has-a-key
|
|
// guard and then fails its uncompressed reader, which drops precisely the
|
|
// validators the node needs in order to sync.
|
|
//
|
|
// These stakers have fixed node IDs, so no peer in a test can hold their keys
|
|
// and complete a handshake. What is checkable is the thing shouldDisconnect
|
|
// actually does with the entry: parse it with
|
|
// bls.PublicKeyFromValidUncompressedBytes, and get back the declared key.
|
|
func TestCanonicalGenesisConfigKeyIsUsableByPeerVerification(t *testing.T) {
|
|
require := require.New(t)
|
|
|
|
genesisConfig, err := builder.GetConfig(constants.LocalID)
|
|
require.NoError(err)
|
|
|
|
declared := map[ids.NodeID]string{}
|
|
for _, staker := range genesisConfig.InitialStakers {
|
|
if staker.Signer != nil && staker.Signer.PublicKey != "" {
|
|
declared[staker.NodeID] = staker.Signer.PublicKey
|
|
}
|
|
}
|
|
require.NotEmpty(declared, "canonical config carries no keyed stakers, so this test would assert nothing")
|
|
|
|
_, listeners, _, configs := newTestNetwork(t, 1)
|
|
config := configs[0]
|
|
config.Beacons = validators.NewManager()
|
|
config.Validators = validators.NewManager()
|
|
config.TrackedChains = set.NewSet[ids.ID](0)
|
|
config.UptimeCalculator = &uptime.NoOpCalculator{}
|
|
// No genesis bytes, so NewNetwork falls back to the canonical config.
|
|
config.GenesisBytes = nil
|
|
config.NetworkID = constants.LocalID
|
|
|
|
net, err := NewNetwork(
|
|
config,
|
|
upgrade.InitiallyActiveTime,
|
|
newMessageCreator(t),
|
|
metric.NewNoOpRegistry(),
|
|
log.NewNoOpLogger(),
|
|
listeners[0],
|
|
newTestDialer(),
|
|
&testHandler{},
|
|
)
|
|
require.NoError(err)
|
|
defer net.StartClose()
|
|
|
|
for nodeID, publicKey := range declared {
|
|
vdr, ok := config.Validators.GetValidator(constants.PrimaryNetworkID, nodeID)
|
|
require.True(ok, "canonical genesis staker %s was not seeded", nodeID)
|
|
|
|
// The reader peer.shouldDisconnect uses. A key it cannot parse makes the
|
|
// peer disconnect on sight.
|
|
got := bls.PublicKeyFromValidUncompressedBytes(vdr.PublicKey)
|
|
require.NotNil(got,
|
|
"canonical genesis staker %s was seeded with a key shouldDisconnect cannot parse, so it is dropped on sight", nodeID)
|
|
|
|
// And it is the key the config declares, not some other bytes that
|
|
// happen to parse.
|
|
want, err := formatting.Decode(formatting.HexNC, publicKey)
|
|
require.NoError(err)
|
|
require.Equal(want, bls.PublicKeyToCompressedBytes(got),
|
|
"canonical genesis staker %s was seeded with a DIFFERENT key than the config declares", nodeID)
|
|
}
|
|
}
|
|
|
|
// declaration is a genesis entry under test: a node ID and the signer genesis
|
|
// declares for it.
|
|
type declaration struct {
|
|
nodeID ids.NodeID
|
|
sig signer.Signer
|
|
}
|
|
|
|
// popFor is [sk]'s proof that it holds its own key.
|
|
func popFor(t *testing.T, sk bls.Signer) *signer.ProofOfPossession {
|
|
t.Helper()
|
|
|
|
pop, err := signer.NewProofOfPossession(sk)
|
|
require.NoError(t, err)
|
|
return pop
|
|
}
|
|
|
|
// honestPoP is a fresh key together with its own possession proof.
|
|
func honestPoP(t *testing.T) *signer.ProofOfPossession {
|
|
t.Helper()
|
|
|
|
sk, err := localsigner.New()
|
|
require.NoError(t, err)
|
|
return popFor(t, sk)
|
|
}
|
|
|
|
// forgedPoP is a well-formed key carrying somebody else's possession proof.
|
|
//
|
|
// Both halves are valid curve points, so it survives the wire round trip and
|
|
// its key parses. Only the pairing rejects it — which is exactly the case
|
|
// tx.PublicKey() reports as an error, and the only forgery worth testing: one
|
|
// whose key did not parse would prove nothing about the seeding decision.
|
|
func forgedPoP(t *testing.T) *signer.ProofOfPossession {
|
|
t.Helper()
|
|
require := require.New(t)
|
|
|
|
forged := *honestPoP(t)
|
|
forged.ProofOfPossession = honestPoP(t).ProofOfPossession
|
|
|
|
parsed, err := bls.PublicKeyFromCompressedBytes(forged.PublicKey[:])
|
|
require.NoError(err, "the forged signer's key must parse — otherwise this proves nothing")
|
|
require.NotNil(parsed)
|
|
require.Error(forged.Verify(), "a forged possession proof must not verify")
|
|
|
|
return &forged
|
|
}
|
|
|
|
// genesisBytesFor builds real P-chain genesis declaring one validator per
|
|
// entry, using the same encoder that writes the genesis a node is started with.
|
|
func genesisBytesFor(t *testing.T, vdrs ...declaration) []byte {
|
|
t.Helper()
|
|
require := require.New(t)
|
|
|
|
txsIn := make([]*txs.Tx, 0, len(vdrs))
|
|
for _, v := range vdrs {
|
|
utx, err := txs.NewAddPermissionlessValidatorTx(
|
|
&lux.BaseTx{},
|
|
txs.Validator{NodeID: v.nodeID, Wght: 100},
|
|
constants.PrimaryNetworkID,
|
|
v.sig,
|
|
nil,
|
|
&secp256k1fx.OutputOwners{},
|
|
&secp256k1fx.OutputOwners{},
|
|
0,
|
|
)
|
|
require.NoError(err)
|
|
|
|
tx := &txs.Tx{Unsigned: utx}
|
|
require.NoError(tx.Initialize())
|
|
txsIn = append(txsIn, tx)
|
|
}
|
|
|
|
genesisBytes, err := (&genesis.Genesis{Validators: txsIn}).Bytes()
|
|
require.NoError(err)
|
|
|
|
// genesis.Parse is a wire decode and checks no possession proof, so a
|
|
// forged validator does reach the seeding path. If that ever changes these
|
|
// tests stop covering the seeding decision and say so here.
|
|
parsedGenesis, err := genesis.Parse(genesisBytes)
|
|
require.NoError(err)
|
|
require.Len(parsedGenesis.Validators, len(vdrs))
|
|
|
|
return genesisBytes
|
|
}
|
|
|
|
// seedFromGenesis runs NewNetwork's genesis-seeding path over [genesisBytes]
|
|
// for [networkID], through [logger], and returns the validator set it produced.
|
|
func seedFromGenesis(t *testing.T, networkID uint32, genesisBytes []byte, logger log.Logger) validators.Manager {
|
|
t.Helper()
|
|
require := require.New(t)
|
|
|
|
_, listeners, _, configs := newTestNetwork(t, 1)
|
|
config := configs[0]
|
|
config.Beacons = validators.NewManager()
|
|
config.Validators = validators.NewManager()
|
|
config.TrackedChains = set.NewSet[ids.ID](0)
|
|
config.UptimeCalculator = &uptime.NoOpCalculator{}
|
|
config.NetworkID = networkID
|
|
config.GenesisBytes = genesisBytes
|
|
|
|
net, err := NewNetwork(
|
|
config,
|
|
upgrade.InitiallyActiveTime,
|
|
newMessageCreator(t),
|
|
metric.NewNoOpRegistry(),
|
|
logger,
|
|
listeners[0],
|
|
newTestDialer(),
|
|
&testHandler{},
|
|
)
|
|
require.NoError(err)
|
|
t.Cleanup(net.StartClose)
|
|
|
|
return config.Validators
|
|
}
|
|
|
|
// TestGenesisValidatorWithUnverifiableSignerIsNotSeeded holds the seeding path
|
|
// to failing CLOSED, and to failing closed only where it should.
|
|
//
|
|
// A signer that does not verify is a declared key nothing can be checked
|
|
// against. Seeding it anyway kept the validator's full genesis weight while
|
|
// peer.shouldDisconnect short-circuits on a keyless entry
|
|
// (vdr.PublicKey == nil ⇒ return false), so the entry would vote for the whole
|
|
// bootstrap window with nobody proving they hold the key — the same fail-open
|
|
// the surrounding seeding fixes closed elsewhere.
|
|
//
|
|
// The other two validators are what keep the skip surgical: an honest one says
|
|
// the seeding path ran at all, and one that declares no key at all is seeded
|
|
// keyless, because keyless is what it says it is and the wire allows it.
|
|
func TestGenesisValidatorWithUnverifiableSignerIsNotSeeded(t *testing.T) {
|
|
require := require.New(t)
|
|
|
|
honest, keyless, forged := ids.GenerateTestNodeID(), ids.GenerateTestNodeID(), ids.GenerateTestNodeID()
|
|
vdrs := seedFromGenesis(t, constants.LocalID, genesisBytesFor(t,
|
|
declaration{honest, honestPoP(t)},
|
|
declaration{keyless, &signer.Empty{}},
|
|
declaration{forged, forgedPoP(t)},
|
|
), log.NewNoOpLogger())
|
|
|
|
honestVdr, ok := vdrs.GetValidator(constants.PrimaryNetworkID, honest)
|
|
require.True(ok, "the honest genesis validator was not seeded, so this test asserts nothing")
|
|
require.NotEmpty(honestVdr.PublicKey)
|
|
|
|
keylessVdr, ok := vdrs.GetValidator(constants.PrimaryNetworkID, keyless)
|
|
require.True(ok, "a validator declaring no key is a shape the wire allows and must still be seeded")
|
|
require.Empty(keylessVdr.PublicKey)
|
|
|
|
// The whole point. Not "seeded with no key" — not seeded.
|
|
if forgedVdr, ok := vdrs.GetValidator(constants.PrimaryNetworkID, forged); ok {
|
|
require.Fail("genesis validator seeded despite an unverifiable signer",
|
|
"weight %d, key %x — and a keyless entry is never checked by peer.shouldDisconnect",
|
|
forgedVdr.Weight, forgedVdr.PublicKey)
|
|
}
|
|
}
|
|
|
|
// TestEmptiedGenesisSetIsNotReplacedByTheCanonicalOne holds the canonical
|
|
// fallback to the one question it answers.
|
|
//
|
|
// The fallback used to be gated on the seeded slice being empty, which was
|
|
// unreachable while an unverifiable validator was still seeded keyless. Failing
|
|
// closed makes it reachable: a node started with custom genesis whose every
|
|
// validator is skipped now empties that slice, and the fallback would hand it
|
|
// builder.GetConfig(NetworkID) — a DIFFERENT network's stakers, answering "who
|
|
// validates here?" with nodes that do not.
|
|
//
|
|
// A genesis that declared a set and lost all of it leaves this node with no
|
|
// validators. That is the answer; it bootstraps from its beacons.
|
|
func TestEmptiedGenesisSetIsNotReplacedByTheCanonicalOne(t *testing.T) {
|
|
require := require.New(t)
|
|
|
|
// A network whose canonical config carries stakers, so a substitution is
|
|
// visible here rather than indistinguishable from the right answer.
|
|
canonical, err := builder.GetConfig(constants.LocalID)
|
|
require.NoError(err)
|
|
require.NotEmpty(canonical.InitialStakers,
|
|
"the canonical config declares no stakers, so a wrong fallback would look exactly like the right answer")
|
|
|
|
vdrs := seedFromGenesis(t, constants.LocalID, genesisBytesFor(t,
|
|
declaration{ids.GenerateTestNodeID(), forgedPoP(t)},
|
|
declaration{ids.GenerateTestNodeID(), forgedPoP(t)},
|
|
), log.NewNoOpLogger())
|
|
|
|
require.Empty(vdrs.GetValidatorIDs(constants.PrimaryNetworkID),
|
|
"a genesis whose every validator was skipped seeded validators anyway; if these are the "+
|
|
"canonical config's node IDs, the fallback substituted another network's set")
|
|
}
|
|
|
|
// declaredBy is [pop] the way a canonical config declares it: both halves as
|
|
// 0x-prefixed hex, of the COMPRESSED key and of the proof over it.
|
|
func declaredBy(t *testing.T, pop *signer.ProofOfPossession) genesiscfg.Staker {
|
|
t.Helper()
|
|
require := require.New(t)
|
|
|
|
pk, err := formatting.Encode(formatting.HexNC, pop.PublicKey[:])
|
|
require.NoError(err)
|
|
proof, err := formatting.Encode(formatting.HexNC, pop.ProofOfPossession[:])
|
|
require.NoError(err)
|
|
|
|
return genesiscfg.Staker{
|
|
NodeID: ids.GenerateTestNodeID(),
|
|
Signer: &genesiscfg.ProofOfPossession{PublicKey: pk, ProofOfPossession: proof},
|
|
}
|
|
}
|
|
|
|
// TestDeclaredKeyRefusesAKeyThatIsNotOne holds the canonical half of the
|
|
// seeding decision to failing CLOSED.
|
|
//
|
|
// builder.GetConfig reads the shipped configs and every one of them verifies,
|
|
// so a bad staker does not arrive through the shipped ones — and the overrides
|
|
// that used to replace the staker list outright are gone. What is left is an
|
|
// operator's own --genesis-file, whose stakers this same decision reads.
|
|
// declaredKey IS that decision, so this checks the decision itself rather than
|
|
// a way of reaching it.
|
|
//
|
|
// A key that PARSES is only half of it. genesis/builder verifies the possession
|
|
// proof when it builds genesis from this same config, so a staker it refuses
|
|
// and this path seeds would be one field read two ways. The forged case below
|
|
// is that difference: both halves are valid curve points, so only the pairing
|
|
// tells them apart.
|
|
func TestDeclaredKeyRefusesAKeyThatIsNotOne(t *testing.T) {
|
|
honest := honestPoP(t)
|
|
realKey, err := bls.PublicKeyFromCompressedBytes(honest.PublicKey[:])
|
|
require.NoError(t, err)
|
|
|
|
realHex, err := formatting.Encode(formatting.HexNC, honest.PublicKey[:])
|
|
require.NoError(t, err)
|
|
realProof, err := formatting.Encode(formatting.HexNC, honest.ProofOfPossession[:])
|
|
require.NoError(t, err)
|
|
|
|
// Right length, valid hex, not a point on the curve.
|
|
notAPoint, err := formatting.Encode(formatting.HexNC, bytes.Repeat([]byte{0xff}, bls.PublicKeyLen))
|
|
require.NoError(t, err)
|
|
|
|
withKey := func(key string) genesiscfg.Staker {
|
|
return genesiscfg.Staker{Signer: &genesiscfg.ProofOfPossession{PublicKey: key}}
|
|
}
|
|
signedBy := func(key, proof string) genesiscfg.Staker {
|
|
return genesiscfg.Staker{Signer: &genesiscfg.ProofOfPossession{PublicKey: key, ProofOfPossession: proof}}
|
|
}
|
|
|
|
for _, test := range []struct {
|
|
name string
|
|
staker genesiscfg.Staker
|
|
want []byte
|
|
refuse bool
|
|
}{
|
|
{
|
|
// Declaring no key is a shape the config allows, and such a staker
|
|
// is seeded keyless because keyless is what it says it is.
|
|
name: "no signer at all",
|
|
staker: genesiscfg.Staker{},
|
|
},
|
|
{
|
|
name: "signer declaring no key",
|
|
staker: withKey(""),
|
|
},
|
|
{
|
|
name: "key that is not hex",
|
|
staker: withKey("0xnot-a-key"),
|
|
refuse: true,
|
|
},
|
|
{
|
|
// The case base64 used to turn into 72 bytes of garbage that then
|
|
// passed the has-a-key guard and failed the uncompressed reader.
|
|
name: "hex that is not a curve point",
|
|
staker: withKey(notAPoint),
|
|
refuse: true,
|
|
},
|
|
{
|
|
// A key nobody proved they hold. genesis/builder refuses this
|
|
// staker, so seeding it here would read one field two ways.
|
|
name: "a real key with no proof at all",
|
|
staker: withKey(realHex),
|
|
refuse: true,
|
|
},
|
|
{
|
|
name: "a real key whose proof is not hex",
|
|
staker: signedBy(realHex, "0xnot-a-proof"),
|
|
refuse: true,
|
|
},
|
|
{
|
|
name: "a real key whose proof is the right length and not a signature",
|
|
staker: signedBy(realHex, mustHex(t, bytes.Repeat([]byte{0xff}, bls.SignatureLen))),
|
|
refuse: true,
|
|
},
|
|
{
|
|
// The only forgery worth testing: both halves parse, and only the
|
|
// pairing rejects it.
|
|
name: "a real key carrying somebody else's proof",
|
|
staker: signedBy(realHex, mustHex(t, honestPoP(t).ProofOfPossession[:])),
|
|
refuse: true,
|
|
},
|
|
{
|
|
name: "a real key and its own proof",
|
|
staker: signedBy(realHex, realProof),
|
|
want: bls.PublicKeyToUncompressedBytes(realKey),
|
|
},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
require := require.New(t)
|
|
|
|
key, err := declaredKey(test.staker)
|
|
if test.refuse {
|
|
require.Error(err, "a declared key that is not a key was accepted")
|
|
require.Nil(key, "a refused staker must carry no key, keyless least of all")
|
|
return
|
|
}
|
|
require.NoError(err)
|
|
require.Equal(test.want, key)
|
|
})
|
|
}
|
|
}
|
|
|
|
// mustHex is [b] as the canonical config writes it.
|
|
func mustHex(t *testing.T, b []byte) string {
|
|
t.Helper()
|
|
|
|
s, err := formatting.Encode(formatting.HexNC, b)
|
|
require.NoError(t, err)
|
|
return s
|
|
}
|
|
|
|
// TestShippedConfigsStillSeedTheirFullValidatorSet is the cost of checking the
|
|
// possession proof here: a shipped config whose proof does not verify loses that
|
|
// validator, on every node that falls back to it.
|
|
//
|
|
// So it is checked, over every network builder.GetConfig serves — the shipped
|
|
// stakers ARE the population this path runs against in production, and the
|
|
// question "does the stricter rule drop anybody?" has one answer per staker.
|
|
func TestShippedConfigsStillSeedTheirFullValidatorSet(t *testing.T) {
|
|
// SHIPPED is the whole claim, and builder.GetConfig now has nothing else
|
|
// to answer with, so nothing a box happens to have configured can stand in
|
|
// for one (builder.TestCanonicalConfigIsTheOnlySource).
|
|
//
|
|
// The well-known networks and the chain-ID aliases GetConfig also answers
|
|
// to, named by the package that SHIPS them — luxfi/constants carries its own
|
|
// copy of the aliases and they do not all agree (its DevnetChainID is 96370,
|
|
// which no shipped config answers to).
|
|
stakers := 0
|
|
for _, networkID := range []uint32{
|
|
genesisconfigs.MainnetID, genesisconfigs.TestnetID, genesisconfigs.DevnetID, genesisconfigs.LocalID,
|
|
genesisconfigs.MainnetChainID, genesisconfigs.TestnetChainID, genesisconfigs.DevnetChainID, genesisconfigs.LocalChainID,
|
|
} {
|
|
t.Run(fmt.Sprint(networkID), func(t *testing.T) {
|
|
require := require.New(t)
|
|
|
|
cfg, err := builder.GetConfig(networkID)
|
|
require.NoError(err)
|
|
require.NotEmpty(cfg.InitialStakers,
|
|
"network %d ships no stakers, so this case asserts nothing", networkID)
|
|
|
|
seeded, refused := canonicalStakers(cfg.InitialStakers)
|
|
require.Empty(refused, "the shipped config for network %d loses validators to the key check", networkID)
|
|
require.Len(seeded, len(cfg.InitialStakers))
|
|
|
|
for i, s := range seeded {
|
|
require.NotEmpty(s.BLSKey,
|
|
"shipped staker %s declares a key and was seeded keyless", cfg.InitialStakers[i].NodeID)
|
|
require.NotNil(bls.PublicKeyFromValidUncompressedBytes(s.BLSKey),
|
|
"shipped staker %s was seeded with a key peer.shouldDisconnect cannot parse", s.NodeID)
|
|
}
|
|
stakers += len(seeded)
|
|
})
|
|
}
|
|
require.NotZero(t, stakers, "no shipped config was read, so this test asserts nothing")
|
|
}
|
|
|
|
// TestCanonicalStakersRefusesRatherThanSeedingKeyless closes the last place the
|
|
// seeding decision could fail open.
|
|
//
|
|
// The canonical path used to skip a bad staker with a `continue` inside
|
|
// NewNetwork, reachable only through builder.GetConfig — and every shipped
|
|
// config verifies, so nothing could drive it. As a mapping it is a value: a
|
|
// refused staker is ABSENT from the seeded set, and seeding it keyless instead
|
|
// (full genesis weight, and peer.shouldDisconnect returning early on it) fails
|
|
// right here.
|
|
func TestCanonicalStakersRefusesRatherThanSeedingKeyless(t *testing.T) {
|
|
require := require.New(t)
|
|
|
|
honest := declaredBy(t, honestPoP(t))
|
|
forged := declaredBy(t, forgedPoP(t))
|
|
|
|
garbage := declaredBy(t, honestPoP(t))
|
|
garbage.Signer.PublicKey = "0xnot-a-key"
|
|
|
|
keyless := genesiscfg.Staker{NodeID: ids.GenerateTestNodeID()}
|
|
|
|
// Weight is the other half of what seeding a refused staker would hand it.
|
|
weighty := honest
|
|
weighty.NodeID = ids.GenerateTestNodeID()
|
|
weighty.Weight = 42
|
|
|
|
seeded, refused := canonicalStakers([]genesiscfg.Staker{honest, forged, garbage, keyless, weighty})
|
|
|
|
got := map[ids.NodeID]genesisStaker{}
|
|
for _, s := range seeded {
|
|
got[s.NodeID] = s
|
|
}
|
|
require.Len(got, 3, "the seeded set is not exactly the stakers that proved their keys")
|
|
|
|
require.Contains(got, honest.NodeID)
|
|
require.NotEmpty(got[honest.NodeID].BLSKey)
|
|
require.EqualValues(1, got[honest.NodeID].Weight, "a staker declaring no weight must still count for one")
|
|
|
|
require.Contains(got, keyless.NodeID, "a staker declaring no key is a shape the config allows")
|
|
require.Empty(got[keyless.NodeID].BLSKey)
|
|
|
|
require.Contains(got, weighty.NodeID)
|
|
require.EqualValues(42, got[weighty.NodeID].Weight)
|
|
|
|
// The whole point. Not "seeded with no key" — not seeded.
|
|
require.NotContains(got, forged.NodeID,
|
|
"a staker whose declared key it never proved it holds was seeded")
|
|
require.NotContains(got, garbage.NodeID,
|
|
"a staker whose declared key is not a key was seeded")
|
|
|
|
refusedIDs := make([]ids.NodeID, 0, len(refused))
|
|
for _, r := range refused {
|
|
require.Error(r.Err)
|
|
refusedIDs = append(refusedIDs, r.NodeID)
|
|
}
|
|
require.ElementsMatch([]ids.NodeID{forged.NodeID, garbage.NodeID}, refusedIDs,
|
|
"the refusals must name exactly the stakers that were not seeded, so the node can say which")
|
|
}
|
|
|
|
// recorder keeps what is written through it at Error and at Warn, and is
|
|
// otherwise the no-op logger.
|
|
//
|
|
// The genesis seeding runs inside NewNetwork, before anything is dispatched, so
|
|
// what it wrote is readable the moment NewNetwork returns. The lock is for the
|
|
// network's own goroutines, not for that.
|
|
type recorder struct {
|
|
log.Logger
|
|
|
|
lock sync.Mutex
|
|
errs []string
|
|
warns []string
|
|
}
|
|
|
|
func newRecorder() *recorder {
|
|
return &recorder{Logger: log.NewNoOpLogger()}
|
|
}
|
|
|
|
func (l *recorder) Error(msg string, _ ...interface{}) {
|
|
l.lock.Lock()
|
|
defer l.lock.Unlock()
|
|
|
|
l.errs = append(l.errs, msg)
|
|
}
|
|
|
|
func (l *recorder) Warn(msg string, _ ...interface{}) {
|
|
l.lock.Lock()
|
|
defer l.lock.Unlock()
|
|
|
|
l.warns = append(l.warns, msg)
|
|
}
|
|
|
|
func (l *recorder) errors() []string {
|
|
l.lock.Lock()
|
|
defer l.lock.Unlock()
|
|
|
|
return slices.Clone(l.errs)
|
|
}
|
|
|
|
func (l *recorder) warnings() []string {
|
|
l.lock.Lock()
|
|
defer l.lock.Unlock()
|
|
|
|
return slices.Clone(l.warns)
|
|
}
|
|
|
|
// TestEmptyValidatorSetIsAnError holds the node to SAYING it has no validators.
|
|
//
|
|
// The error used to be keyed on the why — a genesis that declared stakers and
|
|
// lost them all — so the other way to get there said nothing at all: a node
|
|
// whose canonical config declares no validator started empty in silence, which
|
|
// is the shape a misconfigured network ID has. Keying it on the END STATE makes
|
|
// both audible, and the reason still separates them.
|
|
func TestEmptyValidatorSetIsAnError(t *testing.T) {
|
|
const unknownNetwork = 424242
|
|
|
|
t.Run("the canonical config declares nobody", func(t *testing.T) {
|
|
require := require.New(t)
|
|
|
|
// An unknown network ID ships no config, and nothing else is
|
|
// consulted, so this is the same answer on every box. It used to
|
|
// resolve to the localnet NAME and get looked up under $HOME, where a
|
|
// developer's genesis checkout answered with a real validator set and
|
|
// this case stopped being the one it is named for.
|
|
_, err := builder.GetConfig(unknownNetwork)
|
|
require.Error(err,
|
|
"network %d ships a config after all, so this case proves nothing", unknownNetwork)
|
|
|
|
logger := newRecorder()
|
|
vdrs := seedFromGenesis(t, unknownNetwork, nil, logger)
|
|
|
|
require.Empty(vdrs.GetValidatorIDs(constants.PrimaryNetworkID))
|
|
require.Contains(logger.errors(), emptyValidatorSetReason(0, 0),
|
|
"a node started with no validators at all said nothing about it")
|
|
})
|
|
|
|
t.Run("this node's genesis lost every validator it declared", func(t *testing.T) {
|
|
require := require.New(t)
|
|
|
|
logger := newRecorder()
|
|
vdrs := seedFromGenesis(t, constants.LocalID, genesisBytesFor(t,
|
|
declaration{ids.GenerateTestNodeID(), forgedPoP(t)},
|
|
), logger)
|
|
|
|
require.Empty(vdrs.GetValidatorIDs(constants.PrimaryNetworkID))
|
|
require.Contains(logger.errors(), emptyValidatorSetReason(1, 0),
|
|
"a node that refused every validator its genesis declared said nothing about it")
|
|
})
|
|
|
|
t.Run("the canonical config lost every validator it declares", func(t *testing.T) {
|
|
require := require.New(t)
|
|
|
|
// The node falls back, and what it falls back to declares a validator
|
|
// that cannot prove its key. The canonical config DID name somebody, so
|
|
// saying nobody was named would name the wrong why.
|
|
//
|
|
// This is the one case with no end-to-end route left. Reaching it
|
|
// needed a canonical config that declares an unprovable key, and the
|
|
// shipped ones all verify (TestShippedConfigsStillSeedTheirFullValidator-
|
|
// Set) — the only other way in was PCHAIN_ALLOCS, which no longer
|
|
// reaches builder.GetConfig. That the case is now unreachable IS the
|
|
// fix; what is left to hold is that the two halves still compose to the
|
|
// right answer, so they are composed here over a forged set.
|
|
refusable := []genesiscfg.Staker{{
|
|
NodeID: ids.GenerateTestNodeID(),
|
|
Signer: declaredSigner(t, forgedPoP(t)),
|
|
}}
|
|
|
|
seeded, refused := canonicalStakers(refusable)
|
|
require.Empty(seeded, "a validator that cannot prove its key was seeded anyway")
|
|
require.Len(refused, len(refusable))
|
|
|
|
// What the node then says. The counts it says it FROM are the two
|
|
// above, so the assertion worth making is that they reach the reason
|
|
// naming the canonical config — not the one for a node nobody named a
|
|
// validator to, which is the same empty validator set and a different
|
|
// fact.
|
|
reason := emptyValidatorSetReason(len(seeded), len(refusable))
|
|
require.Contains(reason, "canonical",
|
|
"a node that refused every validator the canonical config declares did not say so")
|
|
require.NotEqual(emptyValidatorSetReason(0, 0), reason,
|
|
"a node that refused the canonical config's validators says it was declared none")
|
|
})
|
|
|
|
t.Run("a seeded node says none of them", func(t *testing.T) {
|
|
require := require.New(t)
|
|
|
|
logger := newRecorder()
|
|
vdrs := seedFromGenesis(t, constants.LocalID, genesisBytesFor(t,
|
|
declaration{ids.GenerateTestNodeID(), honestPoP(t)},
|
|
), logger)
|
|
|
|
require.NotEmpty(vdrs.GetValidatorIDs(constants.PrimaryNetworkID))
|
|
for _, reason := range emptyValidatorSetReasons() {
|
|
require.NotContains(logger.errors(), reason)
|
|
}
|
|
})
|
|
|
|
t.Run("the reason separates all three", func(t *testing.T) {
|
|
// A set of them is as large as the slice only if no two read alike.
|
|
reasons := emptyValidatorSetReasons()
|
|
require.Len(t, reasons, 3)
|
|
require.Equal(t, len(reasons), set.Of(reasons...).Len(),
|
|
"two ways to an empty validator set read the same, so the log cannot tell them apart")
|
|
})
|
|
}
|
|
|
|
// emptyValidatorSetReasons is every way the node can say it has no validators,
|
|
// in the order emptyValidatorSetReason answers them.
|
|
func emptyValidatorSetReasons() []string {
|
|
return []string{
|
|
emptyValidatorSetReason(1, 0),
|
|
emptyValidatorSetReason(0, 1),
|
|
emptyValidatorSetReason(0, 0),
|
|
}
|
|
}
|
|
|
|
// declaredSigner is a possession proof in the shape a canonical config carries
|
|
// it: two hex strings, which is what the config format has and what
|
|
// canonicalStakers is handed.
|
|
func declaredSigner(t *testing.T, pop *signer.ProofOfPossession) *genesiscfg.ProofOfPossession {
|
|
t.Helper()
|
|
|
|
return &genesiscfg.ProofOfPossession{
|
|
PublicKey: mustHex(t, pop.PublicKey[:]),
|
|
ProofOfPossession: mustHex(t, pop.ProofOfPossession[:]),
|
|
}
|
|
}
|
|
|
|
// TestUnreadableGenesisIsAWarning holds the substitution to being audible.
|
|
//
|
|
// Bytes that do not parse leave the declared count at zero, which is the same
|
|
// state as no genesis at all — so the node takes the CANONICAL set for its
|
|
// network ID and validates against stakers its own genesis never named. That
|
|
// was a Debug line, i.e. nothing, on a node running with a corrupt genesis.
|
|
func TestUnreadableGenesisIsAWarning(t *testing.T) {
|
|
require := require.New(t)
|
|
|
|
logger := newRecorder()
|
|
vdrs := seedFromGenesis(t, constants.LocalID, []byte("this is not a P-chain genesis"), logger)
|
|
|
|
require.Contains(logger.warnings(), "failed to parse P-chain genesis bytes, will try canonical config",
|
|
"a node whose genesis could not be read said nothing about it")
|
|
|
|
// And this is what that silence was hiding: the canonical set, seeded under
|
|
// a genesis the node could not read.
|
|
require.NotEmpty(vdrs.GetValidatorIDs(constants.PrimaryNetworkID))
|
|
}
|