mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
The lane that did this was killed before it could commit; the tree it left is green and is preserved here rather than lost. Root package 51.5% at the start of the day, 93.3% at its last checkpoint, 98.6% now, with cert, fhe, types and cmd/plugin all passing. Includes the fault-injection suite it was building when it stopped: a node that cannot read its state does not start, and one that cannot enumerate its registry does not either. Not reviewed by me beyond build, vet, gofmt and the full suite under -count=1. The lane's own report is owed and did not arrive, so its mutation count and its verdict on the mpcvm/fhe duplication are unrecorded.
1199 lines
33 KiB
Go
1199 lines
33 KiB
Go
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package fhe
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/luxfi/crypto/bls"
|
|
"github.com/luxfi/database"
|
|
"github.com/luxfi/ids"
|
|
"github.com/luxfi/log"
|
|
)
|
|
|
|
var (
|
|
ErrEpochNotActive = errors.New("epoch not active")
|
|
ErrEpochAlreadyActive = errors.New("epoch already active")
|
|
ErrCommitteeFull = errors.New("committee is full")
|
|
ErrMemberNotFound = errors.New("committee member not found")
|
|
ErrMemberAlreadyExists = errors.New("committee member already exists")
|
|
ErrInsufficientWeight = errors.New("insufficient committee weight")
|
|
ErrDKGInProgress = errors.New("DKG ceremony in progress")
|
|
ErrDKGNotStarted = errors.New("DKG ceremony not started")
|
|
ErrDKGFailed = errors.New("DKG ceremony failed")
|
|
ErrMissingCommitment = errors.New("participant must submit commitment first")
|
|
ErrNotParticipant = errors.New("node is not a DKG participant")
|
|
ErrTransitionInProgress = errors.New("epoch transition in progress")
|
|
ErrInvalidThreshold = errors.New("invalid threshold")
|
|
ErrEpochExpired = errors.New("epoch has expired")
|
|
)
|
|
|
|
// LifecycleConfig configures the lifecycle manager
|
|
type LifecycleConfig struct {
|
|
// EpochDuration is the duration of each epoch in blocks
|
|
EpochDuration uint64 `json:"epoch_duration"`
|
|
// GracePeriod is the number of blocks to allow pending requests during transition
|
|
GracePeriod uint64 `json:"grace_period"`
|
|
// MinCommitteeSize is the minimum number of committee members
|
|
MinCommitteeSize int `json:"min_committee_size"`
|
|
// MaxCommitteeSize is the maximum number of committee members
|
|
MaxCommitteeSize int `json:"max_committee_size"`
|
|
// DefaultThreshold is the default threshold (e.g., 67 for 67-of-100)
|
|
DefaultThreshold int `json:"default_threshold"`
|
|
// DKGTimeout is the timeout for DKG ceremonies
|
|
DKGTimeout time.Duration `json:"dkg_timeout"`
|
|
// KeyRotationBlocks is how often to rotate keys (0 = never)
|
|
KeyRotationBlocks uint64 `json:"key_rotation_blocks"`
|
|
}
|
|
|
|
// DefaultLifecycleConfig returns sensible defaults
|
|
func DefaultLifecycleConfig() *LifecycleConfig {
|
|
return &LifecycleConfig{
|
|
EpochDuration: 100000, // ~1 day at 1 block/sec
|
|
GracePeriod: 1000, // ~16 minutes
|
|
MinCommitteeSize: 4,
|
|
MaxCommitteeSize: 100,
|
|
DefaultThreshold: 67,
|
|
DKGTimeout: 5 * time.Minute,
|
|
KeyRotationBlocks: 0, // Disabled by default
|
|
}
|
|
}
|
|
|
|
// DKGState represents the state of a DKG ceremony
|
|
type DKGState struct {
|
|
CeremonyID [32]byte `json:"ceremony_id"`
|
|
Epoch uint64 `json:"epoch"`
|
|
Participants []ids.NodeID `json:"participants"`
|
|
Threshold int `json:"threshold"`
|
|
Shares map[string][]byte `json:"shares"` // NodeID hex -> encrypted share
|
|
Commitments map[string][]byte `json:"commitments"`
|
|
PublicKey []byte `json:"public_key,omitempty"`
|
|
Status DKGStatus `json:"status"`
|
|
StartedAt int64 `json:"started_at"`
|
|
CompletedAt int64 `json:"completed_at,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
type DKGStatus uint8
|
|
|
|
const (
|
|
DKGPending DKGStatus = iota
|
|
DKGCommitPhase
|
|
DKGSharePhase
|
|
DKGCompleted
|
|
DKGFailed
|
|
DKGAborted
|
|
)
|
|
|
|
func (s DKGStatus) String() string {
|
|
switch s {
|
|
case DKGPending:
|
|
return "pending"
|
|
case DKGCommitPhase:
|
|
return "commit_phase"
|
|
case DKGSharePhase:
|
|
return "share_phase"
|
|
case DKGCompleted:
|
|
return "completed"
|
|
case DKGFailed:
|
|
return "failed"
|
|
case DKGAborted:
|
|
return "aborted"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
// TransitionState tracks epoch transition progress
|
|
type TransitionState struct {
|
|
FromEpoch uint64 `json:"from_epoch"`
|
|
ToEpoch uint64 `json:"to_epoch"`
|
|
Status TransitionStatus `json:"status"`
|
|
StartedAt int64 `json:"started_at"`
|
|
CompletedAt int64 `json:"completed_at,omitempty"`
|
|
PendingRequests int `json:"pending_requests"`
|
|
MigratedCount int `json:"migrated_count"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
type TransitionStatus uint8
|
|
|
|
const (
|
|
TransitionPending TransitionStatus = iota
|
|
TransitionDKGPhase
|
|
TransitionMigrationPhase
|
|
TransitionFinalizingPhase
|
|
TransitionCompleted
|
|
TransitionFailed
|
|
)
|
|
|
|
func (s TransitionStatus) String() string {
|
|
switch s {
|
|
case TransitionPending:
|
|
return "pending"
|
|
case TransitionDKGPhase:
|
|
return "dkg_phase"
|
|
case TransitionMigrationPhase:
|
|
return "migration_phase"
|
|
case TransitionFinalizingPhase:
|
|
return "finalizing"
|
|
case TransitionCompleted:
|
|
return "completed"
|
|
case TransitionFailed:
|
|
return "failed"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
// MemberRegistration represents a pending committee member registration
|
|
type MemberRegistration struct {
|
|
NodeID ids.NodeID `json:"node_id"`
|
|
PublicKey []byte `json:"public_key"`
|
|
Stake uint64 `json:"stake"`
|
|
RegisteredAt int64 `json:"registered_at"`
|
|
ActivatedAt int64 `json:"activated_at,omitempty"`
|
|
Status MemberStatus `json:"status"`
|
|
}
|
|
|
|
type MemberStatus uint8
|
|
|
|
const (
|
|
MemberPending MemberStatus = iota
|
|
MemberActive
|
|
MemberInactive
|
|
MemberSlashed
|
|
MemberExiting
|
|
)
|
|
|
|
func (s MemberStatus) String() string {
|
|
switch s {
|
|
case MemberPending:
|
|
return "pending"
|
|
case MemberActive:
|
|
return "active"
|
|
case MemberInactive:
|
|
return "inactive"
|
|
case MemberSlashed:
|
|
return "slashed"
|
|
case MemberExiting:
|
|
return "exiting"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
// deferredCallback holds callback information to invoke after releasing the mutex.
|
|
// This prevents deadlock by ensuring external callbacks are never called while holding locks.
|
|
type deferredCallback struct {
|
|
// DKG completion callback data
|
|
dkgComplete bool
|
|
dkgEpoch uint64
|
|
dkgPublicKey []byte
|
|
|
|
// Epoch change callback data
|
|
epochChange bool
|
|
oldEpoch uint64
|
|
newEpoch uint64
|
|
}
|
|
|
|
// LifecycleManager manages epoch and committee lifecycle
|
|
type LifecycleManager struct {
|
|
registry *Registry
|
|
config *LifecycleConfig
|
|
logger log.Logger
|
|
|
|
mu sync.RWMutex
|
|
currentDKG *DKGState
|
|
currentTransition *TransitionState
|
|
|
|
// Callbacks
|
|
onEpochChange func(oldEpoch, newEpoch uint64)
|
|
onCommitteeChange func(members []CommitteeMember)
|
|
onDKGComplete func(epoch uint64, publicKey []byte)
|
|
onDKGStart func(ceremonyID [32]byte, epoch uint64, participants []ids.NodeID)
|
|
onDKGPhaseChange func(ceremonyID [32]byte, phase DKGStatus)
|
|
onSlash func(nodeID ids.NodeID, reason string)
|
|
|
|
// Block tracking
|
|
currentBlock uint64
|
|
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
// invokeCallback safely invokes deferred callbacks after the mutex is released.
|
|
// Must be called WITHOUT holding the mutex.
|
|
func (lm *LifecycleManager) invokeCallback(cb *deferredCallback) {
|
|
if cb == nil {
|
|
return
|
|
}
|
|
if cb.dkgComplete && lm.onDKGComplete != nil {
|
|
lm.onDKGComplete(cb.dkgEpoch, cb.dkgPublicKey)
|
|
}
|
|
if cb.epochChange && lm.onEpochChange != nil {
|
|
lm.onEpochChange(cb.oldEpoch, cb.newEpoch)
|
|
}
|
|
}
|
|
|
|
// NewLifecycleManager creates a new lifecycle manager
|
|
func NewLifecycleManager(registry *Registry, config *LifecycleConfig, logger log.Logger) *LifecycleManager {
|
|
if config == nil {
|
|
config = DefaultLifecycleConfig()
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
return &LifecycleManager{
|
|
registry: registry,
|
|
config: config,
|
|
logger: logger,
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
}
|
|
}
|
|
|
|
// Start begins lifecycle management
|
|
func (lm *LifecycleManager) Start() error {
|
|
lm.logger.Info("Starting FHE lifecycle manager")
|
|
|
|
// Load any pending DKG or transition state
|
|
if err := lm.loadState(); err != nil {
|
|
return fmt.Errorf("failed to load lifecycle state: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Stop halts lifecycle management
|
|
func (lm *LifecycleManager) Stop() {
|
|
lm.cancel()
|
|
lm.logger.Info("Stopped FHE lifecycle manager")
|
|
}
|
|
|
|
// OnBlock processes a new block for lifecycle events
|
|
func (lm *LifecycleManager) OnBlock(blockHeight uint64) error {
|
|
var cb *deferredCallback
|
|
var err error
|
|
|
|
func() {
|
|
lm.mu.Lock()
|
|
defer lm.mu.Unlock()
|
|
|
|
lm.currentBlock = blockHeight
|
|
|
|
// Check for DKG timeout
|
|
if lm.currentDKG != nil &&
|
|
lm.currentDKG.Status != DKGCompleted &&
|
|
lm.currentDKG.Status != DKGFailed &&
|
|
lm.currentDKG.Status != DKGAborted {
|
|
startTime := time.Unix(lm.currentDKG.StartedAt, 0)
|
|
if time.Since(startTime) > lm.config.DKGTimeout {
|
|
lm.logger.Warn("DKG ceremony timed out",
|
|
"ceremony_id", fmt.Sprintf("%x", lm.currentDKG.CeremonyID[:8]),
|
|
"epoch", lm.currentDKG.Epoch,
|
|
"started_at", lm.currentDKG.StartedAt,
|
|
"timeout", lm.config.DKGTimeout)
|
|
|
|
lm.currentDKG.Status = DKGFailed
|
|
lm.currentDKG.Error = "DKG ceremony timed out"
|
|
lm.currentDKG.CompletedAt = time.Now().Unix()
|
|
|
|
// Also fail the transition if in progress
|
|
if lm.currentTransition != nil &&
|
|
lm.currentTransition.Status == TransitionDKGPhase {
|
|
lm.currentTransition.Status = TransitionFailed
|
|
lm.currentTransition.Error = "DKG ceremony timed out"
|
|
lm.currentTransition.CompletedAt = time.Now().Unix()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if we need to start an epoch transition
|
|
if lm.shouldStartTransition(blockHeight) {
|
|
err = lm.startTransitionLocked()
|
|
return
|
|
}
|
|
|
|
// Check if we need to finalize a transition
|
|
if lm.currentTransition != nil && lm.shouldFinalizeTransition(blockHeight) {
|
|
cb, err = lm.finalizeTransitionLocked()
|
|
return
|
|
}
|
|
|
|
// Check for key rotation
|
|
if lm.config.KeyRotationBlocks > 0 && blockHeight%lm.config.KeyRotationBlocks == 0 {
|
|
lm.logger.Info("Triggering key rotation", "block", blockHeight)
|
|
err = lm.startKeyRotationLocked()
|
|
return
|
|
}
|
|
}()
|
|
|
|
// Invoke callback AFTER releasing the mutex to prevent deadlock
|
|
lm.invokeCallback(cb)
|
|
|
|
return err
|
|
}
|
|
|
|
// shouldStartTransition checks if we need to start a new epoch
|
|
func (lm *LifecycleManager) shouldStartTransition(blockHeight uint64) bool {
|
|
if lm.currentTransition != nil {
|
|
return false // Already transitioning
|
|
}
|
|
|
|
epoch := lm.registry.GetCurrentEpoch()
|
|
epochInfo, err := lm.registry.GetEpoch(epoch)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
// Check if epoch has exceeded duration
|
|
epochStartBlock := uint64(epochInfo.StartTime) // Assuming StartTime is block height for now
|
|
return blockHeight >= epochStartBlock+lm.config.EpochDuration
|
|
}
|
|
|
|
// shouldFinalizeTransition checks if transition can be finalized
|
|
func (lm *LifecycleManager) shouldFinalizeTransition(blockHeight uint64) bool {
|
|
if lm.currentTransition == nil {
|
|
return false
|
|
}
|
|
|
|
// Must be past grace period and DKG must be complete
|
|
transitionStart := uint64(lm.currentTransition.StartedAt)
|
|
if blockHeight < transitionStart+lm.config.GracePeriod {
|
|
return false
|
|
}
|
|
|
|
return lm.currentTransition.Status == TransitionFinalizingPhase
|
|
}
|
|
|
|
// RegisterMember registers a new committee member candidate
|
|
func (lm *LifecycleManager) RegisterMember(nodeID ids.NodeID, publicKey []byte, stake uint64) error {
|
|
lm.mu.Lock()
|
|
defer lm.mu.Unlock()
|
|
|
|
// Check if member already exists
|
|
_, err := lm.registry.GetCommitteeMember(nodeID)
|
|
if err == nil {
|
|
return ErrMemberAlreadyExists
|
|
}
|
|
|
|
// Check committee size
|
|
members, err := lm.registry.GetCommittee()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(members) >= lm.config.MaxCommitteeSize {
|
|
return ErrCommitteeFull
|
|
}
|
|
|
|
// Add member (will be activated on next epoch)
|
|
member := &CommitteeMember{
|
|
NodeID: nodeID,
|
|
PublicKey: publicKey,
|
|
Weight: stake,
|
|
Index: len(members),
|
|
}
|
|
|
|
if err := lm.registry.AddCommitteeMember(member); err != nil {
|
|
return err
|
|
}
|
|
|
|
lm.logger.Info("Registered committee member",
|
|
"nodeID", nodeID,
|
|
"stake", stake,
|
|
"index", member.Index)
|
|
|
|
return nil
|
|
}
|
|
|
|
// RemoveMember removes a committee member (effective next epoch)
|
|
func (lm *LifecycleManager) RemoveMember(nodeID ids.NodeID) error {
|
|
lm.mu.Lock()
|
|
defer lm.mu.Unlock()
|
|
|
|
if err := lm.registry.RemoveCommitteeMember(nodeID); err != nil {
|
|
return err
|
|
}
|
|
|
|
lm.logger.Info("Removed committee member", "nodeID", nodeID)
|
|
|
|
// Check if we still have enough members
|
|
members, err := lm.registry.GetCommittee()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(members) < lm.config.MinCommitteeSize {
|
|
lm.logger.Warn("Committee below minimum size",
|
|
"current", len(members),
|
|
"min", lm.config.MinCommitteeSize)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SlashMember slashes a misbehaving committee member
|
|
func (lm *LifecycleManager) SlashMember(nodeID ids.NodeID, reason string) error {
|
|
var onSlash func(ids.NodeID, string)
|
|
|
|
func() {
|
|
lm.mu.Lock()
|
|
defer lm.mu.Unlock()
|
|
|
|
lm.logger.Warn("Slashing committee member",
|
|
"nodeID", nodeID,
|
|
"reason", reason)
|
|
|
|
// Remove from active committee
|
|
if err := lm.registry.RemoveCommitteeMember(nodeID); err != nil {
|
|
return
|
|
}
|
|
|
|
// Capture callback to invoke after releasing lock
|
|
onSlash = lm.onSlash
|
|
}()
|
|
|
|
// Emit slashing event for on-chain penalty via callback
|
|
if onSlash != nil {
|
|
onSlash(nodeID, reason)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// InitiateEpoch creates the first epoch (genesis)
|
|
func (lm *LifecycleManager) InitiateEpoch(committee []CommitteeMember, threshold int, publicKey []byte) error {
|
|
lm.mu.Lock()
|
|
defer lm.mu.Unlock()
|
|
|
|
if len(committee) < lm.config.MinCommitteeSize {
|
|
return fmt.Errorf("committee size %d below minimum %d", len(committee), lm.config.MinCommitteeSize)
|
|
}
|
|
|
|
if threshold <= 0 || threshold > len(committee) {
|
|
return ErrInvalidThreshold
|
|
}
|
|
|
|
epochInfo := &EpochInfo{
|
|
Epoch: 1,
|
|
StartTime: time.Now().Unix(),
|
|
Committee: committee,
|
|
Threshold: threshold,
|
|
PublicKey: publicKey,
|
|
Status: EpochActive,
|
|
}
|
|
|
|
if err := lm.registry.SetEpoch(1, epochInfo); err != nil {
|
|
return err
|
|
}
|
|
|
|
lm.logger.Info("Initiated first epoch",
|
|
"committee_size", len(committee),
|
|
"threshold", threshold)
|
|
|
|
return nil
|
|
}
|
|
|
|
// StartDKG initiates a DKG ceremony for a new epoch
|
|
func (lm *LifecycleManager) StartDKG(epoch uint64, participants []ids.NodeID, threshold int) error {
|
|
var onDKGStart func([32]byte, uint64, []ids.NodeID)
|
|
var ceremonyID [32]byte
|
|
|
|
err := func() error {
|
|
lm.mu.Lock()
|
|
defer lm.mu.Unlock()
|
|
err := lm.startDKGLocked(epoch, participants, threshold)
|
|
if err == nil && lm.currentDKG != nil {
|
|
ceremonyID = lm.currentDKG.CeremonyID
|
|
onDKGStart = lm.onDKGStart
|
|
}
|
|
return err
|
|
}()
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Broadcast DKG start message to participants via callback
|
|
if onDKGStart != nil {
|
|
onDKGStart(ceremonyID, epoch, participants)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// startDKGLocked is the internal version that doesn't acquire the lock
|
|
func (lm *LifecycleManager) startDKGLocked(epoch uint64, participants []ids.NodeID, threshold int) error {
|
|
if lm.currentDKG != nil && lm.currentDKG.Status != DKGCompleted && lm.currentDKG.Status != DKGFailed && lm.currentDKG.Status != DKGAborted {
|
|
return ErrDKGInProgress
|
|
}
|
|
|
|
if len(participants) < lm.config.MinCommitteeSize {
|
|
return fmt.Errorf("not enough participants: %d < %d", len(participants), lm.config.MinCommitteeSize)
|
|
}
|
|
|
|
if threshold <= 0 || threshold > len(participants) {
|
|
return ErrInvalidThreshold
|
|
}
|
|
|
|
var ceremonyID [32]byte
|
|
if _, err := rand.Read(ceremonyID[:]); err != nil {
|
|
return fmt.Errorf("failed to generate ceremony ID: %w", err)
|
|
}
|
|
|
|
lm.currentDKG = &DKGState{
|
|
CeremonyID: ceremonyID,
|
|
Epoch: epoch,
|
|
Participants: participants,
|
|
Threshold: threshold,
|
|
Shares: make(map[string][]byte),
|
|
Commitments: make(map[string][]byte),
|
|
Status: DKGCommitPhase,
|
|
StartedAt: time.Now().Unix(),
|
|
}
|
|
|
|
lm.logger.Info("Started DKG ceremony",
|
|
"ceremony_id", fmt.Sprintf("%x", ceremonyID[:8]),
|
|
"epoch", epoch,
|
|
"participants", len(participants),
|
|
"threshold", threshold)
|
|
|
|
return nil
|
|
}
|
|
|
|
// SubmitDKGCommitment receives a commitment from a participant
|
|
func (lm *LifecycleManager) SubmitDKGCommitment(nodeID ids.NodeID, commitment []byte) error {
|
|
var onPhaseChange func([32]byte, DKGStatus)
|
|
var ceremonyID [32]byte
|
|
var movedToSharePhase bool
|
|
|
|
err := func() error {
|
|
lm.mu.Lock()
|
|
defer lm.mu.Unlock()
|
|
|
|
if lm.currentDKG == nil {
|
|
return ErrDKGNotStarted
|
|
}
|
|
|
|
if lm.currentDKG.Status != DKGCommitPhase {
|
|
return fmt.Errorf("DKG not in commit phase: %s", lm.currentDKG.Status)
|
|
}
|
|
|
|
// Verify participant
|
|
found := false
|
|
for _, p := range lm.currentDKG.Participants {
|
|
if p == nodeID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return fmt.Errorf("node %s not a DKG participant", nodeID)
|
|
}
|
|
|
|
lm.currentDKG.Commitments[nodeID.String()] = commitment
|
|
|
|
lm.logger.Debug("Received DKG commitment",
|
|
"nodeID", nodeID,
|
|
"commitments", len(lm.currentDKG.Commitments),
|
|
"total", len(lm.currentDKG.Participants))
|
|
|
|
// Check if we have all commitments
|
|
if len(lm.currentDKG.Commitments) == len(lm.currentDKG.Participants) {
|
|
lm.currentDKG.Status = DKGSharePhase
|
|
lm.logger.Info("DKG moving to share phase")
|
|
movedToSharePhase = true
|
|
ceremonyID = lm.currentDKG.CeremonyID
|
|
onPhaseChange = lm.onDKGPhaseChange
|
|
}
|
|
|
|
return nil
|
|
}()
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Broadcast share phase start via callback
|
|
if movedToSharePhase && onPhaseChange != nil {
|
|
onPhaseChange(ceremonyID, DKGSharePhase)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SubmitDKGShare receives an encrypted share from a participant
|
|
func (lm *LifecycleManager) SubmitDKGShare(nodeID ids.NodeID, share []byte) error {
|
|
var cb *deferredCallback
|
|
var err error
|
|
|
|
func() {
|
|
lm.mu.Lock()
|
|
defer lm.mu.Unlock()
|
|
|
|
if lm.currentDKG == nil {
|
|
err = ErrDKGNotStarted
|
|
return
|
|
}
|
|
|
|
if lm.currentDKG.Status != DKGSharePhase {
|
|
err = fmt.Errorf("DKG not in share phase: %s", lm.currentDKG.Status)
|
|
return
|
|
}
|
|
|
|
// Verify this node is a DKG participant
|
|
found := false
|
|
for _, p := range lm.currentDKG.Participants {
|
|
if p == nodeID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
err = ErrNotParticipant
|
|
return
|
|
}
|
|
|
|
// Verify this participant submitted a commitment (proves participation)
|
|
if _, hasCommitment := lm.currentDKG.Commitments[nodeID.String()]; !hasCommitment {
|
|
err = ErrMissingCommitment
|
|
return
|
|
}
|
|
|
|
lm.currentDKG.Shares[nodeID.String()] = share
|
|
|
|
lm.logger.Debug("Received DKG share",
|
|
"nodeID", nodeID,
|
|
"shares", len(lm.currentDKG.Shares),
|
|
"total", len(lm.currentDKG.Participants))
|
|
|
|
// Check if we have enough shares
|
|
if len(lm.currentDKG.Shares) >= lm.currentDKG.Threshold {
|
|
cb, err = lm.completeDKGLocked()
|
|
}
|
|
}()
|
|
|
|
// Invoke callback AFTER releasing the mutex to prevent deadlock
|
|
lm.invokeCallback(cb)
|
|
|
|
return err
|
|
}
|
|
|
|
// completeDKGLocked finalizes the DKG ceremony.
|
|
// Returns callback info to be invoked AFTER the mutex is released.
|
|
func (lm *LifecycleManager) completeDKGLocked() (*deferredCallback, error) {
|
|
// Aggregate public key from commitments
|
|
// In a real implementation, this would use the actual DKG protocol
|
|
publicKey := lm.aggregatePublicKey()
|
|
|
|
lm.currentDKG.PublicKey = publicKey
|
|
lm.currentDKG.Status = DKGCompleted
|
|
lm.currentDKG.CompletedAt = time.Now().Unix()
|
|
|
|
// A transition waiting on this ceremony can now be finalized. This is the
|
|
// only place that advances it: shouldFinalizeTransition finalizes only from
|
|
// TransitionFinalizingPhase, so without this the transition stays in
|
|
// TransitionDKGPhase forever, currentTransition never clears, and
|
|
// shouldStartTransition then refuses every later epoch as well.
|
|
if lm.currentTransition != nil && lm.currentTransition.Status == TransitionDKGPhase {
|
|
lm.currentTransition.Status = TransitionFinalizingPhase
|
|
}
|
|
|
|
lm.logger.Info("DKG ceremony completed",
|
|
"epoch", lm.currentDKG.Epoch,
|
|
"public_key_len", len(publicKey))
|
|
|
|
// Persist state after DKG completion (critical for crash recovery)
|
|
if err := lm.persistStateLocked(); err != nil {
|
|
lm.logger.Error("Failed to persist DKG completion state", "error", err)
|
|
}
|
|
|
|
// Return callback info - caller will invoke after releasing lock
|
|
return &deferredCallback{
|
|
dkgComplete: true,
|
|
dkgEpoch: lm.currentDKG.Epoch,
|
|
dkgPublicKey: publicKey,
|
|
}, nil
|
|
}
|
|
|
|
// aggregatePublicKey combines commitments into the threshold public key.
|
|
// Uses BLS12-381 elliptic curve point addition for Feldman VSS commitment coefficients.
|
|
// The first 48 bytes of each commitment are the compressed BLS public key share (G1 point).
|
|
// Falls back to deterministic XOR combination for non-BLS commitments or testing.
|
|
func (lm *LifecycleManager) aggregatePublicKey() []byte {
|
|
if len(lm.currentDKG.Commitments) == 0 {
|
|
// Return 32-byte zero slice for empty commitments (maintains API compatibility)
|
|
return make([]byte, 32)
|
|
}
|
|
|
|
// Try BLS aggregation first for proper cryptographic security
|
|
var pubKeys []*bls.PublicKey
|
|
for nodeID, commitment := range lm.currentDKG.Commitments {
|
|
if len(commitment) >= bls.PublicKeyLen {
|
|
// Parse BLS public key from commitment (first 48 bytes is compressed G1 point)
|
|
pubKey, err := bls.PublicKeyFromCompressedBytes(commitment[:bls.PublicKeyLen])
|
|
if err == nil {
|
|
pubKeys = append(pubKeys, pubKey)
|
|
} else {
|
|
lm.logger.Debug("Commitment not in BLS format, using fallback",
|
|
"nodeID", nodeID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we have valid BLS keys, use proper curve arithmetic
|
|
if len(pubKeys) > 0 {
|
|
aggregatedPubKey, err := bls.AggregatePublicKeys(pubKeys)
|
|
if err == nil {
|
|
return bls.PublicKeyToCompressedBytes(aggregatedPubKey)
|
|
}
|
|
lm.logger.Warn("BLS aggregation failed, using fallback", "error", err)
|
|
}
|
|
|
|
// Fallback: deterministic XOR combination for non-BLS commitments or testing
|
|
// This provides a unique, reproducible aggregate but is NOT cryptographically secure
|
|
// Production deployments MUST use proper BLS-formatted commitments
|
|
result := make([]byte, 32)
|
|
for _, commitment := range lm.currentDKG.Commitments {
|
|
if len(commitment) >= 32 {
|
|
for i := 0; i < 32; i++ {
|
|
result[i] ^= commitment[i]
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// AbortDKG aborts a DKG ceremony
|
|
func (lm *LifecycleManager) AbortDKG(reason string) error {
|
|
lm.mu.Lock()
|
|
defer lm.mu.Unlock()
|
|
|
|
if lm.currentDKG == nil {
|
|
return ErrDKGNotStarted
|
|
}
|
|
|
|
lm.currentDKG.Status = DKGAborted
|
|
lm.currentDKG.Error = reason
|
|
lm.currentDKG.CompletedAt = time.Now().Unix()
|
|
|
|
lm.logger.Warn("DKG ceremony aborted", "reason", reason)
|
|
|
|
// Persist aborted state for crash recovery
|
|
if err := lm.persistStateLocked(); err != nil {
|
|
lm.logger.Error("Failed to persist DKG abort state", "error", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// startTransitionLocked begins an epoch transition
|
|
func (lm *LifecycleManager) startTransitionLocked() error {
|
|
currentEpoch := lm.registry.GetCurrentEpoch()
|
|
newEpoch := currentEpoch + 1
|
|
|
|
lm.currentTransition = &TransitionState{
|
|
FromEpoch: currentEpoch,
|
|
ToEpoch: newEpoch,
|
|
Status: TransitionDKGPhase,
|
|
StartedAt: time.Now().Unix(),
|
|
}
|
|
|
|
lm.logger.Info("Starting epoch transition",
|
|
"from", currentEpoch,
|
|
"to", newEpoch)
|
|
|
|
// Persist transition state immediately for crash recovery
|
|
if err := lm.persistStateLocked(); err != nil {
|
|
lm.logger.Error("Failed to persist transition start state", "error", err)
|
|
}
|
|
|
|
// Get current committee for new DKG
|
|
members, err := lm.registry.GetCommittee()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
participants := make([]ids.NodeID, len(members))
|
|
for i, m := range members {
|
|
participants[i] = m.NodeID
|
|
}
|
|
|
|
// Start DKG for new epoch. A committee too small for the configured
|
|
// threshold falls back to the smallest quorum above two thirds, the same
|
|
// rule DefaultThreshold encodes: 2*100/3+1 is 67, the configured default.
|
|
// Without the +1 the fallback is 2*n/3, which is 66 of 100 -- and 2 of 4,
|
|
// half the committee.
|
|
threshold := lm.config.DefaultThreshold
|
|
if threshold > len(participants) {
|
|
threshold = (len(participants)*2)/3 + 1
|
|
}
|
|
|
|
return lm.startDKGLocked(newEpoch, participants, threshold)
|
|
}
|
|
|
|
// finalizeTransitionLocked completes the epoch transition.
|
|
// Returns callback info to be invoked AFTER the mutex is released.
|
|
func (lm *LifecycleManager) finalizeTransitionLocked() (*deferredCallback, error) {
|
|
if lm.currentTransition == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
if lm.currentDKG == nil || lm.currentDKG.Status != DKGCompleted {
|
|
return nil, ErrDKGNotStarted
|
|
}
|
|
|
|
// End current epoch
|
|
currentEpoch := lm.registry.GetCurrentEpoch()
|
|
epochInfo, err := lm.registry.GetEpoch(currentEpoch)
|
|
if err == nil {
|
|
epochInfo.Status = EpochEnded
|
|
epochInfo.EndTime = time.Now().Unix()
|
|
if err := lm.registry.SetEpoch(currentEpoch, epochInfo); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Create new epoch with DKG results
|
|
members, _ := lm.registry.GetCommittee()
|
|
newEpochInfo := &EpochInfo{
|
|
Epoch: lm.currentTransition.ToEpoch,
|
|
StartTime: time.Now().Unix(),
|
|
Committee: members,
|
|
Threshold: lm.currentDKG.Threshold,
|
|
PublicKey: lm.currentDKG.PublicKey,
|
|
Status: EpochActive,
|
|
}
|
|
|
|
if err := lm.registry.SetEpoch(lm.currentTransition.ToEpoch, newEpochInfo); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Update transition state
|
|
lm.currentTransition.Status = TransitionCompleted
|
|
lm.currentTransition.CompletedAt = time.Now().Unix()
|
|
|
|
oldEpoch := lm.currentTransition.FromEpoch
|
|
newEpoch := lm.currentTransition.ToEpoch
|
|
|
|
lm.logger.Info("Epoch transition completed",
|
|
"from", oldEpoch,
|
|
"to", newEpoch,
|
|
"committee_size", len(members),
|
|
"threshold", lm.currentDKG.Threshold)
|
|
|
|
// Persist final state before clearing (critical for crash recovery)
|
|
if err := lm.persistStateLocked(); err != nil {
|
|
lm.logger.Error("Failed to persist transition completion state", "error", err)
|
|
}
|
|
|
|
// Clear transition state
|
|
lm.currentTransition = nil
|
|
lm.currentDKG = nil
|
|
|
|
// Return callback info - caller will invoke after releasing lock
|
|
return &deferredCallback{
|
|
epochChange: true,
|
|
oldEpoch: oldEpoch,
|
|
newEpoch: newEpoch,
|
|
}, nil
|
|
}
|
|
|
|
// startKeyRotationLocked initiates a key rotation within the current epoch
|
|
func (lm *LifecycleManager) startKeyRotationLocked() error {
|
|
// Key rotation uses re-encryption rather than full epoch transition
|
|
// This allows refreshing keys without changing the epoch
|
|
|
|
currentEpoch := lm.registry.GetCurrentEpoch()
|
|
members, err := lm.registry.GetCommittee()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
participants := make([]ids.NodeID, len(members))
|
|
for i, m := range members {
|
|
participants[i] = m.NodeID
|
|
}
|
|
|
|
epochInfo, err := lm.registry.GetEpoch(currentEpoch)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Start a new DKG but stay in the same epoch
|
|
return lm.startDKGLocked(currentEpoch, participants, epochInfo.Threshold)
|
|
}
|
|
|
|
// GetDKGState returns the current DKG state
|
|
func (lm *LifecycleManager) GetDKGState() *DKGState {
|
|
lm.mu.RLock()
|
|
defer lm.mu.RUnlock()
|
|
return lm.currentDKG
|
|
}
|
|
|
|
// GetTransitionState returns the current transition state
|
|
func (lm *LifecycleManager) GetTransitionState() *TransitionState {
|
|
lm.mu.RLock()
|
|
defer lm.mu.RUnlock()
|
|
return lm.currentTransition
|
|
}
|
|
|
|
// IsTransitioning returns whether an epoch transition is in progress
|
|
func (lm *LifecycleManager) IsTransitioning() bool {
|
|
lm.mu.RLock()
|
|
defer lm.mu.RUnlock()
|
|
return lm.currentTransition != nil
|
|
}
|
|
|
|
// GetCommitteeWeight calculates total committee weight
|
|
func (lm *LifecycleManager) GetCommitteeWeight() (uint64, error) {
|
|
members, err := lm.registry.GetCommittee()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
var total uint64
|
|
for _, m := range members {
|
|
total += m.Weight
|
|
}
|
|
return total, nil
|
|
}
|
|
|
|
// ValidateThresholdMet checks if threshold requirements are met
|
|
func (lm *LifecycleManager) ValidateThresholdMet(participantCount int) error {
|
|
epoch := lm.registry.GetCurrentEpoch()
|
|
epochInfo, err := lm.registry.GetEpoch(epoch)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if participantCount < epochInfo.Threshold {
|
|
return ErrInsufficientWeight
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SetCallbacks sets lifecycle event callbacks
|
|
func (lm *LifecycleManager) SetCallbacks(
|
|
onEpochChange func(oldEpoch, newEpoch uint64),
|
|
onCommitteeChange func(members []CommitteeMember),
|
|
onDKGComplete func(epoch uint64, publicKey []byte),
|
|
) {
|
|
lm.mu.Lock()
|
|
defer lm.mu.Unlock()
|
|
lm.onEpochChange = onEpochChange
|
|
lm.onCommitteeChange = onCommitteeChange
|
|
lm.onDKGComplete = onDKGComplete
|
|
}
|
|
|
|
// SetDKGCallbacks sets DKG-specific event callbacks
|
|
func (lm *LifecycleManager) SetDKGCallbacks(
|
|
onDKGStart func(ceremonyID [32]byte, epoch uint64, participants []ids.NodeID),
|
|
onDKGPhaseChange func(ceremonyID [32]byte, phase DKGStatus),
|
|
) {
|
|
lm.mu.Lock()
|
|
defer lm.mu.Unlock()
|
|
lm.onDKGStart = onDKGStart
|
|
lm.onDKGPhaseChange = onDKGPhaseChange
|
|
}
|
|
|
|
// SetSlashCallback sets the slashing event callback
|
|
func (lm *LifecycleManager) SetSlashCallback(onSlash func(nodeID ids.NodeID, reason string)) {
|
|
lm.mu.Lock()
|
|
defer lm.mu.Unlock()
|
|
lm.onSlash = onSlash
|
|
}
|
|
|
|
// loadState restores a ceremony or a transition that was in flight when the
|
|
// process last stopped.
|
|
//
|
|
// A record that is absent means there was nothing in flight. Anything else --
|
|
// an unreadable database, bytes that do not decode -- is reported, because
|
|
// continuing leaves the manager with no ceremony and no transition, which is
|
|
// indistinguishable from a clean start: the epoch the committee was halfway
|
|
// through would simply never finish, and nothing would say so.
|
|
func (lm *LifecycleManager) loadState() error {
|
|
if lm.registry == nil || lm.registry.db == nil {
|
|
return nil
|
|
}
|
|
|
|
currentEpoch := lm.registry.GetCurrentEpoch()
|
|
|
|
dkgData, err := lm.registry.db.Get(append([]byte("lifecycle:dkg:"), encodeUint64(currentEpoch)...))
|
|
if err != nil && !errors.Is(err, database.ErrNotFound) {
|
|
return fmt.Errorf("read DKG state: %w", err)
|
|
}
|
|
if len(dkgData) > 0 {
|
|
var dkg DKGState
|
|
if err := json.Unmarshal(dkgData, &dkg); err != nil {
|
|
return fmt.Errorf("decode DKG state: %w", err)
|
|
}
|
|
// Only restore if DKG is still in progress
|
|
if dkg.Status != DKGCompleted && dkg.Status != DKGFailed && dkg.Status != DKGAborted {
|
|
lm.currentDKG = &dkg
|
|
lm.logger.Info("Restored DKG state from database",
|
|
"epoch", dkg.Epoch,
|
|
"status", dkg.Status.String())
|
|
}
|
|
}
|
|
|
|
transitionData, err := lm.registry.db.Get(append([]byte("lifecycle:transition:"), encodeUint64(currentEpoch+1)...))
|
|
if err != nil && !errors.Is(err, database.ErrNotFound) {
|
|
return fmt.Errorf("read transition state: %w", err)
|
|
}
|
|
if len(transitionData) > 0 {
|
|
var transition TransitionState
|
|
if err := json.Unmarshal(transitionData, &transition); err != nil {
|
|
return fmt.Errorf("decode transition state: %w", err)
|
|
}
|
|
// Only restore if transition is still in progress
|
|
if transition.Status != TransitionCompleted && transition.Status != TransitionFailed {
|
|
lm.currentTransition = &transition
|
|
lm.logger.Info("Restored transition state from database",
|
|
"fromEpoch", transition.FromEpoch,
|
|
"toEpoch", transition.ToEpoch,
|
|
"status", transition.Status.String())
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// persistStateLocked saves lifecycle state (caller must hold mutex)
|
|
func (lm *LifecycleManager) persistStateLocked() error {
|
|
if lm.registry == nil || lm.registry.db == nil {
|
|
return nil
|
|
}
|
|
|
|
// Persist DKG state if active
|
|
if lm.currentDKG != nil {
|
|
data, err := json.Marshal(lm.currentDKG)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
key := append([]byte("lifecycle:dkg:"), encodeUint64(lm.currentDKG.Epoch)...)
|
|
if err := lm.registry.db.Put(key, data); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Persist transition state if active
|
|
if lm.currentTransition != nil {
|
|
data, err := json.Marshal(lm.currentTransition)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
key := append([]byte("lifecycle:transition:"), encodeUint64(lm.currentTransition.ToEpoch)...)
|
|
if err := lm.registry.db.Put(key, data); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// persistState saves lifecycle state (acquires lock)
|
|
func (lm *LifecycleManager) persistState() error {
|
|
lm.mu.RLock()
|
|
defer lm.mu.RUnlock()
|
|
return lm.persistStateLocked()
|
|
}
|
|
|
|
// EpochKeyInfo returns the public key info for an epoch
|
|
type EpochKeyInfo struct {
|
|
Epoch uint64 `json:"epoch"`
|
|
PublicKey []byte `json:"public_key"`
|
|
Threshold int `json:"threshold"`
|
|
Committee int `json:"committee_size"`
|
|
IsActive bool `json:"is_active"`
|
|
}
|
|
|
|
// GetEpochKeyInfo returns key information for a specific epoch
|
|
func (lm *LifecycleManager) GetEpochKeyInfo(epoch uint64) (*EpochKeyInfo, error) {
|
|
epochInfo, err := lm.registry.GetEpoch(epoch)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &EpochKeyInfo{
|
|
Epoch: epoch,
|
|
PublicKey: epochInfo.PublicKey,
|
|
Threshold: epochInfo.Threshold,
|
|
Committee: len(epochInfo.Committee),
|
|
IsActive: epochInfo.Status == EpochActive,
|
|
}, nil
|
|
}
|
|
|
|
// ForceEpochTransition manually triggers an epoch transition (admin only)
|
|
func (lm *LifecycleManager) ForceEpochTransition() error {
|
|
lm.mu.Lock()
|
|
defer lm.mu.Unlock()
|
|
|
|
if lm.currentTransition != nil {
|
|
return ErrTransitionInProgress
|
|
}
|
|
|
|
lm.logger.Warn("Forcing epoch transition")
|
|
return lm.startTransitionLocked()
|
|
}
|
|
|
|
// GetLifecycleStatus returns overall lifecycle status
|
|
type LifecycleStatus struct {
|
|
CurrentEpoch uint64 `json:"current_epoch"`
|
|
CurrentBlock uint64 `json:"current_block"`
|
|
CommitteeSize int `json:"committee_size"`
|
|
Threshold int `json:"threshold"`
|
|
IsTransitioning bool `json:"is_transitioning"`
|
|
DKGStatus string `json:"dkg_status,omitempty"`
|
|
TransitionStatus string `json:"transition_status,omitempty"`
|
|
EpochProgress float64 `json:"epoch_progress"`
|
|
}
|
|
|
|
func (lm *LifecycleManager) GetStatus() (*LifecycleStatus, error) {
|
|
lm.mu.RLock()
|
|
defer lm.mu.RUnlock()
|
|
|
|
epoch := lm.registry.GetCurrentEpoch()
|
|
epochInfo, err := lm.registry.GetEpoch(epoch)
|
|
if err != nil {
|
|
epochInfo = &EpochInfo{}
|
|
}
|
|
|
|
status := &LifecycleStatus{
|
|
CurrentEpoch: epoch,
|
|
CurrentBlock: lm.currentBlock,
|
|
CommitteeSize: len(epochInfo.Committee),
|
|
Threshold: epochInfo.Threshold,
|
|
IsTransitioning: lm.currentTransition != nil,
|
|
}
|
|
|
|
if lm.currentDKG != nil {
|
|
status.DKGStatus = lm.currentDKG.Status.String()
|
|
}
|
|
|
|
if lm.currentTransition != nil {
|
|
status.TransitionStatus = lm.currentTransition.Status.String()
|
|
}
|
|
|
|
// Calculate epoch progress
|
|
epochStart := uint64(epochInfo.StartTime)
|
|
if lm.config.EpochDuration > 0 && lm.currentBlock >= epochStart {
|
|
progress := float64(lm.currentBlock-epochStart) / float64(lm.config.EpochDuration)
|
|
if progress > 1.0 {
|
|
progress = 1.0
|
|
}
|
|
status.EpochProgress = progress
|
|
}
|
|
|
|
return status, nil
|
|
}
|