Files
zeekayandHanzo Dev 5eaf0304b0 mpcd: a node is not its own peer, and too many ready is not too few
KEYGEN has been dead on the hanzo-mpc fleet — every request answered
503 "peers not ready" while /healthz reported `signing_quorum: true`
beside `ready: false`, and the counters said `ready=5 expected=4` in a
THREE node fleet. Both halves of that arithmetic were wrong.

SELF WAS COUNTED TWICE. A StatefulSet renders one command for every
replica, so the natural spelling hands each node the whole ensemble —
including itself. `peerMap[nodeID] = listenAddr` adds self by id, and the
matching `--peer` then adds it AGAIN under a synthetic `peer-N` id (an
unprefixed address gets a positional id by construction, so it can never
match a real node id, and filterSelf — which compares ids — could never
see it). Three nodes therefore reported four expected members. mpcd now
drops a --peer that names this node, matched by address: equal to
--listen, or a host whose FIRST DNS LABEL equals the node id (compared
whole, so mpc-node-10 is never mpc-node-1).

AND THE GATE WAS AN EQUALITY. `readyCount == len(peers)+1` reads an
OVERSHOOT as not-ready, so once the count passed the expected total
nothing could bring it back — permanently, across restarts. Signing
survived only because it already used >=. A readiness gate must fail
closed on too few, never on too many; both sites are >= now, so the
double-count could not have wedged keygen even before the fix above.

Verified by exercising isSelfAddr over the fleet's real addresses: the
live 3-node template on mpc-node-0 keeps 2 peers, expected total 3.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 16:39:45 -07:00

314 lines
8.3 KiB
Go

// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package transport
import (
"context"
"encoding/json"
"sort"
"sync"
"sync/atomic"
"time"
"github.com/luxfi/mpc/pkg/logger"
)
// Registry implements mpc.PeerRegistry using consensus-based transport
// instead of Consul for peer discovery and readiness tracking
type Registry struct {
nodeID string
peerNodeIDs []string
transport *Transport
readyMu sync.RWMutex
readyMap map[string]bool // nodeID -> ready
readyCount int64
ready atomic.Bool // all peers ready
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
}
// NewRegistry creates a new consensus-based peer registry
func NewRegistry(nodeID string, peerNodeIDs []string, transport *Transport) *Registry {
ctx, cancel := context.WithCancel(context.Background())
r := &Registry{
nodeID: nodeID,
peerNodeIDs: filterSelf(nodeID, peerNodeIDs),
transport: transport,
readyMap: make(map[string]bool),
readyCount: 1, // self is always ready
ctx: ctx,
cancel: cancel,
}
return r
}
func filterSelf(nodeID string, peerNodeIDs []string) []string {
filtered := make([]string, 0, len(peerNodeIDs))
for _, id := range peerNodeIDs {
if id != nodeID {
filtered = append(filtered, id)
}
}
return filtered
}
// Ready marks this node as ready and announces to peers
func (r *Registry) Ready() error {
ready := ReadySignal{
NodeID: r.nodeID,
Ready: true,
Timestamp: time.Now().UnixMilli(),
}
// Broadcast ready signal to all peers via the transport
return r.transport.broadcastReady(ready)
}
// Resign marks this node as not ready
func (r *Registry) Resign() error {
ready := ReadySignal{
NodeID: r.nodeID,
Ready: false,
Timestamp: time.Now().UnixMilli(),
}
return r.transport.broadcastReady(ready)
}
// WatchPeersReady starts watching for peer readiness
func (r *Registry) WatchPeersReady() {
// Subscribe to ready signals
unsubscribe, _ := r.transport.Subscribe("mpc:ready", r.handleReadySignal)
defer unsubscribe()
r.wg.Add(1)
go r.watchLoop()
// Also start logging status
r.wg.Add(1)
go r.logReadyStatus()
}
// watchLoop checks peer connectivity periodically
func (r *Registry) watchLoop() {
defer r.wg.Done()
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-r.ctx.Done():
return
case <-ticker.C:
r.checkPeerConnections()
}
}
}
// checkPeerConnections updates ready status based on transport connections
func (r *Registry) checkPeerConnections() {
connectedPeers := r.transport.GetPeers()
connectedSet := make(map[string]bool, len(connectedPeers))
for _, id := range connectedPeers {
connectedSet[id] = true
}
r.readyMu.Lock()
defer r.readyMu.Unlock()
var changed bool
// Check for new connections
for _, peerID := range connectedPeers {
if !r.readyMap[peerID] {
r.readyMap[peerID] = true
atomic.AddInt64(&r.readyCount, 1)
logger.Info("Peer connected", "peerID", peerID)
changed = true
}
}
// Check for disconnections
for peerID, ready := range r.readyMap {
if ready && !connectedSet[peerID] {
r.readyMap[peerID] = false
atomic.AddInt64(&r.readyCount, -1)
logger.Warn("Peer disconnected", "peerID", peerID)
changed = true
}
}
// Update overall ready status
// >=, never ==. Equality reads an OVERSHOOT as not-ready, and an overshoot
// is not a failure — it is a node that appears twice (self passed in its own
// --peer list) or one that rejoined under a second identity. On hanzo-mpc
// that arithmetic wedged KEYGEN permanently at `ready=5 expected=4` while
// signing (which already used >=) kept working, so the fleet read healthy
// and every new custody address was refused. A readiness gate must fail
// closed on TOO FEW, never on too many.
allReady := len(connectedPeers) >= len(r.peerNodeIDs)
wasReady := r.ready.Load()
if allReady && !wasReady {
r.ready.Store(true)
logger.Info("ALL PEERS ARE READY! Starting to accept MPC requests")
} else if !allReady && wasReady {
r.ready.Store(false)
}
_ = changed // Suppress unused warning
}
// handleReadySignal processes ready signals from peers
func (r *Registry) handleReadySignal(msg *Message) {
var ready ReadySignal
if err := ready.Unmarshal(msg.Data); err != nil {
logger.Error("Failed to unmarshal ready signal", err)
return
}
if ready.NodeID == r.nodeID {
return // Ignore our own signals
}
r.readyMu.Lock()
defer r.readyMu.Unlock()
wasReady := r.readyMap[ready.NodeID]
if ready.Ready && !wasReady {
r.readyMap[ready.NodeID] = true
atomic.AddInt64(&r.readyCount, 1)
logger.Info("Peer became ready", "peerID", ready.NodeID)
} else if !ready.Ready && wasReady {
r.readyMap[ready.NodeID] = false
atomic.AddInt64(&r.readyCount, -1)
logger.Warn("Peer resigned", "peerID", ready.NodeID)
}
// Update overall ready status
// >= for the same reason as above: too many ready peers is not too few.
allReady := atomic.LoadInt64(&r.readyCount) >= int64(len(r.peerNodeIDs)+1)
if allReady && !r.ready.Load() {
r.ready.Store(true)
logger.Info("ALL PEERS ARE READY! Starting to accept MPC requests")
} else if !allReady && r.ready.Load() {
r.ready.Store(false)
}
}
func (rs *ReadySignal) Unmarshal(data []byte) error {
return json.Unmarshal(data, rs)
}
// logReadyStatus periodically logs readiness status
func (r *Registry) logReadyStatus() {
defer r.wg.Done()
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-r.ctx.Done():
return
case <-ticker.C:
if !r.ArePeersReady() {
logger.Info("Peers not ready",
"ready", r.GetReadyPeersCount(),
"expected", len(r.peerNodeIDs)+1,
)
}
}
}
}
// ArePeersReady returns true if ALL peers are ready. Used by keygen sessions,
// which genuinely need every participant present to generate a fresh key.
func (r *Registry) ArePeersReady() bool {
return r.ready.Load()
}
// HasSigningQuorum returns true when this node has enough ready peers
// (including itself) to participate in a threshold signing round for the
// operator-configured t-of-n ensemble.
//
// Semantics match the LiquidMPC CRD `threshold` field and the mpcd
// --threshold CLI flag: `threshold` is the MINIMUM NUMBER OF SIGNERS
// REQUIRED to produce a signature (t in t-of-n). A 3-node 2-of-3 ensemble
// has threshold=2. A 5-node 3-of-5 ensemble has threshold=3.
//
// Quorum therefore holds when `readyCount >= threshold`, where readyCount
// includes self. A 2-of-3 cluster with one peer down has readyCount=2 and
// threshold=2 — quorum holds. If two peers drop, readyCount=1 < threshold=2
// and quorum is lost.
//
// NOTE on the CGGMP21 vs operator-threshold split: pkg/mpc/node.go still
// compares `len(signerPeerIDs) >= keyInfo.Threshold + 1`, where
// KeyInfo.Threshold is the CGGMP21 polynomial degree (one below the
// signers-required count). Those two variables are OFF BY ONE by design
// of the protocol library: the CLI/CRD surfaces "signers required", while
// the cryptographic API stores the polynomial degree. HasSigningQuorum
// uses the operator surface — callers pass the CLI/CRD threshold directly.
func (r *Registry) HasSigningQuorum(threshold int) bool {
if threshold <= 0 {
return atomic.LoadInt64(&r.readyCount) >= 1
}
return atomic.LoadInt64(&r.readyCount) >= int64(threshold)
}
// GetReadyPeersCount returns number of ready peers
func (r *Registry) GetReadyPeersCount() int64 {
return atomic.LoadInt64(&r.readyCount)
}
// GetTotalPeersCount returns total expected peers including self
func (r *Registry) GetTotalPeersCount() int64 {
return int64(len(r.peerNodeIDs) + 1)
}
// GetReadyPeersIncludeSelf returns all ready peer IDs including self, sorted
func (r *Registry) GetReadyPeersIncludeSelf() []string {
r.readyMu.RLock()
defer r.readyMu.RUnlock()
peers := make([]string, 0, len(r.readyMap)+1)
for peerID, ready := range r.readyMap {
if ready {
peers = append(peers, peerID)
}
}
peers = append(peers, r.nodeID)
// Sort for deterministic party ID ordering
sort.Strings(peers)
return peers
}
// Close stops the registry
func (r *Registry) Close() error {
r.cancel()
r.wg.Wait()
return nil
}
// broadcastReady sends a ready signal to all peers
func (t *Transport) broadcastReady(ready ReadySignal) error {
payload, err := json.Marshal(ready)
if err != nil {
return err
}
// Broadcast via transport
return t.broadcast(MsgMPCReady, payload)
}