diff --git a/service/dkg_finalize.go b/service/dkg_finalize.go index 8275fae..a0eb483 100644 --- a/service/dkg_finalize.go +++ b/service/dkg_finalize.go @@ -14,6 +14,7 @@ import ( log "github.com/sirupsen/logrus" "github.com/piplabs/story-kernel/enclave" + "github.com/piplabs/story-kernel/story" pb "github.com/piplabs/story-kernel/types/pb/v0" dkg "go.dedis.ch/kyber/v4/share/dkg/pedersen" @@ -29,7 +30,7 @@ const ( finalizeStageRetryDelay = 2 * time.Second ) -func (s *DKGServer) FinalizeDKG(_ context.Context, req *pb.FinalizeDKGRequest) (*pb.FinalizeDKGResponse, error) { +func (s *DKGServer) FinalizeDKG(ctx context.Context, req *pb.FinalizeDKGRequest) (*pb.FinalizeDKGResponse, error) { codeCommitmentHex := hex.EncodeToString(req.GetCodeCommitment()) // Validate request @@ -87,8 +88,17 @@ func (s *DKGServer) FinalizeDKG(_ context.Context, req *pb.FinalizeDKGRequest) ( // Snapshotting the share only after this wait also lets more of the response feed drain, // so DistKeyShare() interpolates over a fuller QUAL; that same share is sealed and later // used for dealing (loadFromRoundShare), keeping deals and on-chain coeffs consistent. - registrations, err := s.waitForFinalizationRegistrations(codeCommitmentHex, req.GetRound()) + registrations, err := s.waitForFinalizationRegistrations(ctx, codeCommitmentHex, req.GetRound()) if err != nil { + // A canceled request is the caller going away, not a server failure. + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + log.WithFields(log.Fields{ + "round": req.GetRound(), + }).Warnf("FinalizeDKG aborted while waiting for the finalization stage: %v", err) + + return nil, status.FromContextError(err).Err() + } + log.Errorf("failed to get finalization DKG registrations: %v", err) return nil, status.Errorf(codes.Internal, "failed to get finalization DKG registrations") @@ -228,26 +238,38 @@ func validateFinalizeDKGRequest(req *pb.FinalizeDKGRequest) error { // height. Because missing-dealer invalidation happens in the same block that advances the // stage to finalization, waiting for that stage guarantees the returned set excludes any // invalidated dealer, so the kernel's participants root matches the chain's. It retries -// while the light client still reports an earlier stage, returning ErrLightClientLag once -// the retry budget is exhausted. -func (s *DKGServer) waitForFinalizationRegistrations(codeCommitmentHex string, round uint32) ([]*pb.DKGRegistration, error) { +// while the light client still reports an earlier stage or has not observed the round at +// all, returning ErrLightClientLag once the retry budget is exhausted, or ctx.Err() if the +// caller goes away mid-wait. +func (s *DKGServer) waitForFinalizationRegistrations(ctx context.Context, codeCommitmentHex string, round uint32) ([]*pb.DKGRegistration, error) { for attempt := range finalizeStageRetryAttempts { - network, err := s.QueryClient.GetDKGNetwork(context.Background(), codeCommitmentHex, round) - if err != nil { + network, err := s.QueryClient.GetDKGNetwork(ctx, codeCommitmentHex, round) + if err != nil && !errors.Is(err, story.ErrDKGNetworkNotFound) { return nil, err } - if network.GetStage() >= pb.DKGStage_DKG_STAGE_FINALIZATION { - return s.QueryClient.GetAllParticipantDKGRegistrations(context.Background(), codeCommitmentHex, round) + if err == nil && network.GetStage() >= pb.DKGStage_DKG_STAGE_FINALIZATION { + return s.QueryClient.GetAllParticipantDKGRegistrations(ctx, codeCommitmentHex, round) + } + + // A not-found read is the same lag shape as an earlier stage: the light client + // has not verified up to the round's start block yet, so keep retrying. + stage := "not visible" + if err == nil { + stage = network.GetStage().String() } log.WithFields(log.Fields{ "round": round, - "stage": network.GetStage().String(), + "stage": stage, "attempt": attempt + 1, }).Warn("FinalizeDKG: light client has not observed the finalization stage yet, retrying") - time.Sleep(finalizeStageRetryDelay) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(finalizeStageRetryDelay): + } } return nil, fmt.Errorf("%w: round %d did not reach finalization stage after %d retries", diff --git a/service/dkg_finalize_wait_test.go b/service/dkg_finalize_wait_test.go index 8f1442a..06fa958 100644 --- a/service/dkg_finalize_wait_test.go +++ b/service/dkg_finalize_wait_test.go @@ -1,6 +1,7 @@ package service import ( + "context" "errors" "testing" @@ -32,7 +33,7 @@ func TestWaitForFinalizationRegistrations_WaitsForStage(t *testing.T) { s := &DKGServer{QueryClient: stub} - regs, err := s.waitForFinalizationRegistrations("cc", 42) + regs, err := s.waitForFinalizationRegistrations(t.Context(), "cc", 42) require.NoError(t, err) require.Equal(t, filteredRegs(), regs) require.Equal(t, int32(2), stub.netCalls.Load(), "polls stage until finalization (1 lag + 1 caught up)") @@ -49,7 +50,7 @@ func TestWaitForFinalizationRegistrations_Immediate(t *testing.T) { s := &DKGServer{QueryClient: stub} - regs, err := s.waitForFinalizationRegistrations("cc", 42) + regs, err := s.waitForFinalizationRegistrations(t.Context(), "cc", 42) require.NoError(t, err) require.Equal(t, filteredRegs(), regs) require.Equal(t, int32(1), stub.netCalls.Load()) @@ -66,7 +67,7 @@ func TestWaitForFinalizationRegistrations_RetryExhausted(t *testing.T) { s := &DKGServer{QueryClient: stub} - regs, err := s.waitForFinalizationRegistrations("cc", 42) + regs, err := s.waitForFinalizationRegistrations(t.Context(), "cc", 42) require.Error(t, err) require.Nil(t, regs) require.True(t, errors.Is(err, ErrLightClientLag), "exhaustion must preserve ErrLightClientLag") @@ -74,6 +75,25 @@ func TestWaitForFinalizationRegistrations_RetryExhausted(t *testing.T) { require.Equal(t, int32(0), stub.regCalls.Load(), "must never read the participant set while lagging") } +// TestWaitForFinalizationRegistrations_NotFoundThenStage verifies a not-found read (light +// client has not observed the round at all) is retried like an earlier stage instead of +// failing the call. +func TestWaitForFinalizationRegistrations_NotFoundThenStage(t *testing.T) { + stub := &stubQueryClient{ + netErrs: []error{notFoundErr()}, // call 1: round not visible yet + networks: []*pb.DKGNetwork{{Round: 42, Stage: pb.DKGStage_DKG_STAGE_FINALIZATION}}, + registrations: [][]*pb.DKGRegistration{filteredRegs()}, + } + + s := &DKGServer{QueryClient: stub} + + regs, err := s.waitForFinalizationRegistrations(t.Context(), "cc", 42) + require.NoError(t, err) + require.Equal(t, filteredRegs(), regs) + require.Equal(t, int32(2), stub.netCalls.Load(), "polls through the not-found read (1 lag + 1 caught up)") + require.Equal(t, int32(1), stub.regCalls.Load()) +} + // TestWaitForFinalizationRegistrations_NetworkError verifies a network query error is returned // directly without retrying. func TestWaitForFinalizationRegistrations_NetworkError(t *testing.T) { @@ -82,8 +102,27 @@ func TestWaitForFinalizationRegistrations_NetworkError(t *testing.T) { s := &DKGServer{QueryClient: stub} - regs, err := s.waitForFinalizationRegistrations("cc", 42) + regs, err := s.waitForFinalizationRegistrations(t.Context(), "cc", 42) require.ErrorIs(t, err, wantErr) require.Nil(t, regs) require.Equal(t, int32(1), stub.netCalls.Load(), "network error must not be retried") } + +// TestWaitForFinalizationRegistrations_ContextCanceled verifies a caller going away +// mid-wait stops the retry loop promptly instead of burning the remaining budget. +func TestWaitForFinalizationRegistrations_ContextCanceled(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + stub := &stubQueryClient{ + networks: []*pb.DKGNetwork{{Round: 42, Stage: pb.DKGStage_DKG_STAGE_DEALING}}, + } + + s := &DKGServer{QueryClient: stub} + + regs, err := s.waitForFinalizationRegistrations(ctx, "cc", 42) + require.ErrorIs(t, err, context.Canceled) + require.False(t, errors.Is(err, ErrLightClientLag)) + require.Nil(t, regs) + require.Equal(t, int32(1), stub.netCalls.Load(), "cancellation must stop retries promptly") +} diff --git a/service/dkg_generate_key.go b/service/dkg_generate_key.go index fd03295..a1c9b7b 100644 --- a/service/dkg_generate_key.go +++ b/service/dkg_generate_key.go @@ -3,19 +3,36 @@ package service import ( "context" "encoding/hex" + "fmt" + "time" ecrypto "github.com/ethereum/go-ethereum/crypto" "github.com/pkg/errors" log "github.com/sirupsen/logrus" "github.com/piplabs/story-kernel/enclave" + "github.com/piplabs/story-kernel/story" pb "github.com/piplabs/story-kernel/types/pb/v0" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) -func (s *DKGServer) GenerateAndSealKey(_ context.Context, req *pb.GenerateAndSealKeyRequest) (*pb.GenerateAndSealKeyResponse, error) { +const ( + // registrationNetworkRetryAttempts/Delay bound how long GenerateAndSealKey waits for + // the light client to observe the round's DKGNetwork at a round boundary. The verified + // head has been observed to trail the chain tip by ~40s when a round starts, so the + // budget (30 x 3s = 90s) outlasts that with margin. + registrationNetworkRetryAttempts = 30 + registrationNetworkRetryDelay = 3 * time.Second + + // registrationLagNormalAttempts is how many retry attempts fall within the ~40s lag + // documented above. Within it a not-found read is expected steady-state behavior and + // logs at Debug; beyond it the wait is abnormal and escalates to Warn. + registrationLagNormalAttempts = 15 +) + +func (s *DKGServer) GenerateAndSealKey(ctx context.Context, req *pb.GenerateAndSealKeyRequest) (*pb.GenerateAndSealKeyResponse, error) { codeCommitmentHex := hex.EncodeToString(req.GetCodeCommitment()) // Validate the request @@ -61,19 +78,44 @@ func (s *DKGServer) GenerateAndSealKey(_ context.Context, req *pb.GenerateAndSea // Only fetch the DKG network (not registrations) since no registrations // exist yet at key generation time. - network, err := s.QueryClient.GetDKGNetwork(context.Background(), codeCommitmentHex, req.Round) + network, err := s.waitForDKGNetworkCreation(ctx, codeCommitmentHex, req.Round, + registrationNetworkRetryAttempts, registrationNetworkRetryDelay) if err != nil { + // A canceled request is the caller going away, not a server failure. + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + log.WithFields(log.Fields{ + "round": req.Round, + "code_commitment": codeCommitmentHex, + }).Warnf("GenerateAndSealKey aborted while waiting for DKG network: %v", err) + + return nil, status.FromContextError(err).Err() + } + log.WithFields(log.Fields{ "round": req.Round, "code_commitment": codeCommitmentHex, }).Errorf("failed to get DKG network: %v", err) + if errors.Is(err, ErrLightClientLag) { + return nil, status.Errorf(codes.Unavailable, "DKG network not yet visible to the light client") + } + return nil, status.Errorf(codes.Internal, "failed to get DKG network") } // Verify the DKG start block is on the canonical chain. // This ensures the DKG round was legitimately initiated on-chain before generating keys. - if err := s.verifyDKGStartBlock(context.Background(), network); err != nil { + if err := s.verifyDKGStartBlock(ctx, network); err != nil { + // A canceled request is the caller going away, not a verification failure. + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + log.WithFields(log.Fields{ + "round": req.Round, + "code_commitment": codeCommitmentHex, + }).Warnf("GenerateAndSealKey aborted during start block verification: %v", err) + + return nil, status.FromContextError(err).Err() + } + log.WithFields(log.Fields{ "round": req.Round, "code_commitment": codeCommitmentHex, @@ -127,6 +169,52 @@ func (s *DKGServer) GenerateAndSealKey(_ context.Context, req *pb.GenerateAndSea }, nil } +// waitForDKGNetworkCreation blocks until the round's on-chain DKGNetwork record becomes +// visible at the light client's verified height, then returns it. The consensus client only +// requests key generation for a round it observed on-chain, so a not-found read here is +// presumed to be light-client lag rather than a nonexistent round; any other error fails +// fast. Returns ErrLightClientLag once the retry budget is exhausted, or ctx.Err() if the +// caller goes away mid-wait. +func (s *DKGServer) waitForDKGNetworkCreation( + ctx context.Context, + codeCommitmentHex string, + round uint32, + attempts int, + delay time.Duration, +) (*pb.DKGNetwork, error) { + for attempt := range attempts { + network, err := s.QueryClient.GetDKGNetwork(ctx, codeCommitmentHex, round) + if err == nil { + return network, nil + } + + if !errors.Is(err, story.ErrDKGNetworkNotFound) { + return nil, err + } + + if attempt+1 == attempts { + break + } + + fields := log.Fields{"round": round, "attempt": attempt + 1} + msg := "GenerateAndSealKey: DKG network not yet visible to light client, retrying" + if attempt+1 > registrationLagNormalAttempts { + log.WithFields(fields).Warn(msg) + } else { + log.WithFields(fields).Debug(msg) + } + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(delay): + } + } + + return nil, fmt.Errorf("%w: round %d DKG network not visible after %d attempts", + ErrLightClientLag, round, attempts) +} + func validateGenerateAndSealKeyRequest(req *pb.GenerateAndSealKeyRequest) error { if len(req.GetAddress()) == 0 { return errors.New("validator address is required but missing") diff --git a/service/dkg_generate_key_test.go b/service/dkg_generate_key_test.go new file mode 100644 index 0000000..a32276c --- /dev/null +++ b/service/dkg_generate_key_test.go @@ -0,0 +1,100 @@ +package service + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/piplabs/story-kernel/story" + pb "github.com/piplabs/story-kernel/types/pb/v0" +) + +// notFoundErr mimics the error GetDKGNetwork returns when the round's record is absent at +// the light client's verified height. +func notFoundErr() error { + return fmt.Errorf("%w for code_commitment cc, round 42", story.ErrDKGNetworkNotFound) +} + +// TestWaitForDKGNetworkCreation_Immediate verifies no retry happens when the round's network is +// already visible at the light client's verified height. +func TestWaitForDKGNetworkCreation_Immediate(t *testing.T) { + stub := &stubQueryClient{networks: []*pb.DKGNetwork{{Round: 42}}} + s := &DKGServer{QueryClient: stub} + + network, err := s.waitForDKGNetworkCreation(t.Context(), "cc", 42, 3, time.Millisecond) + require.NoError(t, err) + require.Equal(t, uint32(42), network.GetRound()) + require.Equal(t, int32(1), stub.netCalls.Load()) +} + +// TestWaitForDKGNetworkCreation_LagThenSuccess verifies the registration path outlasts a transient +// light-client lag: not-found reads are retried until the round becomes visible. +func TestWaitForDKGNetworkCreation_LagThenSuccess(t *testing.T) { + stub := &stubQueryClient{ + netErrs: []error{notFoundErr(), notFoundErr()}, // calls 1-2: light client still lagging + networks: []*pb.DKGNetwork{{Round: 42}}, // call 3: caught up + } + s := &DKGServer{QueryClient: stub} + + network, err := s.waitForDKGNetworkCreation(t.Context(), "cc", 42, 5, time.Millisecond) + require.NoError(t, err) + require.Equal(t, uint32(42), network.GetRound()) + require.Equal(t, int32(3), stub.netCalls.Load(), "polls until the round is visible (2 lag + 1 caught up)") +} + +// TestWaitForDKGNetworkCreation_RetryExhausted verifies that if the round never becomes visible, +// the call fails with ErrLightClientLag after the full retry budget. +func TestWaitForDKGNetworkCreation_RetryExhausted(t *testing.T) { + stub := &stubQueryClient{netErr: notFoundErr()} + s := &DKGServer{QueryClient: stub} + + network, err := s.waitForDKGNetworkCreation(t.Context(), "cc", 42, 4, time.Millisecond) + require.Error(t, err) + require.Nil(t, network) + require.True(t, errors.Is(err, ErrLightClientLag), "exhaustion must preserve ErrLightClientLag") + require.Equal(t, int32(4), stub.netCalls.Load()) +} + +// TestWaitForDKGNetworkCreation_OtherErrorFailsFast verifies a non-not-found query error is returned +// directly without burning the retry budget. +func TestWaitForDKGNetworkCreation_OtherErrorFailsFast(t *testing.T) { + wantErr := errors.New("rpc down") + stub := &stubQueryClient{netErr: wantErr} + s := &DKGServer{QueryClient: stub} + + network, err := s.waitForDKGNetworkCreation(t.Context(), "cc", 42, 4, time.Millisecond) + require.ErrorIs(t, err, wantErr) + require.False(t, errors.Is(err, ErrLightClientLag)) + require.Nil(t, network) + require.Equal(t, int32(1), stub.netCalls.Load(), "non-lag errors must not be retried") +} + +// TestWaitForDKGNetworkCreation_ContextCanceled verifies a caller going away mid-wait stops the +// retry loop promptly instead of burning the remaining budget in an orphaned handler. +func TestWaitForDKGNetworkCreation_ContextCanceled(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + stub := &stubQueryClient{netErr: notFoundErr()} + s := &DKGServer{QueryClient: stub} + + // A one-minute delay would time the test out if cancellation were not honored. + network, err := s.waitForDKGNetworkCreation(ctx, "cc", 42, 5, time.Minute) + require.ErrorIs(t, err, context.Canceled) + require.False(t, errors.Is(err, ErrLightClientLag)) + require.Nil(t, network) + require.Equal(t, int32(1), stub.netCalls.Load(), "cancellation must stop retries promptly") +} + +// TestRegistrationRetryBudgetOutlastsObservedLag guards the retry budget against accidental +// shrinking: the light client has been observed to trail the tip by ~40s at round +// boundaries, so the total budget must stay comfortably above that. +func TestRegistrationRetryBudgetOutlastsObservedLag(t *testing.T) { + budget := registrationNetworkRetryAttempts * registrationNetworkRetryDelay + require.GreaterOrEqual(t, budget, 60*time.Second, + "registration retry budget must outlast the ~40s observed light-client lag") +} diff --git a/service/round_context.go b/service/round_context.go index 1fcb22a..45a0574 100644 --- a/service/round_context.go +++ b/service/round_context.go @@ -9,6 +9,7 @@ import ( log "github.com/sirupsen/logrus" "github.com/piplabs/story-kernel/store" + "github.com/piplabs/story-kernel/story" pb "github.com/piplabs/story-kernel/types/pb/v0" ) @@ -54,7 +55,7 @@ func (s *DKGServer) GetOrLoadRoundContext( log.WithFields(log.Fields{ "round": round, "attempt": attempt + 1, - }).Warn("GetOrLoadRoundContext: threshold is 0, retrying (light client may lag)") + }).Warn("GetOrLoadRoundContext: round state not caught up (threshold 0 or network not visible), retrying (light client may lag)") time.Sleep(thresholdRetryDelay) @@ -89,6 +90,13 @@ func (s *DKGServer) fetchRoundContext( ) (*store.RoundContext, error) { network, err := s.QueryClient.GetDKGNetwork(context.Background(), codeCommitmentsHex, round) if err != nil { + // A not-found read for a round a caller is actively working on is the same lag + // shape as threshold==0: the light client has not verified up to the round's + // start block yet. Classify it as lag so GetOrLoadRoundContext retries it. + if errors.Is(err, story.ErrDKGNetworkNotFound) { + return nil, fmt.Errorf("%w: %v", ErrLightClientLag, err) + } + return nil, err } diff --git a/service/round_context_test.go b/service/round_context_test.go index 3a050d7..d6d57c1 100644 --- a/service/round_context_test.go +++ b/service/round_context_test.go @@ -24,10 +24,15 @@ type stubQueryClient struct { // (ALL statuses). If nil, that method falls back to the VERIFIED-only set so // existing tests keep both sets identical. allRegistrations []*pb.DKGRegistration - netCalls atomic.Int32 - regCalls atomic.Int32 - netErr error - regErr error + // netErrs, when set, scripts a per-call error for GetDKGNetwork: call i returns + // netErrs[i] (nil = success). Calls beyond the slice succeed. netErr takes precedence. + // Error calls advance the same call counter that indexes networks, so a success after + // k scripted errors reads networks[k] (clamped to the last element). + netErrs []error + netCalls atomic.Int32 + regCalls atomic.Int32 + netErr error + regErr error } var _ story.QueryClient = (*stubQueryClient)(nil) @@ -37,6 +42,9 @@ func (s *stubQueryClient) GetDKGNetwork(_ context.Context, _ string, _ uint32) ( if s.netErr != nil { return nil, s.netErr } + if i < len(s.netErrs) && s.netErrs[i] != nil { + return nil, s.netErrs[i] + } if i >= len(s.networks) { i = len(s.networks) - 1 } @@ -495,3 +503,31 @@ func TestFetchRoundContext_PreservesInvalidatedSlot(t *testing.T) { _, err = shrunkSrv.GetOrLoadRoundContext("cc", 5) require.Error(t, err, "shrinking to the VERIFIED-only set must fail the count check") } + +// A not-found network read (light client has not observed the round yet) is classified as +// ErrLightClientLag so GetOrLoadRoundContext retries it instead of failing the caller. +func TestFetchRoundContext_NotFoundClassifiedAsLag(t *testing.T) { + t.Parallel() + + stub := &stubQueryClient{netErr: notFoundErr()} + s := &DKGServer{QueryClient: stub} + + rc, err := s.fetchRoundContext("cc", 42) + require.Nil(t, rc) + require.ErrorIs(t, err, ErrLightClientLag) + require.Equal(t, int32(1), stub.netCalls.Load()) +} + +// A non-not-found network error must stay non-lag so callers fail fast. +func TestFetchRoundContext_OtherErrorStaysNonLag(t *testing.T) { + t.Parallel() + + wantErr := errors.New("rpc down") + stub := &stubQueryClient{netErr: wantErr} + s := &DKGServer{QueryClient: stub} + + rc, err := s.fetchRoundContext("cc", 42) + require.Nil(t, rc) + require.ErrorIs(t, err, wantErr) + require.False(t, errors.Is(err, ErrLightClientLag)) +} diff --git a/story/query_client.go b/story/query_client.go index 828ce45..2e06fc7 100644 --- a/story/query_client.go +++ b/story/query_client.go @@ -39,8 +39,16 @@ const ( refreshIntervalTime = 3 * time.Second ) +// ErrDKGNetworkNotFound signals the round's DKG network record is absent at the light +// client's verified height. At a round boundary this usually means the light client has +// not yet verified up to the round's start block, not that the round does not exist. +var ErrDKGNetworkNotFound = errors.New("DKG network not found") + // This can be implemented by either HTTP client or verified light client. type QueryClient interface { + // GetDKGNetwork returns the round's DKG network. Implementations must wrap + // ErrDKGNetworkNotFound when the record is absent at the queried height so + // callers can distinguish light-client lag from other failures. GetDKGNetwork(ctx context.Context, codeCommitmentHex string, round uint32) (*pb.DKGNetwork, error) GetAllParticipantDKGRegistrations(ctx context.Context, codeCommitmentHex string, round uint32) ([]*pb.DKGRegistration, error) GetAllRegisteredDKGRegistrations(ctx context.Context, codeCommitmentHex string, round uint32) ([]*pb.DKGRegistration, error) @@ -237,7 +245,7 @@ func (q *VerifiedQueryClient) GetDKGNetwork(ctx context.Context, codeCommitmentH } if len(bz) == 0 { - return nil, fmt.Errorf("DKG network not found for code_commitment %s, round %d", codeCommitmentHex, round) + return nil, fmt.Errorf("%w for code_commitment %s, round %d", ErrDKGNetworkNotFound, codeCommitmentHex, round) } // Decode DKGNetwork from protobuf