diff --git a/universalClient/pushcore/pushCore.go b/universalClient/pushcore/pushCore.go index 647b548ea..ccf5aca04 100644 --- a/universalClient/pushcore/pushCore.go +++ b/universalClient/pushcore/pushCore.go @@ -211,6 +211,27 @@ func (c *Client) GetCurrentKey(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].KeyById(ctx, &utsstypes.QueryKeyByIdRequest{KeyId: keyID}) + if err != nil { + return nil, err + } + if resp == nil || resp.Key == nil { + return nil, fmt.Errorf("pushcore: TSS key %s not found", keyID) + } + return resp.Key, nil + }, + "GetKeyByID", + 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/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) + }) +} 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/keyshare/sweeper.go b/universalClient/tss/keyshare/sweeper.go new file mode 100644 index 000000000..06e4c2e91 --- /dev/null +++ b/universalClient/tss/keyshare/sweeper.go @@ -0,0 +1,157 @@ +package keyshare + +import ( + "context" + "sync" + "time" + + "github.com/rs/zerolog" + + utsstypes "github.com/pushchain/push-chain-node/x/utss/types" +) + +// Quorum change and key refresh are rare, and a retained share is only a +// 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. +type PushCoreClient interface { + GetCurrentKey(ctx context.Context) (*utsstypes.TssKey, error) + GetKeyByID(ctx context.Context, keyID string) (*utsstypes.TssKey, 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. +// +// 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 +// 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. 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 + checkInterval time.Duration + logger zerolog.Logger + startOnce sync.Once +} + +// 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_sweeper").Logger(), + } +} + +// 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) { + s.startOnce.Do(func() { + go s.run(ctx) + }) +} + +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(): + 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 + } + + deleted := 0 + for _, id := range localIDs { + if id == current.KeyId { + 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 + } + if key.TssPubkey != current.TssPubkey { + 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/keyshare/sweeper_test.go b/universalClient/tss/keyshare/sweeper_test.go new file mode 100644 index 000000000..d537812dc --- /dev/null +++ b/universalClient/tss/keyshare/sweeper_test.go @@ -0,0 +1,213 @@ +package keyshare + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "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 { + mu sync.Mutex + ids []string + deleted []string + listErr error + delErr error +} + +func (m *mockStore) List() ([]string, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.listErr != nil { + return nil, m.listErr + } + 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 + + currentErr, keysErr error +} + +func (m *mockCore) GetCurrentKey(context.Context) (*utsstypes.TssKey, error) { + return m.current, m.currentErr +} +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 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: map[string]*utsstypes.TssKey{ + "K0": key("K0", pubkeyB), + "K1": key("K1", pubkeyA), + "K2": 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) +} + +// 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 lookup": func(c *mockCore) { c.keysErr = 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) +} + +// 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.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()) +} + +// 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()) +} diff --git a/universalClient/tss/tss.go b/universalClient/tss/tss.go index f54ca0650..215f5dfb3 100644 --- a/universalClient/tss/tss.go +++ b/universalClient/tss/tss.go @@ -105,6 +105,7 @@ type Node struct { txBroadcaster *txbroadcaster.Broadcaster txResolver *txresolver.Resolver expirySweeper *expirysweeper.Sweeper + keyshareSweeper *keyshare.Sweeper // Network configuration (used during Start) networkCfg libp2pnet.Config @@ -269,6 +270,12 @@ func NewNode(ctx context.Context, cfg Config) (*Node, error) { Logger: logger, }) + node.keyshareSweeper = keyshare.NewSweeper(keyshare.Config{ + Keyshares: mgr, + PushCore: cfg.PushCore, + Logger: logger, + }) + return node, nil } @@ -371,6 +378,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.keyshareSweeper.Start(ctx) + n.logger.Info(). Str("peer_id", net.ID()). Strs("addrs", net.ListenAddrs()).