Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 86 additions & 5 deletions universalClient/tss/coordinator/coordinator.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import (
type PushCoreClient interface {
GetLatestBlock(ctx context.Context) (uint64, error)
GetCurrentKey(ctx context.Context) (*utsstypes.TssKey, error)
GetKeyByID(ctx context.Context, keyID string) (*utsstypes.TssKey, error)
GetAllUniversalValidators(ctx context.Context) ([]*types.UniversalValidator, error)
}

Expand Down Expand Up @@ -472,11 +473,13 @@ func (c *Coordinator) processConfirmedEvents(ctx context.Context) error {
// For SIGN/FUND_MIGRATE: pick a random threshold subset (>2/3 of eligible) rather than all eligible.
// A threshold subset suffices for signing and is more resilient when some nodes are offline.
// For all other protocols (keygen, keyrefresh, quorum_change), all eligible must participate.
var participants []*types.UniversalValidator
if event.Type == store.EventTypeSignOutbound || event.Type == store.EventTypeSignFundMigrate {
participants = getSignParticipants(allValidators)
} else {
participants = getEligibleForProtocol(event.Type, allValidators)
participants, err := c.selectParticipants(ctx, event, allValidators)
if err != nil {
c.logger.Error().Err(err).
Str("event_id", event.EventID).
Str("type", event.Type).
Msg("cannot select participants for event")
continue
}
if participants == nil {
c.logger.Debug().Str("event_id", event.EventID).Str("type", event.Type).Msg("unknown protocol type")
Expand Down Expand Up @@ -1156,3 +1159,81 @@ func (c *Coordinator) assignFundMigrateNonce(ctx context.Context, event store.Ev

return builder.GetNextNonce(ctx, oldTSSAddr, true)
}

// selectParticipants picks who takes part in an event.
//
// For SIGN a random threshold subset (>2/3 of eligible) suffices and is more
// resilient when some nodes are offline. For all other protocols (keygen,
// keyrefresh, quorum change) every eligible validator must participate.
func (c *Coordinator) selectParticipants(
ctx context.Context,
event store.Event,
allValidators []*types.UniversalValidator,
) ([]*types.UniversalValidator, error) {
switch event.Type {
case store.EventTypeSignOutbound:
return getSignParticipants(allValidators), nil
case store.EventTypeSignFundMigrate:
// Signed with the old key's shares, so the signers must be drawn from
// the validators that hold them rather than from whoever is eligible
// now. A newcomer selected here has no such share and never ACKs, so
// the session stalls waiting for a party that cannot take part.
return c.fundMigrateParticipants(ctx, event, allValidators)
default:
return getEligibleForProtocol(event.Type, allValidators), nil
}
}

// fundMigrateParticipants selects signers for a fund migration from the
// validators that hold the old key's shares.
//
// The signature is produced with the old keyshare, so eligibility is decided by
// the historical shareholder set recorded on chain, not by who is a validator
// today. The required count is the old key's threshold for the same reason: it
// is the quorum that key was created under.
//
// Fails rather than returning a short set. Too few surviving shareholders means
// no subset can sign, and returning one anyway would stall the session on an
// ACK that is never coming instead of reporting why.
func (c *Coordinator) fundMigrateParticipants(
ctx context.Context,
event store.Event,
allValidators []*types.UniversalValidator,
) ([]*types.UniversalValidator, error) {
var migrationData utsstypes.FundMigrationInitiatedEventData
if err := json.Unmarshal(event.EventData, &migrationData); err != nil {
return nil, fmt.Errorf("parse fund migration data: %w", err)
}
if migrationData.OldKeyID == "" {
return nil, fmt.Errorf("fund migration event carries no old key id")
}

oldKey, err := c.pushCore.GetKeyByID(ctx, migrationData.OldKeyID)
if err != nil {
return nil, fmt.Errorf("fetch old key %s: %w", migrationData.OldKeyID, err)
}
if oldKey == nil || len(oldKey.Participants) == 0 {
return nil, fmt.Errorf("old key %s records no participants", migrationData.OldKeyID)
}

shareholders := make(map[string]bool, len(oldKey.Participants))
for _, p := range oldKey.Participants {
shareholders[p] = true
}

var holders []*types.UniversalValidator
for _, v := range getSignEligible(allValidators) {
if v.IdentifyInfo != nil && shareholders[v.IdentifyInfo.CoreValidatorAddress] {
holders = append(holders, v)
}
}

required := CalculateThreshold(len(oldKey.Participants))
if len(holders) < required {
return nil, fmt.Errorf(
"key %s needs %d of its %d shareholders to sign, only %d are still eligible",
migrationData.OldKeyID, required, len(oldKey.Participants), len(holders))
}

return selectRandomSubset(holders, required), nil
}
11 changes: 11 additions & 0 deletions universalClient/tss/coordinator/coordinator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1123,6 +1123,10 @@ type stalenessMockPushCore struct {
block uint64
validators []*types.UniversalValidator
failGetAll bool

// Old key history, consulted when selecting fund migration signers.
keysByID map[string]*utsstypes.TssKey
keyErr error
}

func (m *stalenessMockPushCore) GetLatestBlock(_ context.Context) (uint64, error) {
Expand All @@ -1133,6 +1137,13 @@ func (m *stalenessMockPushCore) GetCurrentKey(_ context.Context) (*utsstypes.Tss
return &utsstypes.TssKey{KeyId: "test-key"}, nil
}

func (m *stalenessMockPushCore) GetKeyByID(_ context.Context, keyID string) (*utsstypes.TssKey, error) {
if m.keyErr != nil {
return nil, m.keyErr
}
return m.keysByID[keyID], nil
}

func (m *stalenessMockPushCore) GetAllUniversalValidators(_ context.Context) ([]*types.UniversalValidator, error) {
if m.failGetAll {
return nil, fmt.Errorf("simulated GetAllUniversalValidators RPC failure")
Expand Down
Loading
Loading