From 00dee709b7f703ed9234575b0fbe7e25f07e723a Mon Sep 17 00:00:00 2001 From: 0xHansLee Date: Mon, 3 Aug 2026 17:53:14 +0900 Subject: [PATCH 1/2] fix(service): tolerate light-client lag when fetching the DKG network at registration GenerateAndSealKey read the round's DKG network once and hard-failed when the light client had not yet verified up to the round's start block, so a node whose verified head trailed the chain tip at a round boundary could not register and lost the round. Retry the not-found read with a bounded budget the same way the dealing/finalization paths tolerate lag, and surface exhaustion as codes.Unavailable so callers see a transient failure. --- service/dkg_generate_key.go | 94 ++++++++++++++++++++++++++++- service/dkg_generate_key_test.go | 100 +++++++++++++++++++++++++++++++ service/round_context_test.go | 16 +++-- story/query_client.go | 10 +++- 4 files changed, 212 insertions(+), 8 deletions(-) create mode 100644 service/dkg_generate_key_test.go 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_test.go b/service/round_context_test.go index 3a050d7..d8d2a7c 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 } 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 From f24a3e2b7d3206a93bf639aea8530ab9464d31af Mon Sep 17 00:00:00 2001 From: 0xHansLee Date: Mon, 3 Aug 2026 18:44:24 +0900 Subject: [PATCH 2/2] fix(service): classify a not-found DKG network read as light-client lag in dealing/finalization paths fetchRoundContext and waitForFinalizationRegistrations failed fast when the light client had not observed the round's DKG network record at all, even though that read has the same lag shape as threshold==0 or a not-yet-reached stage. Classify the not-found read as ErrLightClientLag so the existing retry machinery covers it; other errors still fail fast. --- service/dkg_finalize.go | 44 +++++++++++++++++++++-------- service/dkg_finalize_wait_test.go | 47 ++++++++++++++++++++++++++++--- service/round_context.go | 10 ++++++- service/round_context_test.go | 28 ++++++++++++++++++ 4 files changed, 113 insertions(+), 16 deletions(-) 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/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 d8d2a7c..d6d57c1 100644 --- a/service/round_context_test.go +++ b/service/round_context_test.go @@ -503,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)) +}