Skip to content
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Fixed
* **`getLedgers` no longer rejects its own cursor at the tip.** This changes wire behavior for BOTH rpcv1 and rpcv2 — the handler is shared. Resending the cursor of the last page used to fail with `-32602` ("cursor must be between the oldest ledger ... and the latest ledger ...") until the next ledger closed — the same code a malformed cursor gets, so a poller could not tell "wait and retry" from "bad cursor". A cursor at or past the tip now returns an empty page with the cursor echoed back. A cursor below the oldest ledger still errors (that data is gone), and an explicit `startLedger` above the tip still errors (only the server-issued token gets the echo).
* **`getTransactions` cursor no longer breaks a caught-up poller.** This changes wire behavior for BOTH rpcv1 and rpcv2 — the handler is shared. Two long-standing bugs, one fix: the returned `cursor` is now never below the request's cursor; at the tip, the request's cursor is echoed back. Before this fix: (1) a cursor at or above the node's latest ledger returned the literal cursor `"0"`, and resending it failed every poll with `-32602` — the client was stuck until it discarded its cursor (reported in [#745](https://github.com/stellar/stellar-rpc/issues/745)); (2) a cursor at a fully-consumed ledger returned a cursor pointing back at that ledger's start, and the next poll re-delivered all of its transactions as duplicates. Clients need no changes: the returned cursor now always does what the docs promise — fetch what comes next.

### Changed
* `getTransactions` now scans at most 10,000 ledgers per request. On a sparse range a page can come back short — or even empty — while still carrying a `cursor`, so a response shorter than `limit` no longer implies end-of-data. Every response carries a `cursor`; to tell a sparse scan window from the tip, compare the cursor's ledger part against `latestLedger` and keep paging while it is below ([#908](https://github.com/stellar/stellar-rpc/pull/908)).
* `getEvents` now rejects malformed contract IDs in filters with `-32602`: a `C…` string with a valid checksum but wrong-length payload used to decode and silently match nothing, and now errors under the SDK's stricter SEP-23 strkey parsing ([#908](https://github.com/stellar/stellar-rpc/pull/908)).
Expand Down
24 changes: 21 additions & 3 deletions cmd/stellar-rpc/internal/methods/get_ledgers.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,21 @@ func (h ledgersHandler) getLedgers(
}
}

// A caught-up poller's cursor points at or past the tip. Echo it back on
// an empty page instead of rejecting the server's own token. An explicit
// startLedger above the tip stays an error (Validate above rejects it).
if request.Pagination != nil && request.Pagination.Cursor != "" &&
start > availableLedgerRange.LastLedger {
return protocol.GetLedgersResponse{
Ledgers: []protocol.LedgerInfo{},
LatestLedger: ledgerRange.LastLedger.Sequence,
LatestLedgerCloseTime: ledgerRange.LastLedger.CloseTime,
OldestLedger: ledgerRange.FirstLedger.Sequence,
OldestLedgerCloseTime: ledgerRange.FirstLedger.CloseTime,
Cursor: request.Pagination.Cursor,
}, nil
}

