Files
node/chains/manager_authz_test.go
zeekayandHanzo Dev 37e2940ead mesh is the peer layer, network is the validator set
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]>
2026-09-07 21:17:09 -07:00

461 lines
17 KiB
Go

// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package chains
import (
"errors"
"testing"
"github.com/stretchr/testify/require"
"github.com/luxfi/chains/ownership"
"github.com/luxfi/constants"
"github.com/luxfi/container/buffer"
"github.com/luxfi/crypto/secp256k1"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/math/set"
"github.com/luxfi/node/network"
)
// committee stands in for the M-Chain: it returns the attestation recorded for a
// node, or an error. A threshold ECDSA signature verifies exactly like a
// single-key one, so a single test key models a real 3-of-5 ceremony faithfully
// and none of this needs a live M-Chain.
type committee struct {
key *secp256k1.PrivateKey
// held maps a node to the claim the committee has attested for it. A node
// absent here has no entitlement — the ordinary case for most validators.
held map[[20]byte]ownership.Claim
err error
// quorum/signers model the ceremony's policy; defaults are set by newCommittee.
quorum int
signers int
// tamper, when set, edits the attestation AFTER it is signed — the shape of a
// relayer or operator altering content in flight.
tamper func(*ownership.Attestation)
}
func newCommittee(t *testing.T) *committee {
t.Helper()
key, err := secp256k1.NewPrivateKey()
require.NoError(t, err)
return &committee{
key: key,
held: map[[20]byte]ownership.Claim{},
quorum: 3,
signers: 3,
}
}
// attest records that the committee has verified `claim` — the M-Chain-side act.
func (c *committee) attest(claim ownership.Claim) {
c.held[claim.Node] = claim
}
// Entitlement satisfies the manager's narrow reader over the M-Chain VM.
func (c *committee) Entitlement(node [20]byte) (*ownership.Attestation, error) {
if c.err != nil {
return nil, c.err
}
claim, ok := c.held[node]
if !ok {
// No entitlement on record. The M-Chain answers truthfully; it does not
// error, because "nothing attested" is a valid, complete answer.
return &ownership.Attestation{}, nil
}
return c.sign(claim), nil
}
// sign produces the attestation the committee would return for a claim.
func (c *committee) sign(claim ownership.Claim) *ownership.Attestation {
payload := ownership.Payload(claim.Subject(), claim.Root(), claim.Block)
sig, err := c.key.SignHash(payload[:])
if err != nil {
panic(err)
}
att := &ownership.Attestation{
Claim: claim,
Epoch: claim.Block,
Signers: c.signers,
Quorum: c.quorum,
Signature: sig,
GroupKey: c.key.PublicKey().CompressedBytes(),
}
if c.tamper != nil {
c.tamper(att)
}
return att
}
// claimFor is a realistic Validator-tier claim: the token IS the slot, and the
// collection is the live Genesis ERC-721 on Ethereum mainnet.
func claimFor(node ids.NodeID, token uint64) ownership.Claim {
c := ownership.Claim{Chain: 1, Token: token, Block: 25443474, Node: [20]byte(node)}
copy(c.Collection[:], []byte{
0x31, 0xe0, 0xF9, 0x19, 0xC6, 0x7c, 0xeD, 0xd2, 0xBc, 0x3E,
0x29, 0x43, 0x40, 0xDc, 0x90, 0x07, 0x35, 0x81, 0x03, 0x11,
})
for i := range c.Owner {
c.Owner[i] = byte(0xA0 + i)
}
return c
}
// newManager builds a chain manager whose entitlement check is wired to `mChain`.
// When mChain is non-nil it is installed as the M-Chain's tracked VM AND marked
// bootstrapped, since a restricted chain may only be decided once the M-Chain's
// committed state is readable.
func newManager(
nodeID ids.NodeID,
mChainID ids.ID,
restricted set.Set[ids.ID],
critical set.Set[ids.ID],
mChain entitlement,
) *manager {
netsTracker, err := NewNets(ids.GenerateTestNodeID(), map[ids.ID]network.Config{
constants.PrimaryNetworkID: {},
})
if err != nil {
panic(err)
}
m := &manager{
chains: make(map[ids.ID]*chainInfo),
restrictedAttempts: make(map[ids.ID]int),
}
m.Log = log.NewNoOpLogger()
m.Nets = netsTracker
m.NodeID = nodeID
m.MChainID = mChainID
m.RestrictedChains = restricted
m.CriticalChains = critical
if mChain != nil {
m.chains[mChainID] = &chainInfo{Name: "M-Chain", VM: mChain}
sb, _ := netsTracker.GetOrCreate(constants.PrimaryNetworkID)
sb.AddChain(mChainID)
sb.Bootstrapped(mChainID)
}
return m
}
// consentManager is the shape node.go produces for a chain that is BOTH
// consent-requiring and restricted — the D-Chain today — with consent GIVEN so
// these tests exercise the entitlement half.
func consentManager(t *testing.T, c *committee) (*manager, ids.NodeID, ids.ID) {
t.Helper()
nodeID := ids.GenerateTestNodeID()
mChainID := ids.GenerateTestID()
chainID := ids.GenerateTestID()
m := newManager(nodeID, mChainID, set.Of(chainID), nil, c)
m.NeedConsent = set.Of(chainID)
m.Consented = set.Of(chainID)
return m, nodeID, chainID
}
// TestUnrestrictedChainsAreUntouched pins the no-op property: with an empty
// restricted set every chain behaves exactly as it did before entitlement
// existed. This is what makes the check safe to ship to a running network.
func TestUnrestrictedChainsAreUntouched(t *testing.T) {
c := newCommittee(t)
m := newManager(ids.GenerateTestNodeID(), ids.GenerateTestID(), nil, nil, c)
authorized, ready := m.authorizeChainActivation(ids.GenerateTestID())
require.True(t, ready)
require.True(t, authorized, "an unrestricted chain must never be withheld")
}
// TestCriticalChainsAreNeverRestricted proves P/C/X/Q cannot be withheld even if
// someone lists them as restricted. Withholding a foundational chain would brick
// the node rather than decline an optional one.
func TestCriticalChainsAreNeverRestricted(t *testing.T) {
critical := ids.GenerateTestID()
m := newManager(ids.GenerateTestNodeID(), ids.GenerateTestID(),
set.Of(critical), set.Of(critical), newCommittee(t))
authorized, ready := m.authorizeChainActivation(critical)
require.True(t, ready)
require.True(t, authorized, "a critical chain must never be withheld")
}
// TestOwnershipAuthorizes is the headline positive: a node the committee has
// attested for IS entitled to the D-Chain.
func TestOwnershipAuthorizes(t *testing.T) {
c := newCommittee(t)
m, nodeID, dChainID := consentManager(t, c)
c.attest(claimFor(nodeID, 7))
authorized, ready := m.authorizeChainActivation(dChainID)
require.True(t, ready)
require.True(t, authorized, "an ownership attestation naming this node authorizes the D-Chain")
}
// TestAbsenceRefuses is the headline negative. Each subtest is a distinct way an
// entitlement can be missing or forged, and every one must refuse — decided
// (ready=true), so the caller opts out cleanly instead of retrying forever.
func TestAbsenceRefuses(t *testing.T) {
t.Run("no attestation on record", func(t *testing.T) {
c := newCommittee(t)
m, _, dChainID := consentManager(t, c)
authorized, ready := m.authorizeChainActivation(dChainID)
require.True(t, ready)
require.False(t, authorized, "a node with no attestation is not entitled")
})
t.Run("a peer's attestation is not borrowable", func(t *testing.T) {
c := newCommittee(t)
m, _, dChainID := consentManager(t, c)
// The committee attested a DIFFERENT node. m must not activate on it.
c.attest(claimFor(ids.GenerateTestNodeID(), 7))
authorized, ready := m.authorizeChainActivation(dChainID)
require.True(t, ready)
require.False(t, authorized, "an attestation for another node must not authorize this one")
})
t.Run("claim tampered after signing", func(t *testing.T) {
c := newCommittee(t)
m, nodeID, dChainID := consentManager(t, c)
c.attest(claimFor(nodeID, 7))
// Upgrade the claim to a different token on the way out. The signature is
// authentic for the ORIGINAL claim, so re-pointing it at richer content must
// fail — this is what stops a relayer editing an attestation in flight.
c.tamper = func(a *ownership.Attestation) { a.Claim.Token = 8 }
authorized, ready := m.authorizeChainActivation(dChainID)
require.True(t, ready)
require.False(t, authorized, "an edited claim must not survive its signature")
})
t.Run("attestation bound to a different collection", func(t *testing.T) {
c := newCommittee(t)
m, nodeID, dChainID := consentManager(t, c)
c.attest(claimFor(nodeID, 7))
c.tamper = func(a *ownership.Attestation) { a.Claim.Collection[0] ^= 0xff }
authorized, ready := m.authorizeChainActivation(dChainID)
require.True(t, ready)
require.False(t, authorized, "swapping the collection must not survive the signature")
})
t.Run("below quorum", func(t *testing.T) {
c := newCommittee(t)
m, nodeID, dChainID := consentManager(t, c)
c.attest(claimFor(nodeID, 7))
c.signers = c.quorum - 1
authorized, ready := m.authorizeChainActivation(dChainID)
require.True(t, ready)
require.False(t, authorized, "fewer signers than the policy requires is not a committee decision")
})
t.Run("quorum claimed as zero", func(t *testing.T) {
c := newCommittee(t)
m, nodeID, dChainID := consentManager(t, c)
c.attest(claimFor(nodeID, 7))
c.quorum, c.signers = 0, 0
authorized, ready := m.authorizeChainActivation(dChainID)
require.True(t, ready)
require.False(t, authorized, "a zero threshold must never read as satisfied")
})
}
// TestConsentAloneIsNotSufficient is the point of the whole check. Naming a
// chain in --chains states what the operator WANTS; the attestation states what
// they are ENTITLED to. Neither alone activates a chain that needs both.
func TestConsentAloneIsNotSufficient(t *testing.T) {
t.Run("consented, no entitlement => refused", func(t *testing.T) {
c := newCommittee(t)
m, _, chainID := consentManager(t, c)
authorized, ready := m.authorizeChainActivation(chainID)
require.True(t, ready)
require.True(t, m.Consented.Contains(chainID))
require.False(t, authorized,
"naming a chain in --chains must NOT be sufficient on its own")
})
t.Run("not consented, entitled => refused", func(t *testing.T) {
c := newCommittee(t)
m, nodeID, chainID := consentManager(t, c)
m.Consented = nil
c.attest(claimFor(nodeID, 7))
authorized, ready := m.authorizeChainActivation(chainID)
require.True(t, ready)
require.False(t, authorized, "consent remains necessary even for an entitled node")
})
t.Run("consented and entitled => activated", func(t *testing.T) {
c := newCommittee(t)
m, nodeID, chainID := consentManager(t, c)
c.attest(claimFor(nodeID, 7))
authorized, ready := m.authorizeChainActivation(chainID)
require.True(t, ready)
require.True(t, authorized)
})
t.Run("consent is per chain, not global", func(t *testing.T) {
c := newCommittee(t)
nodeID := ids.GenerateTestNodeID()
mChainID := ids.GenerateTestID()
other := ids.GenerateTestID()
m := newManager(nodeID, mChainID, set.Of(other), nil, c)
// A DIFFERENT chain needs consent and did not get it. `other` needs
// none, so it must be untouched: withholding one chain must never
// withhold a second.
m.NeedConsent = set.Of(ids.GenerateTestID())
m.Consented = nil
c.attest(claimFor(nodeID, 7))
authorized, ready := m.authorizeChainActivation(other)
require.True(t, ready)
require.True(t, authorized, "consent must gate ONLY the chains that require it")
})
t.Run("a consent-only chain activates on consent alone", func(t *testing.T) {
// B and M need consent but are not restricted. Nothing else may be
// asked of them, or the operator has a flag that silently does nothing.
chainID := ids.GenerateTestID()
m := newManager(ids.GenerateTestNodeID(), ids.GenerateTestID(), nil, nil, newCommittee(t))
m.NeedConsent = set.Of(chainID)
m.Consented = set.Of(chainID)
authorized, ready := m.authorizeChainActivation(chainID)
require.True(t, ready)
require.True(t, authorized)
m.Consented = nil
authorized, ready = m.authorizeChainActivation(chainID)
require.True(t, ready)
require.False(t, authorized, "an unnamed consent-requiring chain must decline")
})
}
// TestUnconfiguredRefusesRatherThanGuessing covers the two ways the entitlement
// cannot be read at all. Both must refuse — and refuse DECIDED, so the operator
// gets a clean opt-out and a logged reason rather than a silent false forever.
func TestUnconfiguredRefusesRatherThanGuessing(t *testing.T) {
t.Run("no M-Chain in genesis", func(t *testing.T) {
dChainID := ids.GenerateTestID()
m := newManager(ids.GenerateTestNodeID(), ids.Empty, set.Of(dChainID), nil, nil)
m.NeedConsent = set.Of(dChainID)
m.Consented = set.Of(dChainID)
authorized, ready := m.authorizeChainActivation(dChainID)
require.True(t, ready, "an unprovable entitlement is decided, not retried forever")
require.False(t, authorized, "no M-Chain means no entitlement, so refuse")
})
t.Run("M-Chain present but exposes no reader", func(t *testing.T) {
nodeID := ids.GenerateTestNodeID()
mChainID := ids.GenerateTestID()
dChainID := ids.GenerateTestID()
// A VM that does not satisfy the entitlement interface — the shape a
// plugin-loaded M-Chain presents until it exposes the accessor.
m := newManager(nodeID, mChainID, set.Of(dChainID), nil, nil)
m.NeedConsent = set.Of(dChainID)
m.Consented = set.Of(dChainID)
m.chains[mChainID] = &chainInfo{Name: "M-Chain", VM: struct{}{}}
sb, _ := m.Nets.GetOrCreate(constants.PrimaryNetworkID)
sb.AddChain(mChainID)
sb.Bootstrapped(mChainID)
authorized, ready := m.authorizeChainActivation(dChainID)
require.True(t, ready)
require.False(t, authorized, "an unreadable M-Chain must refuse, not fail open")
})
}
// TestNotBootstrappedDefers proves the check waits rather than deciding while the
// M-Chain's state is not yet valid to read. Deciding here would opt every node out
// of every restricted chain on every restart.
func TestNotBootstrappedDefers(t *testing.T) {
c := newCommittee(t)
nodeID := ids.GenerateTestNodeID()
mChainID := ids.GenerateTestID()
dChainID := ids.GenerateTestID()
m := newManager(nodeID, mChainID, set.Of(dChainID), nil, nil)
m.NeedConsent = set.Of(dChainID)
m.Consented = set.Of(dChainID)
// Present and readable, but NOT marked bootstrapped.
m.chains[mChainID] = &chainInfo{Name: "M-Chain", VM: c}
authorized, ready := m.authorizeChainActivation(dChainID)
require.False(t, ready, "an un-bootstrapped M-Chain must defer, never decide")
require.False(t, authorized)
}
// TestReadErrorDefers proves a transient failure retries instead of permanently
// opting the node out of a chain it may well be entitled to.
func TestReadErrorDefers(t *testing.T) {
c := newCommittee(t)
m, nodeID, dChainID := consentManager(t, c)
c.attest(claimFor(nodeID, 7))
c.err = errors.New("state unavailable")
authorized, ready := m.authorizeChainActivation(dChainID)
require.False(t, ready, "a read error is a defer, not a decline")
require.False(t, authorized)
}
// TestDeferCapOptsOut proves the park loop is bounded: an M-Chain that never
// converges cannot park a chain forever.
func TestDeferCapOptsOut(t *testing.T) {
m := newManager(ids.GenerateTestNodeID(), ids.GenerateTestID(), nil, nil, nil)
parked := ChainParameters{ID: ids.GenerateTestID()}
for i := 0; i < maxRestrictedActivationAttempts; i++ {
require.True(t, m.deferRestrictedChain(parked), "attempt %d must still retry", i+1)
}
require.False(t, m.deferRestrictedChain(parked), "the cap must eventually opt out")
}
// TestRetryDrainsParked proves parked chains are re-queued when the M-Chain
// converges — the drain that keeps a deferred chain from being lost.
func TestRetryDrainsParked(t *testing.T) {
m := newManager(ids.GenerateTestNodeID(), ids.GenerateTestID(), nil, nil, nil)
m.chainsQueue = buffer.NewUnboundedBlockingDeque[ChainParameters](initialQueueSize)
parked := ChainParameters{ID: ids.GenerateTestID(), VMID: ids.GenerateTestID()}
require.True(t, m.deferRestrictedChain(parked))
m.retryPendingRestrictedChains()
m.restrictedChainsLock.Lock()
require.Empty(t, m.pendingRestrictedChains, "the parked list must be drained")
m.restrictedChainsLock.Unlock()
got, ok := m.chainsQueue.PopLeft()
require.True(t, ok)
require.Equal(t, parked.ID, got.ID)
}
// vmWithoutEntitlement is a VM that does not implement the entitlement accessor —
// the shape every production VM currently has, since nothing in the tree
// implements Entitlement(node). The tests above all install a VM that DOES, which
// is why the gap below never showed up in them.
type vmWithoutEntitlement struct{}
// TestMChainWithoutAccessorRefusesFinally pins what a restricted chain does when
// the M-Chain is right there and simply cannot answer the question.
//
// It must be FINAL (ready=true), not a deferral. The drain that releases parked
// chains runs once, on the M-Chain's bootstrap edge; a chain deferred at that
// point has nothing left to release it, so deferring here would park the DEX and
// the bridge silently for the life of the process instead of stating an outcome.
func TestMChainWithoutAccessorRefusesFinally(t *testing.T) {
c := newCommittee(t)
m, _, dChainID := consentManager(t, c)
// Swap in an M-Chain VM that does not implement the accessor.
m.chains[m.MChainID] = &chainInfo{Name: "M-Chain", VM: vmWithoutEntitlement{}}
authorized, ready := m.authorizeChainActivation(dChainID)
require.False(t, authorized, "no accessor means no attestation can be read, so no authorization")
require.True(t, ready, "must be a final refusal: nothing re-drains a chain parked after the "+
"M-Chain's bootstrap edge, so deferring here parks the chain forever and silently")
}