From 51bdfbd6526b61bb4be8e7ee547b88616bbe2b08 Mon Sep 17 00:00:00 2001 From: George Date: Tue, 10 Feb 2026 11:43:29 -0800 Subject: [PATCH 1/3] Ensure we have ledgers to index into --- cmd/stellar-rpc/internal/methods/get_ledgers.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cmd/stellar-rpc/internal/methods/get_ledgers.go b/cmd/stellar-rpc/internal/methods/get_ledgers.go index 0ac7c4ab..bd6bff07 100644 --- a/cmd/stellar-rpc/internal/methods/get_ledgers.go +++ b/cmd/stellar-rpc/internal/methods/get_ledgers.go @@ -98,8 +98,13 @@ func (h ledgersHandler) getLedgers( if err != nil { return protocol.GetLedgersResponse{}, err } - cursor := strconv.Itoa(int(ledgers[len(ledgers)-1].Sequence)) + cursorLedger := start + if len(ledgers) > 0 { + cursorLedger = ledgers[len(ledgers)-1].Sequence + } + + cursor := strconv.Itoa(int(cursorLedger)) return protocol.GetLedgersResponse{ Ledgers: ledgers, // TODO: update these fields using ledger range from datastore From 054bd30a585adc5e787cb7ce06f199a2d6d60bfc Mon Sep 17 00:00:00 2001 From: George Date: Tue, 10 Feb 2026 13:41:02 -0800 Subject: [PATCH 2/3] Improve fallback scenario --- cmd/stellar-rpc/internal/methods/get_ledgers.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/cmd/stellar-rpc/internal/methods/get_ledgers.go b/cmd/stellar-rpc/internal/methods/get_ledgers.go index bd6bff07..fb38803e 100644 --- a/cmd/stellar-rpc/internal/methods/get_ledgers.go +++ b/cmd/stellar-rpc/internal/methods/get_ledgers.go @@ -99,12 +99,17 @@ func (h ledgersHandler) getLedgers( return protocol.GetLedgersResponse{}, err } - cursorLedger := start + var cursor string if len(ledgers) > 0 { - cursorLedger = ledgers[len(ledgers)-1].Sequence + cursor = strconv.FormatUint(uint64(ledgers[len(ledgers)-1].Sequence), 10) + } else { + if request.Pagination != nil && request.Pagination.Cursor != "" { + cursor = request.Pagination.Cursor + } else { // start > 0 by validation + cursor = strconv.FormatUint(uint64(start-1), 10) + } } - cursor := strconv.Itoa(int(cursorLedger)) return protocol.GetLedgersResponse{ Ledgers: ledgers, // TODO: update these fields using ledger range from datastore From 7d10341247fa930c6af8a01678a176e7a1015885 Mon Sep 17 00:00:00 2001 From: George Date: Tue, 10 Feb 2026 14:43:49 -0800 Subject: [PATCH 3/3] Add unit tests to cover the new cases --- .../internal/methods/get_ledgers_test.go | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/cmd/stellar-rpc/internal/methods/get_ledgers_test.go b/cmd/stellar-rpc/internal/methods/get_ledgers_test.go index d50321a6..7285c66d 100644 --- a/cmd/stellar-rpc/internal/methods/get_ledgers_test.go +++ b/cmd/stellar-rpc/internal/methods/get_ledgers_test.go @@ -445,3 +445,87 @@ func TestFetchLedgersErrors(t *testing.T) { mockTx.AssertExpectations(t) }) } + +// TestGetLedgers_EmptyBatchGetLedgersResult is a regression test that ensures +// when GetLedgerRange reports data but BatchGetLedgers returns an empty slice, +// getLedgers returns an empty page with a stable cursor and does not panic. +func TestGetLedgers_EmptyBatchGetLedgersResult(t *testing.T) { + ctx := t.Context() + + t.Run("empty result with cursor", func(t *testing.T) { + mockReader := new(MockLedgerReader) + mockReaderTx := new(MockLedgerReaderTx) + + handler := ledgersHandler{ + ledgerReader: mockReader, + maxLimit: 100, + defaultLimit: 5, + } + + localRange := ledgerbucketwindow.LedgerRange{ + FirstLedger: ledgerbucketwindow.LedgerInfo{Sequence: 100}, + LastLedger: ledgerbucketwindow.LedgerInfo{Sequence: 200}, + } + + mockReader.On("NewTx", ctx).Return(mockReaderTx, nil) + mockReaderTx.On("Done").Return(nil) + mockReaderTx.On("GetLedgerRange", ctx).Return(localRange, nil) + // BatchGetLedgers returns empty slice even though GetLedgerRange indicates data exists + mockReaderTx.On("BatchGetLedgers", ctx, uint32(151), uint32(155)). + Return([]db.LedgerMetadataChunk{}, nil) + + request := protocol.GetLedgersRequest{ + Pagination: &protocol.LedgerPaginationOptions{ + Cursor: "150", + Limit: 5, + }, + } + + response, err := handler.getLedgers(ctx, request) + require.NoError(t, err) + assert.Empty(t, response.Ledgers) + // Cursor should echo the request cursor for stability + assert.Equal(t, "150", response.Cursor) + assert.Equal(t, uint32(200), response.LatestLedger) + + mockReader.AssertExpectations(t) + mockReaderTx.AssertExpectations(t) + }) + + t.Run("empty result without cursor", func(t *testing.T) { + mockReader := new(MockLedgerReader) + mockReaderTx := new(MockLedgerReaderTx) + + handler := ledgersHandler{ + ledgerReader: mockReader, + maxLimit: 100, + defaultLimit: 5, + } + + localRange := ledgerbucketwindow.LedgerRange{ + FirstLedger: ledgerbucketwindow.LedgerInfo{Sequence: 100}, + LastLedger: ledgerbucketwindow.LedgerInfo{Sequence: 200}, + } + + mockReader.On("NewTx", ctx).Return(mockReaderTx, nil) + mockReaderTx.On("Done").Return(nil) + mockReaderTx.On("GetLedgerRange", ctx).Return(localRange, nil) + // BatchGetLedgers returns empty slice even though GetLedgerRange indicates data exists + mockReaderTx.On("BatchGetLedgers", ctx, uint32(100), uint32(104)). + Return([]db.LedgerMetadataChunk{}, nil) + + request := protocol.GetLedgersRequest{ + StartLedger: 100, + } + + response, err := handler.getLedgers(ctx, request) + require.NoError(t, err) + assert.Empty(t, response.Ledgers) + // Cursor should be start-1 (99) when no cursor provided and no results + assert.Equal(t, "99", response.Cursor) + assert.Equal(t, uint32(200), response.LatestLedger) + + mockReader.AssertExpectations(t) + mockReaderTx.AssertExpectations(t) + }) +}