From dea74fb2ea8b15fe3db275e972ba3c5d581d189c Mon Sep 17 00:00:00 2001 From: aman035 Date: Mon, 17 Aug 2026 14:05:10 +0530 Subject: [PATCH 1/7] fix: delete keyshares superseded by quorum change or key refresh (F-2026-18190) --- universalClient/pushcore/pushCore.go | 19 ++ universalClient/tss/keyshare/manager.go | 53 ++++++ universalClient/tss/keyshare/manager_test.go | 130 +++++++++++++ universalClient/tss/keysharegc/sweeper.go | 176 ++++++++++++++++++ .../tss/keysharegc/sweeper_test.go | 161 ++++++++++++++++ universalClient/tss/tss.go | 11 ++ 6 files changed, 550 insertions(+) create mode 100644 universalClient/tss/keysharegc/sweeper.go create mode 100644 universalClient/tss/keysharegc/sweeper_test.go diff --git a/universalClient/pushcore/pushCore.go b/universalClient/pushcore/pushCore.go index 647b548ea..7012557b3 100644 --- a/universalClient/pushcore/pushCore.go +++ b/universalClient/pushcore/pushCore.go @@ -211,6 +211,25 @@ func (c *Client) GetCurrentKey(ctx context.Context) (*utsstypes.TssKey, error) { ) } +// GetAllKeys retrieves the TSS key history from Push Chain, oldest first. +func (c *Client) GetAllKeys(ctx context.Context) ([]*utsstypes.TssKey, error) { + return retryWithRoundRobin( + len(c.utssClients), + &c.rr, + func(idx int) ([]*utsstypes.TssKey, error) { + resp, err := c.utssClients[idx].AllKeys(ctx, &utsstypes.QueryAllKeysRequest{ + Pagination: &query.PageRequest{Limit: 1000}, + }) + if err != nil { + return nil, err + } + return resp.Keys, nil + }, + "GetAllKeys", + c.logger, + ) +} + // GetGasPrice retrieves the median gas price for a specific chain from the on-chain oracle. func (c *Client) GetGasPrice(ctx context.Context, chainID string) (*big.Int, error) { if chainID == "" { diff --git a/universalClient/tss/keyshare/manager.go b/universalClient/tss/keyshare/manager.go index 0e5574261..0e599428e 100644 --- a/universalClient/tss/keyshare/manager.go +++ b/universalClient/tss/keyshare/manager.go @@ -144,6 +144,59 @@ func (m *Manager) Exists(id string) (bool, error) { return true, nil } +// List returns the IDs of all stored keyshares. +func (m *Manager) List() ([]string, error) { + entries, err := os.ReadDir(m.keysharesDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("failed to read keyshares directory: %w", err) + } + + ids := make([]string, 0, len(entries)) + for _, e := range entries { + if !e.IsDir() { + ids = append(ids, e.Name()) + } + } + return ids, nil +} + +// Delete removes a stored keyshare. It overwrites the file with random bytes +// before unlinking; on SSD/COW filesystems that is best-effort, so the real +// protection remains the at-rest encryption. Deleting a missing ID is a no-op. +func (m *Manager) Delete(id string) error { + if id == "" { + return ErrInvalidID + } + + if strings.Contains(id, "/") || strings.Contains(id, "\\") || strings.Contains(id, "..") { + return fmt.Errorf("%w: id contains invalid characters", ErrInvalidID) + } + + filePath := filepath.Join(m.keysharesDir, id) + info, err := os.Stat(filePath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("failed to stat keyshare file: %w", err) + } + + if info.Mode().IsRegular() && info.Size() > 0 { + scratch := make([]byte, info.Size()) + if _, rerr := rand.Read(scratch); rerr == nil { + _ = os.WriteFile(filePath, scratch, filePerms) + } + } + + if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove keyshare file: %w", err) + } + return nil +} + // encrypt encrypts keyshare data using AES-256-GCM with a password-derived key. // Returns encrypted data in format: [salt(32) || nonce(12) || ciphertext || tag(16)] func (m *Manager) encrypt(keyshareData []byte) ([]byte, error) { diff --git a/universalClient/tss/keyshare/manager_test.go b/universalClient/tss/keyshare/manager_test.go index 348d0d3d7..f7c9c37af 100644 --- a/universalClient/tss/keyshare/manager_test.go +++ b/universalClient/tss/keyshare/manager_test.go @@ -516,3 +516,133 @@ func TestManager_EncryptDecrypt(t *testing.T) { } }) } + +func TestList(t *testing.T) { + t.Run("empty directory", func(t *testing.T) { + mgr, err := NewManager(t.TempDir(), "pw") + if err != nil { + t.Fatalf("NewManager() error = %v", err) + } + ids, err := mgr.List() + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(ids) != 0 { + t.Errorf("List() = %v, want empty", ids) + } + }) + + t.Run("returns stored ids", func(t *testing.T) { + mgr, err := NewManager(t.TempDir(), "pw") + if err != nil { + t.Fatalf("NewManager() error = %v", err) + } + for _, id := range []string{"key-a", "key-b"} { + if err := mgr.Store([]byte("share-"+id), id); err != nil { + t.Fatalf("Store(%s) error = %v", id, err) + } + } + ids, err := mgr.List() + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(ids) != 2 { + t.Fatalf("List() returned %d ids, want 2", len(ids)) + } + found := map[string]bool{} + for _, id := range ids { + found[id] = true + } + if !found["key-a"] || !found["key-b"] { + t.Errorf("List() = %v, want key-a and key-b", ids) + } + }) + + t.Run("ignores subdirectories", func(t *testing.T) { + tmpDir := t.TempDir() + mgr, err := NewManager(tmpDir, "pw") + if err != nil { + t.Fatalf("NewManager() error = %v", err) + } + if err := os.MkdirAll(filepath.Join(mgr.keysharesDir, "nested"), dirPerms); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + ids, err := mgr.List() + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(ids) != 0 { + t.Errorf("List() = %v, want empty (dirs ignored)", ids) + } + }) +} + +func TestDelete(t *testing.T) { + t.Run("removes stored keyshare", func(t *testing.T) { + mgr, err := NewManager(t.TempDir(), "pw") + if err != nil { + t.Fatalf("NewManager() error = %v", err) + } + if err := mgr.Store([]byte("secret-share"), "key-1"); err != nil { + t.Fatalf("Store() error = %v", err) + } + if err := mgr.Delete("key-1"); err != nil { + t.Fatalf("Delete() error = %v", err) + } + if _, err := mgr.Get("key-1"); !errors.Is(err, ErrKeyshareNotFound) { + t.Errorf("Get() after Delete error = %v, want ErrKeyshareNotFound", err) + } + exists, err := mgr.Exists("key-1") + if err != nil { + t.Fatalf("Exists() error = %v", err) + } + if exists { + t.Error("Exists() = true after Delete, want false") + } + }) + + t.Run("missing id is a no-op", func(t *testing.T) { + mgr, err := NewManager(t.TempDir(), "pw") + if err != nil { + t.Fatalf("NewManager() error = %v", err) + } + if err := mgr.Delete("never-stored"); err != nil { + t.Errorf("Delete() on missing id error = %v, want nil", err) + } + }) + + t.Run("rejects invalid ids", func(t *testing.T) { + mgr, err := NewManager(t.TempDir(), "pw") + if err != nil { + t.Fatalf("NewManager() error = %v", err) + } + for _, id := range []string{"", "../escape", "sub/dir", "back\\slash"} { + if err := mgr.Delete(id); !errors.Is(err, ErrInvalidID) { + t.Errorf("Delete(%q) error = %v, want ErrInvalidID", id, err) + } + } + }) + + t.Run("leaves other keyshares intact", func(t *testing.T) { + mgr, err := NewManager(t.TempDir(), "pw") + if err != nil { + t.Fatalf("NewManager() error = %v", err) + } + if err := mgr.Store([]byte("share-a"), "key-a"); err != nil { + t.Fatalf("Store() error = %v", err) + } + if err := mgr.Store([]byte("share-b"), "key-b"); err != nil { + t.Fatalf("Store() error = %v", err) + } + if err := mgr.Delete("key-a"); err != nil { + t.Fatalf("Delete() error = %v", err) + } + got, err := mgr.Get("key-b") + if err != nil { + t.Fatalf("Get(key-b) error = %v", err) + } + if string(got) != "share-b" { + t.Errorf("Get(key-b) = %q, want %q", got, "share-b") + } + }) +} diff --git a/universalClient/tss/keysharegc/sweeper.go b/universalClient/tss/keysharegc/sweeper.go new file mode 100644 index 000000000..f8b188e45 --- /dev/null +++ b/universalClient/tss/keysharegc/sweeper.go @@ -0,0 +1,176 @@ +// Package keysharegc deletes keyshares that a quorum change or key refresh has +// made redundant, so a departed or rotated-out share cannot be used to sign for +// the live vault key. +package keysharegc + +import ( + "context" + "time" + + "github.com/rs/zerolog" + + utsstypes "github.com/pushchain/push-chain-node/x/utss/types" +) + +const defaultCheckInterval = 5 * time.Minute + +// PushCoreClient is the subset of pushcore.Client the sweeper depends on. +// Defined as an interface so tests can inject a mock. *pushcore.Client satisfies it. +type PushCoreClient interface { + GetCurrentKey(ctx context.Context) (*utsstypes.TssKey, error) + GetAllKeys(ctx context.Context) ([]*utsstypes.TssKey, error) + GetPendingTssEvents(ctx context.Context) ([]*utsstypes.TssEvent, error) + GetPendingFundMigrations(ctx context.Context) ([]*utsstypes.FundMigration, error) +} + +// KeyshareStore is the subset of keyshare.Manager the sweeper depends on. +type KeyshareStore interface { + List() ([]string, error) + Delete(id string) error +} + +// Config holds configuration for the keyshare sweeper. +type Config struct { + Keyshares KeyshareStore + PushCore PushCoreClient + CheckInterval time.Duration + Logger zerolog.Logger +} + +// Sweeper deletes local keyshares that chain state proves are redundant. +// +// A keyshare is deleted only when every one of these holds: +// - it is not the current key ID; +// - its TSS pubkey equals the current key's pubkey, i.e. a quorum change or +// key refresh superseded it while preserving the vault key; +// - no TSS process is pending, so no in-flight session can still load it; +// - no pending fund migration references it. +// +// Shares whose pubkey differs from the current one are kept: they belong to a +// rotated-away key that fund migration still needs to sweep its vault. Retiring +// those is an explicit operator action, since a chain that was never migrated is +// indistinguishable from one with nothing to migrate. +// +// Every chain-state lookup fails closed: on error the sweep is skipped and +// retried next tick rather than deleting on incomplete information. +type Sweeper struct { + keyshares KeyshareStore + pushCore PushCoreClient + checkInterval time.Duration + logger zerolog.Logger +} + +// NewSweeper creates a new keyshare sweeper. +func NewSweeper(cfg Config) *Sweeper { + interval := cfg.CheckInterval + if interval == 0 { + interval = defaultCheckInterval + } + return &Sweeper{ + keyshares: cfg.Keyshares, + pushCore: cfg.PushCore, + checkInterval: interval, + logger: cfg.Logger.With().Str("component", "keyshare_gc").Logger(), + } +} + +// Start begins the background sweep loop. +func (s *Sweeper) Start(ctx context.Context) { + go s.run(ctx) +} + +func (s *Sweeper) run(ctx context.Context) { + ticker := time.NewTicker(s.checkInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.sweep(ctx) + } + } +} + +func (s *Sweeper) sweep(ctx context.Context) { + if s.keyshares == nil || s.pushCore == nil { + return + } + + localIDs, err := s.keyshares.List() + if err != nil { + s.logger.Warn().Err(err).Msg("failed to list keyshares, skipping sweep") + return + } + if len(localIDs) <= 1 { + return + } + + current, err := s.pushCore.GetCurrentKey(ctx) + if err != nil { + s.logger.Debug().Err(err).Msg("failed to get current TSS key, skipping sweep") + return + } + if current == nil || current.KeyId == "" || current.TssPubkey == "" { + return + } + + // An in-flight session may still load a predecessor share; a missing share + // is silently reinterpreted as "new party" during quorum change, so wait. + pendingProcesses, err := s.pushCore.GetPendingTssEvents(ctx) + if err != nil { + s.logger.Debug().Err(err).Msg("failed to get pending TSS events, skipping sweep") + return + } + if len(pendingProcesses) > 0 { + s.logger.Debug().Int("pending", len(pendingProcesses)).Msg("TSS process pending, skipping sweep") + return + } + + allKeys, err := s.pushCore.GetAllKeys(ctx) + if err != nil { + s.logger.Debug().Err(err).Msg("failed to get TSS key history, skipping sweep") + return + } + pubkeyByID := make(map[string]string, len(allKeys)) + for _, k := range allKeys { + if k != nil { + pubkeyByID[k.KeyId] = k.TssPubkey + } + } + + migrations, err := s.pushCore.GetPendingFundMigrations(ctx) + if err != nil { + s.logger.Debug().Err(err).Msg("failed to get pending fund migrations, skipping sweep") + return + } + migrating := make(map[string]bool, len(migrations)) + for _, m := range migrations { + if m != nil { + migrating[m.OldKeyId] = true + } + } + + deleted := 0 + for _, id := range localIDs { + if id == current.KeyId { + continue + } + pubkey, known := pubkeyByID[id] + if !known || pubkey != current.TssPubkey || migrating[id] { + continue + } + if err := s.keyshares.Delete(id); err != nil { + s.logger.Warn().Err(err).Str("key_id", id).Msg("failed to delete superseded keyshare") + continue + } + deleted++ + s.logger.Info().Str("key_id", id).Str("current_key_id", current.KeyId). + Msg("deleted superseded keyshare") + } + + if deleted > 0 { + s.logger.Info().Int("deleted", deleted).Msg("keyshare sweep complete") + } +} diff --git a/universalClient/tss/keysharegc/sweeper_test.go b/universalClient/tss/keysharegc/sweeper_test.go new file mode 100644 index 000000000..fd71e894d --- /dev/null +++ b/universalClient/tss/keysharegc/sweeper_test.go @@ -0,0 +1,161 @@ +package keysharegc + +import ( + "context" + "errors" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + utsstypes "github.com/pushchain/push-chain-node/x/utss/types" +) + +const ( + pubkeyA = "0xAAA" + pubkeyB = "0xBBB" +) + +type mockStore struct { + ids []string + deleted []string + listErr error + delErr error +} + +func (m *mockStore) List() ([]string, error) { + if m.listErr != nil { + return nil, m.listErr + } + return m.ids, nil +} + +func (m *mockStore) Delete(id string) error { + if m.delErr != nil { + return m.delErr + } + m.deleted = append(m.deleted, id) + return nil +} + +type mockCore struct { + current *utsstypes.TssKey + keys []*utsstypes.TssKey + pending []*utsstypes.TssEvent + migrations []*utsstypes.FundMigration + + currentErr, keysErr, pendingErr, migErr error +} + +func (m *mockCore) GetCurrentKey(context.Context) (*utsstypes.TssKey, error) { + return m.current, m.currentErr +} +func (m *mockCore) GetAllKeys(context.Context) ([]*utsstypes.TssKey, error) { + return m.keys, m.keysErr +} +func (m *mockCore) GetPendingTssEvents(context.Context) ([]*utsstypes.TssEvent, error) { + return m.pending, m.pendingErr +} +func (m *mockCore) GetPendingFundMigrations(context.Context) ([]*utsstypes.FundMigration, error) { + return m.migrations, m.migErr +} + +func key(id, pubkey string) *utsstypes.TssKey { + return &utsstypes.TssKey{KeyId: id, TssPubkey: pubkey} +} + +// baseCore: K1 and K2 share pubkeyA (quorum change / refresh); K0 is a rotated +// away key on pubkeyB. K2 is current. +func baseCore() *mockCore { + return &mockCore{ + current: key("K2", pubkeyA), + keys: []*utsstypes.TssKey{ + key("K0", pubkeyB), + key("K1", pubkeyA), + key("K2", pubkeyA), + }, + } +} + +func sweepWith(t *testing.T, store *mockStore, core *mockCore) *mockStore { + t.Helper() + NewSweeper(Config{Keyshares: store, PushCore: core, Logger: zerolog.Nop()}).sweep(context.Background()) + return store +} + +func TestSweep_DeletesSupersededSamePubkeyShare(t *testing.T) { + store := sweepWith(t, &mockStore{ids: []string{"K1", "K2"}}, baseCore()) + assert.Equal(t, []string{"K1"}, store.deleted) +} + +func TestSweep_KeepsCurrentKey(t *testing.T) { + store := sweepWith(t, &mockStore{ids: []string{"K1", "K2"}}, baseCore()) + assert.NotContains(t, store.deleted, "K2") +} + +// A rotated-away key (different pubkey) may still be needed to sign fund +// migration out of the retired vault, so it must survive. +func TestSweep_KeepsRotatedAwayPubkeyShare(t *testing.T) { + store := sweepWith(t, &mockStore{ids: []string{"K0", "K1", "K2"}}, baseCore()) + assert.NotContains(t, store.deleted, "K0") + assert.Equal(t, []string{"K1"}, store.deleted) +} + +func TestSweep_KeepsShareReferencedByPendingFundMigration(t *testing.T) { + core := baseCore() + core.migrations = []*utsstypes.FundMigration{{OldKeyId: "K1"}} + store := sweepWith(t, &mockStore{ids: []string{"K1", "K2"}}, core) + assert.Empty(t, store.deleted) +} + +// A pending TSS process means an in-flight session may still load a predecessor +// share; a missing share is silently treated as "new party" during quorum change. +func TestSweep_SkipsWhileTssProcessPending(t *testing.T) { + core := baseCore() + core.pending = []*utsstypes.TssEvent{{}} + store := sweepWith(t, &mockStore{ids: []string{"K1", "K2"}}, core) + assert.Empty(t, store.deleted) +} + +// A share the chain doesn't know about is never deleted. +func TestSweep_KeepsUnknownKeyID(t *testing.T) { + store := sweepWith(t, &mockStore{ids: []string{"mystery", "K2"}}, baseCore()) + assert.Empty(t, store.deleted) +} + +func TestSweep_FailsClosedOnRPCError(t *testing.T) { + cases := map[string]func(*mockCore){ + "current key": func(c *mockCore) { c.currentErr = errors.New("boom") }, + "key history": func(c *mockCore) { c.keysErr = errors.New("boom") }, + "pending events": func(c *mockCore) { c.pendingErr = errors.New("boom") }, + "fund migrations": func(c *mockCore) { c.migErr = errors.New("boom") }, + } + for name, breakIt := range cases { + t.Run(name, func(t *testing.T) { + core := baseCore() + breakIt(core) + store := sweepWith(t, &mockStore{ids: []string{"K1", "K2"}}, core) + assert.Empty(t, store.deleted, "must not delete on incomplete chain state") + }) + } +} + +func TestSweep_NoopWhenSingleOrNoShare(t *testing.T) { + core := baseCore() + core.currentErr = errors.New("should not be called") + store := sweepWith(t, &mockStore{ids: []string{"K2"}}, core) + assert.Empty(t, store.deleted) +} + +func TestSweep_ContinuesAfterDeleteError(t *testing.T) { + store := &mockStore{ids: []string{"K1", "K2"}, delErr: errors.New("disk error")} + NewSweeper(Config{Keyshares: store, PushCore: baseCore(), Logger: zerolog.Nop()}). + sweep(context.Background()) + assert.Empty(t, store.deleted) +} + +func TestNewSweeper_DefaultInterval(t *testing.T) { + s := NewSweeper(Config{Keyshares: &mockStore{}, PushCore: baseCore(), Logger: zerolog.Nop()}) + require.Equal(t, defaultCheckInterval, s.checkInterval) +} diff --git a/universalClient/tss/tss.go b/universalClient/tss/tss.go index f54ca0650..0cc7a726d 100644 --- a/universalClient/tss/tss.go +++ b/universalClient/tss/tss.go @@ -22,6 +22,7 @@ import ( "github.com/pushchain/push-chain-node/universalClient/tss/eventstore" "github.com/pushchain/push-chain-node/universalClient/tss/expirysweeper" "github.com/pushchain/push-chain-node/universalClient/tss/keyshare" + "github.com/pushchain/push-chain-node/universalClient/tss/keysharegc" "github.com/pushchain/push-chain-node/universalClient/tss/networking" libp2pnet "github.com/pushchain/push-chain-node/universalClient/tss/networking/libp2p" "github.com/pushchain/push-chain-node/universalClient/tss/sessionmanager" @@ -105,6 +106,7 @@ type Node struct { txBroadcaster *txbroadcaster.Broadcaster txResolver *txresolver.Resolver expirySweeper *expirysweeper.Sweeper + keyshareGC *keysharegc.Sweeper // Network configuration (used during Start) networkCfg libp2pnet.Config @@ -269,6 +271,12 @@ func NewNode(ctx context.Context, cfg Config) (*Node, error) { Logger: logger, }) + node.keyshareGC = keysharegc.NewSweeper(keysharegc.Config{ + Keyshares: mgr, + PushCore: cfg.PushCore, + Logger: logger, + }) + return node, nil } @@ -371,6 +379,9 @@ func (n *Node) Start(ctx context.Context) error { // Start expiry sweeper (CONFIRMED past expiry → REVERTED) n.expirySweeper.Start(ctx) + // Start keyshare GC (delete shares superseded by quorum change / key refresh) + n.keyshareGC.Start(ctx) + n.logger.Info(). Str("peer_id", net.ID()). Strs("addrs", net.ListenAddrs()). From dbdffaa0260c26c231698bb598c42542cfb8ea39 Mon Sep 17 00:00:00 2001 From: aman035 Date: Mon, 17 Aug 2026 19:16:55 +0530 Subject: [PATCH 2/7] fix: resolve keyshare pubkeys per share instead of paging full key history (F-2026-18190) --- universalClient/pushcore/pushCore.go | 18 ++++++----- universalClient/tss/keysharegc/sweeper.go | 30 ++++++++----------- .../tss/keysharegc/sweeper_test.go | 30 ++++++++++++++----- 3 files changed, 45 insertions(+), 33 deletions(-) diff --git a/universalClient/pushcore/pushCore.go b/universalClient/pushcore/pushCore.go index 7012557b3..72408f181 100644 --- a/universalClient/pushcore/pushCore.go +++ b/universalClient/pushcore/pushCore.go @@ -211,21 +211,23 @@ func (c *Client) GetCurrentKey(ctx context.Context) (*utsstypes.TssKey, error) { ) } -// GetAllKeys retrieves the TSS key history from Push Chain, oldest first. -func (c *Client) GetAllKeys(ctx context.Context) ([]*utsstypes.TssKey, error) { +// GetKeyByID retrieves a single TSS key from the on-chain key history. +// Returns an error if the key ID is not in the history. +func (c *Client) GetKeyByID(ctx context.Context, keyID string) (*utsstypes.TssKey, error) { return retryWithRoundRobin( len(c.utssClients), &c.rr, - func(idx int) ([]*utsstypes.TssKey, error) { - resp, err := c.utssClients[idx].AllKeys(ctx, &utsstypes.QueryAllKeysRequest{ - Pagination: &query.PageRequest{Limit: 1000}, - }) + func(idx int) (*utsstypes.TssKey, error) { + resp, err := c.utssClients[idx].KeyById(ctx, &utsstypes.QueryKeyByIdRequest{KeyId: keyID}) if err != nil { return nil, err } - return resp.Keys, nil + if resp.Key == nil { + return nil, fmt.Errorf("pushcore: TSS key %s not found", keyID) + } + return resp.Key, nil }, - "GetAllKeys", + "GetKeyByID", c.logger, ) } diff --git a/universalClient/tss/keysharegc/sweeper.go b/universalClient/tss/keysharegc/sweeper.go index f8b188e45..2829f0f6e 100644 --- a/universalClient/tss/keysharegc/sweeper.go +++ b/universalClient/tss/keysharegc/sweeper.go @@ -18,7 +18,7 @@ const defaultCheckInterval = 5 * time.Minute // Defined as an interface so tests can inject a mock. *pushcore.Client satisfies it. type PushCoreClient interface { GetCurrentKey(ctx context.Context) (*utsstypes.TssKey, error) - GetAllKeys(ctx context.Context) ([]*utsstypes.TssKey, error) + GetKeyByID(ctx context.Context, keyID string) (*utsstypes.TssKey, error) GetPendingTssEvents(ctx context.Context) ([]*utsstypes.TssEvent, error) GetPendingFundMigrations(ctx context.Context) ([]*utsstypes.FundMigration, error) } @@ -52,7 +52,9 @@ type Config struct { // indistinguishable from one with nothing to migrate. // // Every chain-state lookup fails closed: on error the sweep is skipped and -// retried next tick rather than deleting on incomplete information. +// retried next tick rather than deleting on incomplete information. Pubkeys are +// resolved per held share rather than from the full key history, which grows +// unbounded and would need paging. type Sweeper struct { keyshares KeyshareStore pushCore PushCoreClient @@ -128,18 +130,6 @@ func (s *Sweeper) sweep(ctx context.Context) { return } - allKeys, err := s.pushCore.GetAllKeys(ctx) - if err != nil { - s.logger.Debug().Err(err).Msg("failed to get TSS key history, skipping sweep") - return - } - pubkeyByID := make(map[string]string, len(allKeys)) - for _, k := range allKeys { - if k != nil { - pubkeyByID[k.KeyId] = k.TssPubkey - } - } - migrations, err := s.pushCore.GetPendingFundMigrations(ctx) if err != nil { s.logger.Debug().Err(err).Msg("failed to get pending fund migrations, skipping sweep") @@ -154,11 +144,17 @@ func (s *Sweeper) sweep(ctx context.Context) { deleted := 0 for _, id := range localIDs { - if id == current.KeyId { + if id == current.KeyId || migrating[id] { + continue + } + // Look up only the shares we hold; the on-chain key history is unbounded. + // Any lookup failure (unknown ID or transport error) keeps the share. + key, err := s.pushCore.GetKeyByID(ctx, id) + if err != nil || key == nil { + s.logger.Debug().Err(err).Str("key_id", id).Msg("cannot resolve keyshare on chain, keeping") continue } - pubkey, known := pubkeyByID[id] - if !known || pubkey != current.TssPubkey || migrating[id] { + if key.TssPubkey != current.TssPubkey { continue } if err := s.keyshares.Delete(id); err != nil { diff --git a/universalClient/tss/keysharegc/sweeper_test.go b/universalClient/tss/keysharegc/sweeper_test.go index fd71e894d..72ddf4d8c 100644 --- a/universalClient/tss/keysharegc/sweeper_test.go +++ b/universalClient/tss/keysharegc/sweeper_test.go @@ -41,7 +41,7 @@ func (m *mockStore) Delete(id string) error { type mockCore struct { current *utsstypes.TssKey - keys []*utsstypes.TssKey + keys map[string]*utsstypes.TssKey pending []*utsstypes.TssEvent migrations []*utsstypes.FundMigration @@ -51,8 +51,15 @@ type mockCore struct { func (m *mockCore) GetCurrentKey(context.Context) (*utsstypes.TssKey, error) { return m.current, m.currentErr } -func (m *mockCore) GetAllKeys(context.Context) ([]*utsstypes.TssKey, error) { - return m.keys, m.keysErr +func (m *mockCore) GetKeyByID(_ context.Context, keyID string) (*utsstypes.TssKey, error) { + if m.keysErr != nil { + return nil, m.keysErr + } + k, ok := m.keys[keyID] + if !ok { + return nil, errors.New("key not found") + } + return k, nil } func (m *mockCore) GetPendingTssEvents(context.Context) ([]*utsstypes.TssEvent, error) { return m.pending, m.pendingErr @@ -70,10 +77,10 @@ func key(id, pubkey string) *utsstypes.TssKey { func baseCore() *mockCore { return &mockCore{ current: key("K2", pubkeyA), - keys: []*utsstypes.TssKey{ - key("K0", pubkeyB), - key("K1", pubkeyA), - key("K2", pubkeyA), + keys: map[string]*utsstypes.TssKey{ + "K0": key("K0", pubkeyB), + "K1": key("K1", pubkeyA), + "K2": key("K2", pubkeyA), }, } } @@ -127,7 +134,7 @@ func TestSweep_KeepsUnknownKeyID(t *testing.T) { func TestSweep_FailsClosedOnRPCError(t *testing.T) { cases := map[string]func(*mockCore){ "current key": func(c *mockCore) { c.currentErr = errors.New("boom") }, - "key history": func(c *mockCore) { c.keysErr = errors.New("boom") }, + "key lookup": func(c *mockCore) { c.keysErr = errors.New("boom") }, "pending events": func(c *mockCore) { c.pendingErr = errors.New("boom") }, "fund migrations": func(c *mockCore) { c.migErr = errors.New("boom") }, } @@ -159,3 +166,10 @@ func TestNewSweeper_DefaultInterval(t *testing.T) { s := NewSweeper(Config{Keyshares: &mockStore{}, PushCore: baseCore(), Logger: zerolog.Nop()}) require.Equal(t, defaultCheckInterval, s.checkInterval) } + +// One unresolvable share must not block collection of the others; only the +// shares we hold are looked up, so the unbounded key history is never paged. +func TestSweep_StrayShareDoesNotBlockOthers(t *testing.T) { + store := sweepWith(t, &mockStore{ids: []string{"stray", "K1", "K2"}}, baseCore()) + assert.Equal(t, []string{"K1"}, store.deleted) +} From 31363167a05a1dad96267464abfc1fde278a25af Mon Sep 17 00:00:00 2001 From: aman035 Date: Mon, 17 Aug 2026 19:24:46 +0530 Subject: [PATCH 3/7] refactor: rename keysharegc to keysharesweeper, sweep hourly (F-2026-18190) --- .../tss/{keysharegc => keysharesweeper}/sweeper.go | 8 +++++--- .../tss/{keysharegc => keysharesweeper}/sweeper_test.go | 2 +- universalClient/tss/tss.go | 8 ++++---- 3 files changed, 10 insertions(+), 8 deletions(-) rename universalClient/tss/{keysharegc => keysharesweeper}/sweeper.go (94%) rename universalClient/tss/{keysharegc => keysharesweeper}/sweeper_test.go (99%) diff --git a/universalClient/tss/keysharegc/sweeper.go b/universalClient/tss/keysharesweeper/sweeper.go similarity index 94% rename from universalClient/tss/keysharegc/sweeper.go rename to universalClient/tss/keysharesweeper/sweeper.go index 2829f0f6e..62867618e 100644 --- a/universalClient/tss/keysharegc/sweeper.go +++ b/universalClient/tss/keysharesweeper/sweeper.go @@ -1,7 +1,7 @@ // Package keysharegc deletes keyshares that a quorum change or key refresh has // made redundant, so a departed or rotated-out share cannot be used to sign for // the live vault key. -package keysharegc +package keysharesweeper import ( "context" @@ -12,7 +12,9 @@ import ( utsstypes "github.com/pushchain/push-chain-node/x/utss/types" ) -const defaultCheckInterval = 5 * time.Minute +// Quorum change and key refresh are rare, and a retained share is only a +// concern over the long run, so sweeping hourly is ample. +const defaultCheckInterval = time.Hour // PushCoreClient is the subset of pushcore.Client the sweeper depends on. // Defined as an interface so tests can inject a mock. *pushcore.Client satisfies it. @@ -72,7 +74,7 @@ func NewSweeper(cfg Config) *Sweeper { keyshares: cfg.Keyshares, pushCore: cfg.PushCore, checkInterval: interval, - logger: cfg.Logger.With().Str("component", "keyshare_gc").Logger(), + logger: cfg.Logger.With().Str("component", "keyshare_sweeper").Logger(), } } diff --git a/universalClient/tss/keysharegc/sweeper_test.go b/universalClient/tss/keysharesweeper/sweeper_test.go similarity index 99% rename from universalClient/tss/keysharegc/sweeper_test.go rename to universalClient/tss/keysharesweeper/sweeper_test.go index 72ddf4d8c..f362915da 100644 --- a/universalClient/tss/keysharegc/sweeper_test.go +++ b/universalClient/tss/keysharesweeper/sweeper_test.go @@ -1,4 +1,4 @@ -package keysharegc +package keysharesweeper import ( "context" diff --git a/universalClient/tss/tss.go b/universalClient/tss/tss.go index 0cc7a726d..c83d8d8de 100644 --- a/universalClient/tss/tss.go +++ b/universalClient/tss/tss.go @@ -22,7 +22,7 @@ import ( "github.com/pushchain/push-chain-node/universalClient/tss/eventstore" "github.com/pushchain/push-chain-node/universalClient/tss/expirysweeper" "github.com/pushchain/push-chain-node/universalClient/tss/keyshare" - "github.com/pushchain/push-chain-node/universalClient/tss/keysharegc" + "github.com/pushchain/push-chain-node/universalClient/tss/keysharesweeper" "github.com/pushchain/push-chain-node/universalClient/tss/networking" libp2pnet "github.com/pushchain/push-chain-node/universalClient/tss/networking/libp2p" "github.com/pushchain/push-chain-node/universalClient/tss/sessionmanager" @@ -106,7 +106,7 @@ type Node struct { txBroadcaster *txbroadcaster.Broadcaster txResolver *txresolver.Resolver expirySweeper *expirysweeper.Sweeper - keyshareGC *keysharegc.Sweeper + keyshareSweeper *keysharesweeper.Sweeper // Network configuration (used during Start) networkCfg libp2pnet.Config @@ -271,7 +271,7 @@ func NewNode(ctx context.Context, cfg Config) (*Node, error) { Logger: logger, }) - node.keyshareGC = keysharegc.NewSweeper(keysharegc.Config{ + node.keyshareSweeper = keysharesweeper.NewSweeper(keysharesweeper.Config{ Keyshares: mgr, PushCore: cfg.PushCore, Logger: logger, @@ -380,7 +380,7 @@ func (n *Node) Start(ctx context.Context) error { n.expirySweeper.Start(ctx) // Start keyshare GC (delete shares superseded by quorum change / key refresh) - n.keyshareGC.Start(ctx) + n.keyshareSweeper.Start(ctx) n.logger.Info(). Str("peer_id", net.ID()). From 81e41a974201fc5380f46127f9ff87a2ad80348c Mon Sep 17 00:00:00 2001 From: aman035 Date: Mon, 17 Aug 2026 19:39:53 +0530 Subject: [PATCH 4/7] refactor: move keyshare sweeper into keyshare package, guard Start with sync.Once (F-2026-18190) --- .../{keysharesweeper => keyshare}/sweeper.go | 14 +++--- .../sweeper_test.go | 50 +++++++++++++++++-- universalClient/tss/tss.go | 5 +- 3 files changed, 57 insertions(+), 12 deletions(-) rename universalClient/tss/{keysharesweeper => keyshare}/sweeper.go (95%) rename universalClient/tss/{keysharesweeper => keyshare}/sweeper_test.go (81%) diff --git a/universalClient/tss/keysharesweeper/sweeper.go b/universalClient/tss/keyshare/sweeper.go similarity index 95% rename from universalClient/tss/keysharesweeper/sweeper.go rename to universalClient/tss/keyshare/sweeper.go index 62867618e..c15c35de9 100644 --- a/universalClient/tss/keysharesweeper/sweeper.go +++ b/universalClient/tss/keyshare/sweeper.go @@ -1,10 +1,8 @@ -// Package keysharegc deletes keyshares that a quorum change or key refresh has -// made redundant, so a departed or rotated-out share cannot be used to sign for -// the live vault key. -package keysharesweeper +package keyshare import ( "context" + "sync" "time" "github.com/rs/zerolog" @@ -62,6 +60,7 @@ type Sweeper struct { pushCore PushCoreClient checkInterval time.Duration logger zerolog.Logger + startOnce sync.Once } // NewSweeper creates a new keyshare sweeper. @@ -78,9 +77,12 @@ func NewSweeper(cfg Config) *Sweeper { } } -// Start begins the background sweep loop. +// Start begins the background sweep loop. Repeat calls are no-ops, so a +// restarted node cannot end up with two sweepers deleting concurrently. func (s *Sweeper) Start(ctx context.Context) { - go s.run(ctx) + s.startOnce.Do(func() { + go s.run(ctx) + }) } func (s *Sweeper) run(ctx context.Context) { diff --git a/universalClient/tss/keysharesweeper/sweeper_test.go b/universalClient/tss/keyshare/sweeper_test.go similarity index 81% rename from universalClient/tss/keysharesweeper/sweeper_test.go rename to universalClient/tss/keyshare/sweeper_test.go index f362915da..f0e4980cc 100644 --- a/universalClient/tss/keysharesweeper/sweeper_test.go +++ b/universalClient/tss/keyshare/sweeper_test.go @@ -1,9 +1,11 @@ -package keysharesweeper +package keyshare import ( "context" "errors" + "sync" "testing" + "time" "github.com/rs/zerolog" "github.com/stretchr/testify/assert" @@ -18,6 +20,7 @@ const ( ) type mockStore struct { + mu sync.Mutex ids []string deleted []string listErr error @@ -25,20 +28,39 @@ type mockStore struct { } func (m *mockStore) List() ([]string, error) { + m.mu.Lock() + defer m.mu.Unlock() if m.listErr != nil { return nil, m.listErr } - return m.ids, nil + return append([]string(nil), m.ids...), nil } +// Delete drops the id so a repeat sweep cannot delete it twice, matching the +// real Manager. func (m *mockStore) Delete(id string) error { + m.mu.Lock() + defer m.mu.Unlock() if m.delErr != nil { return m.delErr } + remaining := m.ids[:0] + for _, existing := range m.ids { + if existing != id { + remaining = append(remaining, existing) + } + } + m.ids = remaining m.deleted = append(m.deleted, id) return nil } +func (m *mockStore) deletedIDs() []string { + m.mu.Lock() + defer m.mu.Unlock() + return append([]string(nil), m.deleted...) +} + type mockCore struct { current *utsstypes.TssKey keys map[string]*utsstypes.TssKey @@ -171,5 +193,27 @@ func TestNewSweeper_DefaultInterval(t *testing.T) { // shares we hold are looked up, so the unbounded key history is never paged. func TestSweep_StrayShareDoesNotBlockOthers(t *testing.T) { store := sweepWith(t, &mockStore{ids: []string{"stray", "K1", "K2"}}, baseCore()) - assert.Equal(t, []string{"K1"}, store.deleted) + assert.Equal(t, []string{"K1"}, store.deletedIDs()) +} + +// Start must be idempotent: a second call cannot spawn a concurrent sweeper. +func TestSweeper_StartIsIdempotent(t *testing.T) { + store := &mockStore{ids: []string{"K1", "K2"}} + s := NewSweeper(Config{ + Keyshares: store, + PushCore: baseCore(), + CheckInterval: 10 * time.Millisecond, + Logger: zerolog.Nop(), + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + for range 5 { + s.Start(ctx) + } + + // One loop deletes K1 exactly once; duplicates would retry the deleted id. + time.Sleep(60 * time.Millisecond) + cancel() + assert.Equal(t, []string{"K1"}, store.deletedIDs()) } diff --git a/universalClient/tss/tss.go b/universalClient/tss/tss.go index c83d8d8de..215f5dfb3 100644 --- a/universalClient/tss/tss.go +++ b/universalClient/tss/tss.go @@ -22,7 +22,6 @@ import ( "github.com/pushchain/push-chain-node/universalClient/tss/eventstore" "github.com/pushchain/push-chain-node/universalClient/tss/expirysweeper" "github.com/pushchain/push-chain-node/universalClient/tss/keyshare" - "github.com/pushchain/push-chain-node/universalClient/tss/keysharesweeper" "github.com/pushchain/push-chain-node/universalClient/tss/networking" libp2pnet "github.com/pushchain/push-chain-node/universalClient/tss/networking/libp2p" "github.com/pushchain/push-chain-node/universalClient/tss/sessionmanager" @@ -106,7 +105,7 @@ type Node struct { txBroadcaster *txbroadcaster.Broadcaster txResolver *txresolver.Resolver expirySweeper *expirysweeper.Sweeper - keyshareSweeper *keysharesweeper.Sweeper + keyshareSweeper *keyshare.Sweeper // Network configuration (used during Start) networkCfg libp2pnet.Config @@ -271,7 +270,7 @@ func NewNode(ctx context.Context, cfg Config) (*Node, error) { Logger: logger, }) - node.keyshareSweeper = keysharesweeper.NewSweeper(keysharesweeper.Config{ + node.keyshareSweeper = keyshare.NewSweeper(keyshare.Config{ Keyshares: mgr, PushCore: cfg.PushCore, Logger: logger, From 6a87cf082b48d2aa146fcf58a44145f2a021ade2 Mon Sep 17 00:00:00 2001 From: aman035 Date: Mon, 17 Aug 2026 19:43:30 +0530 Subject: [PATCH 5/7] refactor: sweep keyshares daily and run once at start (F-2026-18190) --- universalClient/tss/keyshare/sweeper.go | 8 ++++++-- universalClient/tss/keyshare/sweeper_test.go | 21 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/universalClient/tss/keyshare/sweeper.go b/universalClient/tss/keyshare/sweeper.go index c15c35de9..68e5601e9 100644 --- a/universalClient/tss/keyshare/sweeper.go +++ b/universalClient/tss/keyshare/sweeper.go @@ -11,8 +11,8 @@ import ( ) // Quorum change and key refresh are rare, and a retained share is only a -// concern over the long run, so sweeping hourly is ample. -const defaultCheckInterval = time.Hour +// concern over the long run, so sweeping daily is ample. +const defaultCheckInterval = 24 * time.Hour // PushCoreClient is the subset of pushcore.Client the sweeper depends on. // Defined as an interface so tests can inject a mock. *pushcore.Client satisfies it. @@ -89,6 +89,10 @@ func (s *Sweeper) run(ctx context.Context) { ticker := time.NewTicker(s.checkInterval) defer ticker.Stop() + // Sweep on start: with a long interval, a node restarted more often than + // that would otherwise never sweep. + s.sweep(ctx) + for { select { case <-ctx.Done(): diff --git a/universalClient/tss/keyshare/sweeper_test.go b/universalClient/tss/keyshare/sweeper_test.go index f0e4980cc..0da2cd814 100644 --- a/universalClient/tss/keyshare/sweeper_test.go +++ b/universalClient/tss/keyshare/sweeper_test.go @@ -217,3 +217,24 @@ func TestSweeper_StartIsIdempotent(t *testing.T) { cancel() assert.Equal(t, []string{"K1"}, store.deletedIDs()) } + +// The interval is long, so the first sweep must happen at start rather than +// after a full period — otherwise a frequently restarted node never sweeps. +func TestSweeper_SweepsOnStart(t *testing.T) { + store := &mockStore{ids: []string{"K1", "K2"}} + s := NewSweeper(Config{ + Keyshares: store, + PushCore: baseCore(), + CheckInterval: time.Hour, // far longer than the test waits + Logger: zerolog.Nop(), + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s.Start(ctx) + + assert.Eventually(t, func() bool { + return len(store.deletedIDs()) == 1 + }, 2*time.Second, 10*time.Millisecond, "expected a sweep at start") + assert.Equal(t, []string{"K1"}, store.deletedIDs()) +} From 618b5fe80fdb429393b08578b4fb686c283225f3 Mon Sep 17 00:00:00 2001 From: aman035 Date: Mon, 17 Aug 2026 23:49:57 +0530 Subject: [PATCH 6/7] test: cover GetKeyByID and guard nil query response (F-2026-18190) --- universalClient/pushcore/pushCore.go | 2 +- universalClient/pushcore/pushCore_test.go | 75 +++++++++++++++++++++-- 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/universalClient/pushcore/pushCore.go b/universalClient/pushcore/pushCore.go index 72408f181..ccf5aca04 100644 --- a/universalClient/pushcore/pushCore.go +++ b/universalClient/pushcore/pushCore.go @@ -222,7 +222,7 @@ func (c *Client) GetKeyByID(ctx context.Context, keyID string) (*utsstypes.TssKe if err != nil { return nil, err } - if resp.Key == nil { + if resp == nil || resp.Key == nil { return nil, fmt.Errorf("pushcore: TSS key %s not found", keyID) } return resp.Key, nil diff --git a/universalClient/pushcore/pushCore_test.go b/universalClient/pushcore/pushCore_test.go index 323372b4e..e7b88e045 100644 --- a/universalClient/pushcore/pushCore_test.go +++ b/universalClient/pushcore/pushCore_test.go @@ -2,6 +2,7 @@ package pushcore import ( "context" + "errors" "math/big" "testing" @@ -965,10 +966,11 @@ func (m *mockUValidatorQueryClient) UniversalValidator(ctx context.Context, req type mockUTSSQueryClient struct { utsstypes.QueryClient - currentKeyResp *utsstypes.QueryCurrentKeyResponse - pendingTssEventsResp *utsstypes.QueryAllPendingTssEventsResponse - pendingFundMigrationsResp *utsstypes.QueryPendingFundMigrationsResponse - err error + currentKeyResp *utsstypes.QueryCurrentKeyResponse + keyByIdResp *utsstypes.QueryKeyByIdResponse + pendingTssEventsResp *utsstypes.QueryAllPendingTssEventsResponse + pendingFundMigrationsResp *utsstypes.QueryPendingFundMigrationsResponse + err error } func (m *mockUTSSQueryClient) CurrentKey(ctx context.Context, req *utsstypes.QueryCurrentKeyRequest, opts ...grpc.CallOption) (*utsstypes.QueryCurrentKeyResponse, error) { @@ -993,7 +995,10 @@ func (m *mockUTSSQueryClient) PendingFundMigrations(ctx context.Context, req *ut } func (m *mockUTSSQueryClient) KeyById(ctx context.Context, req *utsstypes.QueryKeyByIdRequest, opts ...grpc.CallOption) (*utsstypes.QueryKeyByIdResponse, error) { - return nil, nil + if m.err != nil { + return nil, m.err + } + return m.keyByIdResp, nil } type mockTxServiceClient struct { @@ -1084,3 +1089,63 @@ func (m *mockAuthAccountQueryClient) Account(ctx context.Context, req *authtypes } return m.accountResp, nil } + +func TestClient_GetKeyByID(t *testing.T) { + logger := zerolog.Nop() + + t.Run("no endpoints configured", func(t *testing.T) { + client := &Client{logger: logger, utssClients: []utsstypes.QueryClient{}} + + key, err := client.GetKeyByID(context.Background(), "key-123") + require.Error(t, err) + assert.Contains(t, err.Error(), "no endpoints configured") + assert.Nil(t, key) + }) + + t.Run("successful query returns key", func(t *testing.T) { + mockClient := &mockUTSSQueryClient{ + keyByIdResp: &utsstypes.QueryKeyByIdResponse{ + Key: &utsstypes.TssKey{KeyId: "key-123", TssPubkey: "0xpub"}, + }, + } + client := &Client{logger: logger, utssClients: []utsstypes.QueryClient{mockClient}} + + key, err := client.GetKeyByID(context.Background(), "key-123") + require.NoError(t, err) + require.NotNil(t, key) + assert.Equal(t, "key-123", key.KeyId) + assert.Equal(t, "0xpub", key.TssPubkey) + }) + + t.Run("unknown key id errors", func(t *testing.T) { + mockClient := &mockUTSSQueryClient{ + keyByIdResp: &utsstypes.QueryKeyByIdResponse{Key: nil}, + } + client := &Client{logger: logger, utssClients: []utsstypes.QueryClient{mockClient}} + + key, err := client.GetKeyByID(context.Background(), "missing") + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") + assert.Nil(t, key) + }) + + // A nil response with a nil error must not panic. + t.Run("nil response errors", func(t *testing.T) { + mockClient := &mockUTSSQueryClient{keyByIdResp: nil} + client := &Client{logger: logger, utssClients: []utsstypes.QueryClient{mockClient}} + + key, err := client.GetKeyByID(context.Background(), "key-123") + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") + assert.Nil(t, key) + }) + + t.Run("query error propagates", func(t *testing.T) { + mockClient := &mockUTSSQueryClient{err: errors.New("rpc down")} + client := &Client{logger: logger, utssClients: []utsstypes.QueryClient{mockClient}} + + key, err := client.GetKeyByID(context.Background(), "key-123") + require.Error(t, err) + assert.Nil(t, key) + }) +} From 36acc6851095d6a20209f6bed114692dca965afa Mon Sep 17 00:00:00 2001 From: aman035 Date: Mon, 17 Aug 2026 23:51:51 +0530 Subject: [PATCH 7/7] refactor: drop redundant pending-process and fund-migration guards from keyshare sweeper (F-2026-18190) --- universalClient/tss/keyshare/sweeper.go | 37 ++++---------------- universalClient/tss/keyshare/sweeper_test.go | 37 +++----------------- 2 files changed, 12 insertions(+), 62 deletions(-) diff --git a/universalClient/tss/keyshare/sweeper.go b/universalClient/tss/keyshare/sweeper.go index 68e5601e9..06e4c2e91 100644 --- a/universalClient/tss/keyshare/sweeper.go +++ b/universalClient/tss/keyshare/sweeper.go @@ -19,8 +19,6 @@ const defaultCheckInterval = 24 * time.Hour type PushCoreClient interface { GetCurrentKey(ctx context.Context) (*utsstypes.TssKey, error) GetKeyByID(ctx context.Context, keyID string) (*utsstypes.TssKey, error) - GetPendingTssEvents(ctx context.Context) ([]*utsstypes.TssEvent, error) - GetPendingFundMigrations(ctx context.Context) ([]*utsstypes.FundMigration, error) } // KeyshareStore is the subset of keyshare.Manager the sweeper depends on. @@ -42,9 +40,12 @@ type Config struct { // A keyshare is deleted only when every one of these holds: // - it is not the current key ID; // - its TSS pubkey equals the current key's pubkey, i.e. a quorum change or -// key refresh superseded it while preserving the vault key; -// - no TSS process is pending, so no in-flight session can still load it; -// - no pending fund migration references it. +// key refresh superseded it while preserving the vault key. +// +// Those two conditions are sufficient. The current key only changes when a key +// process finalizes, so while one is in flight the predecessor is still current +// and therefore never a deletion candidate. Fund migrations only exist across a +// pubkey rotation, so they can only reference a key this sweeper already keeps. // // Shares whose pubkey differs from the current one are kept: they belong to a // rotated-away key that fund migration still needs to sweep its vault. Retiring @@ -126,33 +127,9 @@ func (s *Sweeper) sweep(ctx context.Context) { return } - // An in-flight session may still load a predecessor share; a missing share - // is silently reinterpreted as "new party" during quorum change, so wait. - pendingProcesses, err := s.pushCore.GetPendingTssEvents(ctx) - if err != nil { - s.logger.Debug().Err(err).Msg("failed to get pending TSS events, skipping sweep") - return - } - if len(pendingProcesses) > 0 { - s.logger.Debug().Int("pending", len(pendingProcesses)).Msg("TSS process pending, skipping sweep") - return - } - - migrations, err := s.pushCore.GetPendingFundMigrations(ctx) - if err != nil { - s.logger.Debug().Err(err).Msg("failed to get pending fund migrations, skipping sweep") - return - } - migrating := make(map[string]bool, len(migrations)) - for _, m := range migrations { - if m != nil { - migrating[m.OldKeyId] = true - } - } - deleted := 0 for _, id := range localIDs { - if id == current.KeyId || migrating[id] { + if id == current.KeyId { continue } // Look up only the shares we hold; the on-chain key history is unbounded. diff --git a/universalClient/tss/keyshare/sweeper_test.go b/universalClient/tss/keyshare/sweeper_test.go index 0da2cd814..d537812dc 100644 --- a/universalClient/tss/keyshare/sweeper_test.go +++ b/universalClient/tss/keyshare/sweeper_test.go @@ -62,12 +62,10 @@ func (m *mockStore) deletedIDs() []string { } type mockCore struct { - current *utsstypes.TssKey - keys map[string]*utsstypes.TssKey - pending []*utsstypes.TssEvent - migrations []*utsstypes.FundMigration + current *utsstypes.TssKey + keys map[string]*utsstypes.TssKey - currentErr, keysErr, pendingErr, migErr error + currentErr, keysErr error } func (m *mockCore) GetCurrentKey(context.Context) (*utsstypes.TssKey, error) { @@ -83,13 +81,6 @@ func (m *mockCore) GetKeyByID(_ context.Context, keyID string) (*utsstypes.TssKe } return k, nil } -func (m *mockCore) GetPendingTssEvents(context.Context) ([]*utsstypes.TssEvent, error) { - return m.pending, m.pendingErr -} -func (m *mockCore) GetPendingFundMigrations(context.Context) ([]*utsstypes.FundMigration, error) { - return m.migrations, m.migErr -} - func key(id, pubkey string) *utsstypes.TssKey { return &utsstypes.TssKey{KeyId: id, TssPubkey: pubkey} } @@ -131,22 +122,6 @@ func TestSweep_KeepsRotatedAwayPubkeyShare(t *testing.T) { assert.Equal(t, []string{"K1"}, store.deleted) } -func TestSweep_KeepsShareReferencedByPendingFundMigration(t *testing.T) { - core := baseCore() - core.migrations = []*utsstypes.FundMigration{{OldKeyId: "K1"}} - store := sweepWith(t, &mockStore{ids: []string{"K1", "K2"}}, core) - assert.Empty(t, store.deleted) -} - -// A pending TSS process means an in-flight session may still load a predecessor -// share; a missing share is silently treated as "new party" during quorum change. -func TestSweep_SkipsWhileTssProcessPending(t *testing.T) { - core := baseCore() - core.pending = []*utsstypes.TssEvent{{}} - store := sweepWith(t, &mockStore{ids: []string{"K1", "K2"}}, core) - assert.Empty(t, store.deleted) -} - // A share the chain doesn't know about is never deleted. func TestSweep_KeepsUnknownKeyID(t *testing.T) { store := sweepWith(t, &mockStore{ids: []string{"mystery", "K2"}}, baseCore()) @@ -155,10 +130,8 @@ func TestSweep_KeepsUnknownKeyID(t *testing.T) { func TestSweep_FailsClosedOnRPCError(t *testing.T) { cases := map[string]func(*mockCore){ - "current key": func(c *mockCore) { c.currentErr = errors.New("boom") }, - "key lookup": func(c *mockCore) { c.keysErr = errors.New("boom") }, - "pending events": func(c *mockCore) { c.pendingErr = errors.New("boom") }, - "fund migrations": func(c *mockCore) { c.migErr = errors.New("boom") }, + "current key": func(c *mockCore) { c.currentErr = errors.New("boom") }, + "key lookup": func(c *mockCore) { c.keysErr = errors.New("boom") }, } for name, breakIt := range cases { t.Run(name, func(t *testing.T) {