mirror of
https://github.com/luxfi/node.git
synced 2026-10-30 15:24:05 +00:00
The module already called itself github.com/luxfi/node while containing no node in Go: cmd/luxd was twenty-one lines that printed the size of a few types, and the Go column of the matrix measured a VM plugin. A repo that fetches the thing it claims to be has not replaced it, so the Go sources move in — 1515 files, the daemon and everything under it — and the build reaches outside this module for nothing. Rust and C++ sit beside it unchanged and are still measured against it by the differential. That is the point of one repo: the reference implementation and the two ports are the same checkout, so parity is a thing you can run rather than a thing you assert. Verified: builds, and the closure carries no lux-private — checked at build time rather than believed. The daemon starts twelve chains on a local network (P X C Q Z A B G K M F, plus D through its plugin) and reports healthy.
194 lines
8.0 KiB
Go
194 lines
8.0 KiB
Go
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package chains
|
|
|
|
import (
|
|
"github.com/luxfi/chains/ownership"
|
|
"github.com/luxfi/ids"
|
|
"github.com/luxfi/log"
|
|
)
|
|
|
|
// maxRestrictedActivationAttempts bounds how many times a restricted chain may be
|
|
// deferred waiting for the M-Chain to bootstrap before it is opted out. It exists
|
|
// only as defense-in-depth against an M-Chain that never converges; the normal
|
|
// path defers zero or one time.
|
|
const maxRestrictedActivationAttempts = 8
|
|
|
|
// entitlement is the narrow accessor the activation check needs from the M-Chain
|
|
// VM: the ownership attestation recorded for a node. *mpcvm.VM satisfies it by
|
|
// reading its own committed state.
|
|
//
|
|
// The attestation carries the group key it was signed under, which is safe here
|
|
// and ONLY here: it comes out of M-Chain consensus state, which a node operator
|
|
// cannot edit. An attestation obtained from anywhere the operator controls — a
|
|
// config file, a flag, a file on disk — would prove nothing at all, because the
|
|
// operator could sign their own claim with their own key. That is why this is an
|
|
// interface over a chain VM and not a parameter.
|
|
type entitlement interface {
|
|
Entitlement(node [20]byte) (*ownership.Attestation, error)
|
|
}
|
|
|
|
// authorizeChainActivation reports whether this node may activate (track/validate)
|
|
// [chainID].
|
|
//
|
|
// Returns (authorized, ready):
|
|
// - ready=false means the check cannot decide yet because the M-Chain is not
|
|
// bootstrapped and therefore not queryable. The caller MUST defer, not skip.
|
|
// - ready=true, authorized=true: proceed normally.
|
|
// - ready=true, authorized=false: this node is not entitled to the chain.
|
|
//
|
|
// Decision order:
|
|
// 1. Critical chains (P/C/X/…) are never restricted — always (true, true).
|
|
// 2. The D-Chain additionally requires the operator's dex-validator opt-in, so a
|
|
// node that did not ask for the DEX never gets it.
|
|
// 3. Unrestricted chains are always (true, true), so the zero/nil set is a no-op.
|
|
// 4. Restricted chain, M-Chain absent → (false, true) with a specific reason. An
|
|
// unconfigured entitlement refuses and SAYS SO; it never silently returns
|
|
// false forever.
|
|
// 5. Restricted chain, M-Chain present but not bootstrapped → (false, false):
|
|
// defer and retry.
|
|
// 6. Otherwise read this node's ownership attestation and let it decide.
|
|
func (m *manager) authorizeChainActivation(chainID ids.ID) (authorized bool, ready bool) {
|
|
// Critical chains are foundational; the check must never skip or defer them.
|
|
if m.CriticalChains.Contains(chainID) {
|
|
return true, true
|
|
}
|
|
|
|
// Consent is a NECESSARY condition that composes AND-wise with the
|
|
// entitlement below, and it is checked before the unrestricted
|
|
// short-circuit so it is required either way. A node whose operator did
|
|
// not name the chain declines it even under --track-all-chains: this is
|
|
// the activation authority, not the platformvm tracking set.
|
|
//
|
|
// One rule for every chain that needs consent. It was a boolean special
|
|
// -cased to the D-Chain, which answered the question for one chain and
|
|
// left the next one to add a second flag, a second field and a second
|
|
// branch that could disagree with the first.
|
|
if m.NeedConsent.Contains(chainID) && !m.Consented.Contains(chainID) {
|
|
m.Log.Info("chain activation declined: this chain is not in --chains",
|
|
log.String("name", m.ConsentNames[chainID]),
|
|
log.Stringer("chainID", chainID),
|
|
)
|
|
return false, true
|
|
}
|
|
|
|
if !m.RestrictedChains.Contains(chainID) {
|
|
return true, true
|
|
}
|
|
|
|
// The entitlement lives in M-Chain consensus state. No M-Chain means no way to
|
|
// prove entitlement, so refuse — but refuse with the reason, because "this node
|
|
// is not entitled" and "this node cannot find out" are different operational
|
|
// problems and an operator must be able to tell them apart.
|
|
if m.MChainID == ids.Empty {
|
|
m.Log.Error("restricted chain activation refused: no M-Chain in this network's genesis, so no entitlement can be verified",
|
|
log.Stringer("chainID", chainID),
|
|
)
|
|
return false, true
|
|
}
|
|
if !m.IsBootstrapped(m.MChainID) {
|
|
return false, false
|
|
}
|
|
reader := m.entitlements()
|
|
if reader == nil {
|
|
// IsBootstrapped above already required the M-Chain to be registered here,
|
|
// so reaching this means the M-Chain is present and its VM does not
|
|
// implement the accessor: no node can ever be entitled, and every
|
|
// restricted chain opts out on every start until one does. Said plainly,
|
|
// because "not reachable in-process" reads as a transient wiring fault and
|
|
// sends an operator looking for a race that is not there.
|
|
m.Log.Error("restricted chain activation refused: the M-Chain VM implements no entitlement accessor, so no entitlement can be read",
|
|
log.Stringer("chainID", chainID),
|
|
log.Stringer("mChainID", m.MChainID),
|
|
)
|
|
return false, true
|
|
}
|
|
|
|
node := [20]byte(m.NodeID)
|
|
att, err := reader.Entitlement(node)
|
|
if err != nil {
|
|
// A read error is not a decline — the M-Chain claims to be bootstrapped but
|
|
// cannot answer. Defer and retry rather than permanently opt out on a
|
|
// transient failure.
|
|
m.Log.Warn("restricted chain activation deferred: entitlement read failed",
|
|
log.Stringer("chainID", chainID),
|
|
log.Err(err),
|
|
)
|
|
return false, false
|
|
}
|
|
// Authorizes settles authenticity and node-binding together, so there is no way
|
|
// to accept an attestation that is valid but issued for a different node.
|
|
if err := att.Authorizes(node); err != nil {
|
|
m.Log.Info("restricted chain activation not authorized",
|
|
log.Stringer("chainID", chainID),
|
|
log.Err(err),
|
|
)
|
|
return false, true
|
|
}
|
|
m.Log.Info("restricted chain activation authorized by ownership attestation",
|
|
log.Stringer("chainID", chainID),
|
|
log.Uint64("token", att.Claim.Token),
|
|
)
|
|
return true, true
|
|
}
|
|
|
|
// entitlements returns the in-process entitlement reader for the M-Chain, or nil
|
|
// if the M-Chain VM is not present or does not expose the accessor.
|
|
//
|
|
// Callers reach this only after IsBootstrapped, which already requires the
|
|
// M-Chain to be in this map, so in practice a nil here means the VM does not
|
|
// implement the accessor rather than that the chain is missing.
|
|
func (m *manager) entitlements() entitlement {
|
|
m.chainsLock.Lock()
|
|
info, ok := m.chains[m.MChainID]
|
|
m.chainsLock.Unlock()
|
|
if !ok {
|
|
return nil
|
|
}
|
|
reader, _ := info.VM.(entitlement)
|
|
return reader
|
|
}
|
|
|
|
// deferRestrictedChain parks [chainParams] out of the creation queue because the
|
|
// M-Chain is not yet bootstrapped, and reports whether the chain may still be
|
|
// retried. It returns false once the per-chain attempt cap is exhausted, in which
|
|
// case the caller must opt the chain out — an M-Chain that never converges must
|
|
// not park a chain forever. Parking out of the queue, rather than re-pushing, is
|
|
// what keeps the single-threaded dispatch loop from busy-spinning;
|
|
// retryPendingRestrictedChains re-pushes when the M-Chain converges.
|
|
func (m *manager) deferRestrictedChain(chainParams ChainParameters) (retry bool) {
|
|
m.restrictedChainsLock.Lock()
|
|
defer m.restrictedChainsLock.Unlock()
|
|
|
|
m.restrictedAttempts[chainParams.ID]++
|
|
if m.restrictedAttempts[chainParams.ID] > maxRestrictedActivationAttempts {
|
|
return false
|
|
}
|
|
m.pendingRestrictedChains = append(m.pendingRestrictedChains, chainParams)
|
|
return true
|
|
}
|
|
|
|
// retryPendingRestrictedChains re-queues every chain parked by
|
|
// deferRestrictedChain.
|
|
//
|
|
// It is called when the M-Chain finishes BOOTSTRAPPING, not merely when it is
|
|
// created. The M-Chain is an engine chain, so it is marked bootstrapped
|
|
// asynchronously by monitorBootstrap — draining at creation time would re-run the
|
|
// check while the answer was still "not ready", park the chain again, and leave it
|
|
// parked with nothing left to drain it.
|
|
func (m *manager) retryPendingRestrictedChains() {
|
|
m.restrictedChainsLock.Lock()
|
|
parked := m.pendingRestrictedChains
|
|
m.pendingRestrictedChains = nil
|
|
m.restrictedChainsLock.Unlock()
|
|
|
|
for _, chainParams := range parked {
|
|
m.Log.Info("re-queuing restricted chain after M-Chain bootstrap",
|
|
log.Stringer("chainID", chainParams.ID),
|
|
log.Stringer("vmID", chainParams.VMID),
|
|
)
|
|
m.chainsQueue.PushRight(chainParams)
|
|
}
|
|
}
|