Skip to content
Merged
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
124 changes: 118 additions & 6 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 @@ -976,7 +979,7 @@ func getSignParticipants(allValidators []*types.UniversalValidator) []*types.Uni
eligible := getSignEligible(allValidators)

// Use utils function to select random threshold subset
return selectRandomThreshold(eligible)
return selectRandomThreshold(eligible, CalculateThreshold(len(eligible)))
}

// getInFlightSignCountPerChain returns per-chain in-flight SIGN count.
Expand Down Expand Up @@ -1156,3 +1159,112 @@ 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
}
}

// FundMigrateEligible returns the validators that may sign a fund migration,
// and how many of them are required.
//
// Used by the coordinator to select signers and by every participant to
// validate the selection it receives. Both derive the answer from the same
// chain state, so a set the coordinator can legitimately pick is a set the
// participants accept.
func (c *Coordinator) FundMigrateEligible(
ctx context.Context,
event store.Event,
) ([]*types.UniversalValidator, int, error) {
return c.fundMigrateEligible(ctx, event, c.validatorsSnapshot())
}

// fundMigrateParticipants selects signers for a fund migration from the
// validators that hold the old key's shares.
func (c *Coordinator) fundMigrateParticipants(
ctx context.Context,
event store.Event,
allValidators []*types.UniversalValidator,
) ([]*types.UniversalValidator, error) {
holders, required, err := c.fundMigrateEligible(ctx, event, allValidators)
if err != nil {
return nil, err
}
return selectRandomThreshold(holders, required), nil
}

// fundMigrateEligible resolves the eligible signers and the required count for
// a fund migration.
//
// 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 set that is already too small. Too few
// surviving shareholders means no subset can sign, and proceeding anyway would
// stall the session on an ACK that is never coming instead of reporting why.
//
// Nothing here can rebuild a lost quorum: an old key of N tolerates only
// N-threshold(N) departures, so migration must follow keygen promptly.
func (c *Coordinator) fundMigrateEligible(
ctx context.Context,
event store.Event,
allValidators []*types.UniversalValidator,
) ([]*types.UniversalValidator, int, error) {
var migrationData utsstypes.FundMigrationInitiatedEventData
if err := json.Unmarshal(event.EventData, &migrationData); err != nil {
return nil, 0, fmt.Errorf("parse fund migration data: %w", err)
}
if migrationData.OldKeyID == "" {
return nil, 0, fmt.Errorf("fund migration event carries no old key id")
}

oldKey, err := c.pushCore.GetKeyByID(ctx, migrationData.OldKeyID)
if err != nil {
return nil, 0, fmt.Errorf("fetch old key %s: %w", migrationData.OldKeyID, err)
}
if oldKey == nil || len(oldKey.Participants) == 0 {
return nil, 0, 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, 0, 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 holders, required, nil
}
19 changes: 15 additions & 4 deletions universalClient/tss/coordinator/coordinator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -379,21 +379,21 @@ func TestSelectRandomThreshold(t *testing.T) {

t.Run("returns exactly threshold count", func(t *testing.T) {
// threshold(5) = 4
assert.Len(t, selectRandomThreshold(makeN(5)), 4)
assert.Len(t, selectRandomThreshold(makeN(5), CalculateThreshold(5)), 4)
})

t.Run("returns all when count equals threshold", func(t *testing.T) {
// threshold(2) = 2 → returns all 2
assert.Len(t, selectRandomThreshold(makeN(2)), 2)
assert.Len(t, selectRandomThreshold(makeN(2), CalculateThreshold(2)), 2)
})

t.Run("returns all when count is below threshold", func(t *testing.T) {
// threshold(1) = 1 → returns all 1
assert.Len(t, selectRandomThreshold(makeN(1)), 1)
assert.Len(t, selectRandomThreshold(makeN(1), CalculateThreshold(1)), 1)
})

t.Run("returns nil for empty list", func(t *testing.T) {
assert.Nil(t, selectRandomThreshold(nil))
assert.Nil(t, selectRandomThreshold(nil, 3))
})
}

Expand Down 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