mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
Under LP-0130's canonical roster, chains/thresholdvm/ hosts the M-Chain VM (MPC threshold signing). The package name "thresholdvm" was the last piece of T-Chain nomenclature still shipping in code — legacy from the pre-LP-134 shared-substrate design. Renamed to mpcvm/ so the package name matches the M-Chain letter and LP-7100 spec. - chains/thresholdvm/ → chains/mpcvm/ (dir + inner files + package decl) - Sed-replace thresholdvm → mpcvm across .go / .md / .yml / .json / .sh - Fee/settlement primitive references now say mpcvm - LLM.md canonical chain roster (LP-0130) already points at mpcvm/ F-Chain runtime pieces (fhe/, protocol/tfhe_keygen/, runtime/f_chain_adapter.go) stay under mpcvm/ for now — they consume the same shared threshold primitives. A follow-up extract to chains/fhevm/ can happen once F-Chain runtime graduates. Co-authored-by: Hanzo Dev <[email protected]>
1171 lines
32 KiB
Go
1171 lines
32 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/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()
|
|
|
|
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
|
|
threshold := lm.config.DefaultThreshold
|
|
if threshold > len(participants) {
|
|
threshold = (len(participants) * 2) / 3 // 2/3 majority
|
|
}
|
|
|
|
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 loads persisted lifecycle state
|
|
func (lm *LifecycleManager) loadState() error {
|
|
if lm.registry == nil || lm.registry.db == nil {
|
|
return nil
|
|
}
|
|
|
|
// Load any active DKG state
|
|
currentEpoch := lm.registry.GetCurrentEpoch()
|
|
dkgKey := append([]byte("lifecycle:dkg:"), encodeUint64(currentEpoch)...)
|
|
if dkgData, err := lm.registry.db.Get(dkgKey); err == nil && len(dkgData) > 0 {
|
|
var dkg DKGState
|
|
if err := json.Unmarshal(dkgData, &dkg); err == nil {
|
|
// 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())
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load any active transition state
|
|
transitionKey := append([]byte("lifecycle:transition:"), encodeUint64(currentEpoch+1)...)
|
|
if transitionData, err := lm.registry.db.Get(transitionKey); err == nil && len(transitionData) > 0 {
|
|
var transition TransitionState
|
|
if err := json.Unmarshal(transitionData, &transition); err == nil {
|
|
// 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
|
|
}
|