end := start + uint32(limit) - 1 //nolint:gosec
ledgers, err := h.fetchLedgers(ctx, start, end, request.Format, readTx, ledgerRange.ToLedgerSeqRange())
if err != nil {
Expand Down Expand Up @@ -151,13 +166,16 @@ func (h ledgersHandler) parseCursor(cursor string, ledgerRange protocol.LedgerSe
return 0, err
}

// Only the lower bound is an error: below the oldest ledger is data the
// node no longer has. At or past the tip is a caught-up poller, answered
// with an empty page by getLedgers. The +1 wraps a max-uint32 cursor to
// start 0, which this check also catches.
start := uint32(cursorInt) + 1
if !protocol.IsLedgerWithinRange(start, ledgerRange) {
if start < ledgerRange.FirstLedger {
return 0, fmt.Errorf(
"cursor ('%s') must be between the oldest ledger: %d and the latest ledger: %d for this rpc instance",
"cursor ('%s') must be at or above the oldest ledger: %d for this rpc instance",
cursor,
ledgerRange.FirstLedger,
ledgerRange.LastLedger,
)
}

Expand Down
35 changes: 32 additions & 3 deletions cmd/stellar-rpc/internal/methods/get_ledgers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,36 @@ func TestGetLedgers_NoLedgers(t *testing.T) {
assert.Contains(t, err.Error(), "[-32603] DB is empty")
}

func TestGetLedgers_CursorGreaterThanLatestLedger(t *testing.T) {
func TestGetLedgers_CaughtUpCursorIsEchoed(t *testing.T) {
cursors := map[string]string{
"at the tip": "10",
"above the tip": "15",
}
for name, cursor := range cursors {
t.Run(name, func(t *testing.T) {
testDB := setupTestDB(t, 10)
handler := ledgersHandler{
ledgerReader: sqlitedb.NewLedgerReader(testDB),
maxLimit: 100,
defaultLimit: 5,
}

request := protocol.GetLedgersRequest{
Pagination: &protocol.LedgerPaginationOptions{
Cursor: cursor,
},
}

response, err := handler.getLedgers(context.TODO(), request)
require.NoError(t, err)
assert.Empty(t, response.Ledgers)
assert.Equal(t, cursor, response.Cursor)
assert.Equal(t, uint32(10), response.LatestLedger)
})
}
}

func TestGetLedgers_MaxUint32CursorIsRejected(t *testing.T) {
testDB := setupTestDB(t, 10)
handler := ledgersHandler{
ledgerReader: sqlitedb.NewLedgerReader(testDB),
Expand All @@ -235,13 +264,13 @@ func TestGetLedgers_CursorGreaterThanLatestLedger(t *testing.T) {

request := protocol.GetLedgersRequest{
Pagination: &protocol.LedgerPaginationOptions{
Cursor: "15",
Cursor: "4294967295",
},
}

_, err := handler.getLedgers(context.TODO(), request)
require.Error(t, err)
assert.Contains(t, err.Error(), "cursor ('15') must be between")
assert.Contains(t, err.Error(), "must be at or above the oldest ledger")
}

func BenchmarkGetLedgers(b *testing.B) {
Expand Down
28 changes: 21 additions & 7 deletions cmd/stellar-rpc/internal/methods/get_transactions.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,27 +36,33 @@ func uint32ToInt32(value uint32, fieldName string) (int32, error) {
return int32(parsed), nil
}

// initializePagination sets the pagination limit and cursor
func (h transactionsRPCHandler) initializePagination(request protocol.GetTransactionsRequest) (toid.ID, uint, error) {
// initializePagination sets the pagination limit and cursor. The second
// return value is the request's own cursor, nil when the request has none.
func (h transactionsRPCHandler) initializePagination(
request protocol.GetTransactionsRequest,
) (toid.ID, *toid.ID, uint, error) {
startLedger, err := uint32ToInt32(request.StartLedger, "startLedger")
if err != nil {
return toid.ID{}, 0, &jrpc2.Error{
return toid.ID{}, nil, 0, &jrpc2.Error{
Code: jrpc2.InvalidParams,
Message: err.Error(),
}
}
start := toid.New(startLedger, 1, 1)
limit := h.defaultLimit
var requestCursor *toid.ID
if request.Pagination != nil {
if request.Pagination.Cursor != "" {
cursorInt, err := strconv.ParseInt(request.Pagination.Cursor, 10, 64)
if err != nil {
return toid.ID{}, 0, &jrpc2.Error{
return toid.ID{}, nil, 0, &jrpc2.Error{
Code: jrpc2.InvalidParams,
Message: err.Error(),
}
}
*start = toid.Parse(cursorInt)
parsed := toid.Parse(cursorInt)
requestCursor = &parsed
*start = parsed
// increment tx index because, when paginating,
// we start with the item right after the cursor
start.TransactionOrder++
Expand All @@ -65,7 +71,7 @@ func (h transactionsRPCHandler) initializePagination(request protocol.GetTransac
limit = request.Pagination.Limit
}
}
return *start, limit, nil
return *start, requestCursor, limit, nil
}

// fetchLedgerData calls the meta table to fetch the corresponding ledger data.
Expand Down Expand Up @@ -245,7 +251,7 @@ func (h transactionsRPCHandler) getTransactionsByLedgerSequence(ctx context.Cont
}
}

start, limit, err := h.initializePagination(request)
start, requestCursor, limit, err := h.initializePagination(request)
if err != nil {
return protocol.GetTransactionsResponse{}, err
}
Expand Down Expand Up @@ -280,6 +286,14 @@ func (h transactionsRPCHandler) getTransactionsByLedgerSequence(ctx context.Cont
}
}

// A caught-up poller's cursor points at or past the tip. The walk then
// produces nothing and leaves the cursor below the request's own — at the
// zero value, or at the consumed ledger's start. Echo the request's cursor
// instead: the returned token must always fetch what comes next (#745).
if requestCursor != nil && cursor.ToInt64() < requestCursor.ToInt64() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While verifying the cursor guard here I checked how the other two paginated methods behave for the same caught-up poll. getEvents is fine: an empty page returns a max cursor at the last scanned ledger, which stays valid when resent at the tip. getLedgers still breaks the polling loop, and it fails with an error instead of an empty page:

  1. A poller reaches the tip ledger T. getLedgers returns its last page with cursor: "T".
  2. The poller resends cursor: "T". parseCursor computes start = T + 1 and rejects it, because IsLedgerWithinRange requires start <= T (get_ledgers.go, line 154).
  3. The poller gets -32602: "cursor ('T') must be between the oldest ledger: X and the latest ledger: T". Every poll fails until the next ledger closes.

This self-heals within one ledger interval, so nobody gets permanently stuck the way #745 clients did. But it is the steady state for anyone polling faster than the close interval, and the code is the same -32602 a malformed cursor gets, so a client cannot tell "wait and retry" apart from "my cursor is garbage" without parsing the message text. The handler already contains the intended behavior: the empty-result branch echoes request.Pagination.Cursor (line 106), but validation rejects the caught-up request before that branch can run, so it is unreachable for exactly the case it was written for.

Since the changelog entry in this PR states the contract generally ("the returned cursor now always does what the docs promise"), I think we should bring getLedgers in line, either as one more commit here or as an immediate follow-up, ideally landing in the same release so the caught-up contract changes once for both methods. The shape I have in mind mirrors the getTransactions fix: parseCursor keeps only the lower-bound rejection (a cursor below the oldest ledger is real data loss and should stay an error), and the handler returns early for a cursor at or past the tip, before end is computed or fetchLedgers runs:

// A caught-up poller's cursor points at or past the tip. Echo it back on an
// empty page instead of rejecting the server's own token.
if request.Pagination != nil && request.Pagination.Cursor != "" &&
	start > availableLedgerRange.LastLedger {
	return protocol.GetLedgersResponse{
		Ledgers:               []protocol.LedgerInfo{},
		LatestLedger:          ledgerRange.LastLedger.Sequence,
		LatestLedgerCloseTime: ledgerRange.LastLedger.CloseTime,
		OldestLedger:          ledgerRange.FirstLedger.Sequence,
		OldestLedgerCloseTime: ledgerRange.FirstLedger.CloseTime,
		Cursor:                request.Pagination.Cursor,
	}, nil
}

Two details worth pinning in tests: an explicit startLedger above the tip should stay an error, the same asymmetry getTransactions has (only the server-issued token gets the echo, an explicit out-of-range start is a client mistake), and the cursor "4294967295" makes start wrap to 0 in parseCursor, which the retained lower-bound check needs to catch. Placing the early return before end := start + uint32(limit) - 1 also keeps that addition from ever running with an above-tip start.

If you'd rather defer this, a row on #937 plus scoping the changelog sentence to getTransactions would keep the docs honest in the meantime.

@karthikiyer56 karthikiyer56 Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair callout — done in 37d8424, making getLedgers toe the same cursor-semantics line as getTransactions.

cursor = requestCursor
}

return protocol.GetTransactionsResponse{
Transactions: txns,
LatestLedger: ledgerRange.LastLedger.Sequence,
Expand Down
30 changes: 30 additions & 0 deletions cmd/stellar-rpc/internal/methods/get_transactions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,36 @@ func TestGetTransactions_CustomLimitAndCursor(t *testing.T) {
assert.Equal(t, uint32(3), response.Transactions[2].Ledger)
}

func TestGetTransactions_CaughtUpCursorIsEchoed(t *testing.T) {
cursors := map[string]string{
"above the tip": toid.New(15, 1, 1).String(),
"at the consumed tip": toid.New(10, 2, 1).String(),
"past the tip's last tx": toid.New(10, 5, 1).String(),
}
for name, cursor := range cursors {
t.Run(name, func(t *testing.T) {
testDB := setupDB(t, 10, 0)
handler := transactionsRPCHandler{
ledgerReader: sqlitedb.NewLedgerReader(testDB),
maxLimit: 100,
defaultLimit: 10,
networkPassphrase: NetworkPassphrase,
}

request := protocol.GetTransactionsRequest{
Pagination: &protocol.LedgerPaginationOptions{
Cursor: cursor,
},
}

response, err := handler.getTransactionsByLedgerSequence(context.TODO(), request)
require.NoError(t, err)
assert.Empty(t, response.Transactions)
assert.Equal(t, cursor, response.Cursor)
})
}
}

func TestGetTransactions_InvalidStartLedger(t *testing.T) {
testDB := setupDB(t, 3, 0)
handler := transactionsRPCHandler{
Expand Down
19 changes: 19 additions & 0 deletions cmd/stellar-rpc/internal/rpcv2/jsonrpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ func newJSONRPCHandler(cfg config.Config, p handlerParams) jsonrpc.Handler {
specs = limitsByMethod(m).Apply(specs)
for i := range specs {
specs[i].Handler = wrapAdapterRequest(specs[i].Handler, p.registry)
if specs[i].MethodName == protocol.GetHealthMethodName {
specs[i].Handler = gateHealthOnFirstCommit(specs[i].Handler, p.registry)
}
}

return jsonrpc.NewHandler(jsonrpc.Params{
Expand Down Expand Up @@ -155,6 +158,22 @@ func wrapAdapterRequest(h jrpc2.Handler, registry *query.Registry) jrpc2.Handler
}
}

// gateHealthOnFirstCommit fails getHealth until this run commits a ledger.
// Close times survive restarts in the durable stores, so freshness alone
// cannot tell a working node from one whose ingestion never started. v1
// keeps the shared behavior.
func gateHealthOnFirstCommit(h jrpc2.Handler, registry *query.Registry) jrpc2.Handler {
return func(ctx context.Context, req *jrpc2.Request) (any, error) {
if !registry.HasCommittedSinceBoot() {
return nil, &jrpc2.Error{
Code: jrpc2.InternalError,
Message: "ingestion has not committed a ledger since this process started",
}
}
return h(ctx, req)
}
}

// graceMargin is the slack deriveLifecycleGrace adds on top of the longest
// request timeout. It covers the gap between a request's deadline firing and
// its handler goroutine actually stopping: the duration limiter answers the
Expand Down
20 changes: 20 additions & 0 deletions cmd/stellar-rpc/internal/rpcv2/jsonrpc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,26 @@ func TestJSONRPCHandler_HealthyOverFreshRegistryStamp(t *testing.T) {
assert.Equal(t, "healthy", result.Status)
}

func TestJSONRPCHandler_HealthGatedUntilFirstCommit(t *testing.T) {
r := seedServingRegistry(t)
r.SeedLatestAtBoot(chunk.FirstLedgerSeq, time.Now().Unix())
url := newTestRPCServer(t, r)

out := rpcv2test.PostRPC(t, url, "getHealth", `{}`)
require.NotNil(t, out.Error)
assert.EqualValues(t, jrpc2.InternalError, out.Error.Code)
assert.Contains(t, out.Error.Message, "since this process started")

r.SetLatestLedger(chunk.FirstLedgerSeq+1, time.Now().Unix())
out = rpcv2test.PostRPC(t, url, "getHealth", `{}`)
require.Nil(t, out.Error)
var result struct {
Status string `json:"status"`
}
require.NoError(t, json.Unmarshal(out.Result, &result))
assert.Equal(t, "healthy", result.Status)
}

func TestWrapAdapterRequest_PanicReleasesSharedView(t *testing.T) {
logger, buf := capturingLogger()
cat, _ := rpcv2test.OpenTestCatalogWith(t, testCPI, logger)
Expand Down
57 changes: 57 additions & 0 deletions cmd/stellar-rpc/internal/rpcv2/methods_completeness_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package rpcv2

import (
"reflect"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/config"
)

// configMethodNames maps each MethodsConfig method field's Go name to its wire
// name (the toml tag). The two methods-wide default-tier fields are pointers,
// not structs, so the kind check skips them.
func configMethodNames(t *testing.T) map[string]string {
t.Helper()
typ := reflect.TypeFor[config.MethodsConfig]()
names := map[string]string{}
for field := range typ.Fields() {
if field.Type.Kind() != reflect.Struct {
continue
}
wireName, _, _ := strings.Cut(field.Tag.Get("toml"), ",")
require.NotEmpty(t, wireName, "method field %s needs a toml tag", field.Name)
names[field.Name] = wireName
}
return names
}

func TestLimitsByMethod_CoversEveryConfiguredMethod(t *testing.T) {
limits := limitsByMethod(validCfg(1, 1, "genesis").Service.Methods)

wireNames := configMethodNames(t)
for _, name := range wireNames {
assert.Contains(t, limits, name)
}
assert.Len(t, limits, len(wireNames),
"limitsByMethod has a key with no MethodsConfig field behind it")
}

func TestValidateService_ChecksEveryConfiguredMethod(t *testing.T) {
for fieldName, wireName := range configMethodNames(t) {
t.Run(wireName, func(t *testing.T) {
cfg := validCfg(1, 1, "genesis")
methodField := reflect.ValueOf(&cfg.Service.Methods).Elem().FieldByName(fieldName)
zero := uint(0)
methodField.FieldByName("QueueLimit").Set(reflect.ValueOf(&zero))

err := validateService(cfg.Service)
require.Error(t, err,
"validateService's method list is missing %s", wireName)
assert.Contains(t, err.Error(), wireName)
})
}
}
5 changes: 5 additions & 0 deletions cmd/stellar-rpc/internal/rpcv2/observability/observability.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,11 @@ func NewPrometheusMetrics(registry *prometheus.Registry, namespace string) *Prom
"cold ledger packs whose file was gone on first read "+
"(routing only opens packs the catalog snapshot holds; any count is an alarm)",
ledger.MissingPackOpens),
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Namespace: namespace, Subsystem: subsystem, Name: "open_snapshots",
Help: "RocksDB snapshots currently held, across all stores " +
"(request-scoped, so a floor that stops returning to zero is a leaked read view)",
}, func() float64 { return float64(rocksdb.OpenSnapshots()) }),
)
return m
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ func TestPrometheusMetrics_RegistersAndRecords(t *testing.T) {
assert.InDelta(t, float64(3), values["test_ns_fullhistory_streaming_discarded_hot_chunks_total"], 0)
assert.InDelta(t, float64(2), values["test_ns_fullhistory_streaming_pruned_artifacts_total"], 0)

_, exported := values["test_ns_fullhistory_streaming_open_snapshots"]
assert.True(t, exported, "open_snapshots gauge must be registered")

// Phase-duration histogram saw backfill_pass + freeze + rebuild + discard + prune = 5 observations.
assert.Equal(t, uint64(5), counts["test_ns_fullhistory_streaming_phase_duration_seconds"])
}
Expand Down
18 changes: 17 additions & 1 deletion cmd/stellar-rpc/internal/rpcv2/query/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ type Registry struct {
// (ReadView.LatestLedger / LatestCloseTime), never this live value.
latest atomic.Pointer[ledgerStamp]

// bootSeq is the latest ledger found at startup; set once by SeedLatestAtBoot.
bootSeq uint32

// oldest is a read-through cache of the retention floor's first ledger and
// its close time. Views populate it after a fallback point read
// (ReadView.RecordOldestCloseTime); readers trust it only while its seq
Expand Down Expand Up @@ -118,7 +121,7 @@ func OpenRegistry(
}
r.PublishHandle(live.ChunkID(), live)
// The catalog has no close times, so the seed stamp starts at 0 (unknown).
r.SetLatestLedger(lastCommitted, 0)
r.SeedLatestAtBoot(lastCommitted, 0)
return r, nil
}

Expand All @@ -145,6 +148,19 @@ func (r *Registry) SetLatestLedger(seq uint32, closeTimeUnix int64) {
r.latest.Store(&ledgerStamp{seq: seq, closeTime: closeTimeUnix})
}

// SeedLatestAtBoot is SetLatestLedger plus a record of seq as this process's
// boot value. OpenRegistry calls it once at startup.
func (r *Registry) SeedLatestAtBoot(seq uint32, closeTimeUnix int64) {
r.bootSeq = seq
r.SetLatestLedger(seq, closeTimeUnix)
}

// HasCommittedSinceBoot reports whether ingestion advanced the latest ledger
// past the boot value.
func (r *Registry) HasCommittedSinceBoot() bool {
return r.latest.Load().seq > r.bootSeq
}

// LatestLedger returns the live latest ledger. Queries do not call this — they
// read the frozen ReadView.LatestLedger captured at acquisition (see the
// latest field).
Expand Down
Loading
Loading