From d377b7923411fa432969af9bda62f276cfc167ad Mon Sep 17 00:00:00 2001 From: Tomer Weller Date: Sat, 20 Dec 2025 15:07:40 -0500 Subject: [PATCH 1/2] Add order parameter to getEvents for descending order support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This adds an optional `order` field to the getEvents pagination options, allowing clients to retrieve events in descending order (newest first). Changes: - Update DB layer to accept order parameter for query direction - Update handler to use protocol.EventOrder types - Add tests for descending order, limit with descending, and invalid order This feature enables efficient querying of the N most recent events without scanning the entire retention window. The implementation is fully backwards compatible - order defaults to "asc" when not specified. Depends on: https://github.com/stellar/go-stellar-sdk/pull/5888 Closes #575 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/stellar-rpc/internal/db/event.go | 22 +- cmd/stellar-rpc/internal/db/event_test.go | 2 +- .../internal/db/transaction_test.go | 2 +- .../internal/methods/get_events.go | 59 +++++- .../internal/methods/get_events_test.go | 193 ++++++++++++++++++ go.mod | 12 +- go.sum | 26 ++- 7 files changed, 286 insertions(+), 30 deletions(-) diff --git a/cmd/stellar-rpc/internal/db/event.go b/cmd/stellar-rpc/internal/db/event.go index feb287d65..b31d218d0 100644 --- a/cmd/stellar-rpc/internal/db/event.go +++ b/cmd/stellar-rpc/internal/db/event.go @@ -30,6 +30,16 @@ type EventWriter interface { InsertEvents(lcm xdr.LedgerCloseMeta) error } +// EventOrder represents the order in which events are returned +type EventOrder string + +const ( + // EventOrderAsc returns events in ascending order (oldest first) + EventOrderAsc EventOrder = "asc" + // EventOrderDesc returns events in descending order (newest first) + EventOrderDesc EventOrder = "desc" +) + // EventReader has all the public methods to fetch events from DB type EventReader interface { GetEvents( @@ -38,6 +48,7 @@ type EventReader interface { contractIDs [][]byte, topics NestedTopicArray, eventTypes []int, + order EventOrder, f ScanFunction, ) error } @@ -292,7 +303,7 @@ func (eventHandler *eventHandler) trimEvents(latestLedgerSeq uint32, retentionWi // GetEvents applies f on all the events occurring in the given range with // specified contract IDs if provided. The events are returned in sorted -// ascending Cursor order. +// order based on the order parameter (ascending or descending). // // If f returns false, the scan terminates early (f will not be applied on // remaining events in the range). @@ -304,16 +315,23 @@ func (eventHandler *eventHandler) GetEvents( contractIDs [][]byte, topics NestedTopicArray, eventTypes []int, + order EventOrder, scanner ScanFunction, ) error { start := time.Now() + // Determine sort order + orderDirection := "ASC" + if order == EventOrderDesc { + orderDirection = "DESC" + } + rowQ := sq. Select("id", "event_data", "transaction_hash", "ledger_close_time"). From(eventTableName). Where(sq.GtOrEq{"id": cursorRange.Start.String()}). Where(sq.Lt{"id": cursorRange.End.String()}). - OrderBy("id ASC") + OrderBy("id " + orderDirection) if len(contractIDs) > 0 { rowQ = rowQ.Where(sq.Eq{"contract_id": contractIDs}) diff --git a/cmd/stellar-rpc/internal/db/event_test.go b/cmd/stellar-rpc/internal/db/event_test.go index 382089182..026da6265 100644 --- a/cmd/stellar-rpc/internal/db/event_test.go +++ b/cmd/stellar-rpc/internal/db/event_test.go @@ -201,6 +201,6 @@ func TestInsertEvents(t *testing.T) { end := protocol.Cursor{Ledger: 100} cursorRange := protocol.CursorRange{Start: start, End: end} - err = eventReader.GetEvents(ctx, cursorRange, nil, nil, nil, nil) + err = eventReader.GetEvents(ctx, cursorRange, nil, nil, nil, EventOrderAsc, nil) require.NoError(t, err) } diff --git a/cmd/stellar-rpc/internal/db/transaction_test.go b/cmd/stellar-rpc/internal/db/transaction_test.go index a392bb3dc..c2619625f 100644 --- a/cmd/stellar-rpc/internal/db/transaction_test.go +++ b/cmd/stellar-rpc/internal/db/transaction_test.go @@ -229,7 +229,7 @@ func TestTransactionFound(t *testing.T) { end := protocol.Cursor{Ledger: 1000} cursorRange := protocol.CursorRange{Start: start, End: end} - err = eventReader.GetEvents(ctx, cursorRange, nil, nil, nil, nil) + err = eventReader.GetEvents(ctx, cursorRange, nil, nil, nil, EventOrderAsc, nil) require.NoError(t, err) // check all 200 cases diff --git a/cmd/stellar-rpc/internal/methods/get_events.go b/cmd/stellar-rpc/internal/methods/get_events.go index c3c9ea790..38987a9aa 100644 --- a/cmd/stellar-rpc/internal/methods/get_events.go +++ b/cmd/stellar-rpc/internal/methods/get_events.go @@ -117,14 +117,30 @@ func (h eventsRPCHandler) getEvents(ctx context.Context, request protocol.GetEve } } + order := protocol.EventOrderAsc + if request.Pagination != nil && request.Pagination.Order != "" { + order = request.Pagination.Order + } + isDescending := order == protocol.EventOrderDesc + start := protocol.Cursor{Ledger: request.StartLedger} limit := h.defaultLimit if request.Pagination != nil { if request.Pagination.Cursor != nil { start = *request.Pagination.Cursor - // increment event index because, when paginating, we start with the - // item right after the cursor - start.Event++ + // Adjust cursor for pagination based on order direction + if isDescending { + // For descending order, we move backwards from the cursor + if start.Event > 0 { + start.Event-- + } else { + // Need to move to previous tx/op/ledger + start = decrementCursor(start) + } + } else { + // For ascending order, we move forward from the cursor + start.Event++ + } } if request.Pagination.Limit > 0 { limit = request.Pagination.Limit @@ -179,7 +195,13 @@ func (h eventsRPCHandler) getEvents(ctx context.Context, request protocol.GetEve return uint(len(found)) < limit } - err = h.dbReader.GetEvents(ctx, cursorRange, contractIDs, topics, eventTypes, eventScanFunction) + // Convert order to db.EventOrder + dbOrder := db.EventOrderAsc + if isDescending { + dbOrder = db.EventOrderDesc + } + + err = h.dbReader.GetEvents(ctx, cursorRange, contractIDs, topics, eventTypes, dbOrder, eventScanFunction) if err != nil { return protocol.GetEventsResponse{}, &jrpc2.Error{ Code: jrpc2.InvalidRequest, Message: err.Error(), @@ -209,9 +231,15 @@ func (h eventsRPCHandler) getEvents(ctx context.Context, request protocol.GetEve // cursor represents end of the search window if events does not reach limit // here endLedger is always exclusive when fetching events // so search window is max Cursor value with endLedger - 1 - maxCursor := protocol.MaxCursor - maxCursor.Ledger = endLedger - 1 - cursor = maxCursor.String() + if isDescending { + // For descending order, the cursor represents the start of the search window + minCursor := protocol.Cursor{Ledger: start.Ledger} + cursor = minCursor.String() + } else { + maxCursor := protocol.MaxCursor + maxCursor.Ledger = endLedger - 1 + cursor = maxCursor.String() + } } return protocol.GetEventsResponse{ @@ -225,6 +253,23 @@ func (h eventsRPCHandler) getEvents(ctx context.Context, request protocol.GetEve }, nil } +// decrementCursor decrements the cursor to the previous position +func decrementCursor(c protocol.Cursor) protocol.Cursor { + // If we're at the minimum cursor for this ledger, we can't go further back + // The cursor will remain at position 0,0,0 for the ledger + if c.Event == 0 && c.Op == 0 && c.Tx == 0 { + return c + } + // Set to the maximum possible cursor value to capture all earlier events + // This effectively means "everything before this cursor in this ledger" + return protocol.Cursor{ + Ledger: c.Ledger, + Tx: c.Tx, + Op: c.Op, + Event: 0, // The DB query will handle the rest with DESC ordering + } +} + func eventInfoForEvent( event xdr.DiagnosticEvent, cursor protocol.Cursor, diff --git a/cmd/stellar-rpc/internal/methods/get_events_test.go b/cmd/stellar-rpc/internal/methods/get_events_test.go index 478b79435..ecf13710a 100644 --- a/cmd/stellar-rpc/internal/methods/get_events_test.go +++ b/cmd/stellar-rpc/internal/methods/get_events_test.go @@ -1012,6 +1012,199 @@ func TestGetEvents(t *testing.T) { results, ) }) + + t.Run("with descending order", func(t *testing.T) { + dbx := newTestDB(t) + ctx := context.TODO() + log := log.DefaultLogger + log.SetLevel(logrus.TraceLevel) + + writer := db.NewReadWriter(log, dbx, interfaces.MakeNoOpDeamon(), 10, 10, passphrase) + write, err := writer.NewTx(ctx) + require.NoError(t, err) + + ledgerW, eventW := write.LedgerWriter(), write.EventWriter() + store := db.NewEventReader(log, dbx, passphrase) + + contractID := xdr.ContractId([32]byte{}) + var txMeta []xdr.TransactionMeta + for i := range 5 { + number := xdr.Uint64(i) + txMeta = append(txMeta, transactionMetaWithEvents( + contractEvent( + contractID, + xdr.ScVec{ + xdr.ScVal{Type: xdr.ScValTypeScvSymbol, Sym: &counter}, + }, + xdr.ScVal{Type: xdr.ScValTypeScvU64, U64: &number}, + ), + )) + } + ledgerCloseMeta := ledgerCloseMetaWithEvents(1, now.Unix(), txMeta...) + require.NoError(t, ledgerW.InsertLedger(ledgerCloseMeta), "ingestion failed for ledger") + require.NoError(t, eventW.InsertEvents(ledgerCloseMeta), "ingestion failed for events") + require.NoError(t, write.Commit(ledgerCloseMeta, nil)) + + handler := eventsRPCHandler{ + dbReader: store, + maxLimit: 10000, + defaultLimit: 100, + ledgerReader: db.NewLedgerReader(dbx), + } + + // Test descending order returns events in reverse order + results, err := handler.getEvents(ctx, protocol.GetEventsRequest{ + StartLedger: 1, + Pagination: &protocol.PaginationOptions{ + Order: protocol.EventOrderDesc, + }, + }) + require.NoError(t, err) + require.Len(t, results.Events, 5) + + // Verify events are returned in descending order (tx 5, 4, 3, 2, 1) + for i, event := range results.Events { + expectedTxIndex := uint32(5 - i) + assert.Equal(t, expectedTxIndex, event.TxIndex, + "event %d should have TxIndex %d", i, expectedTxIndex) + } + }) + + t.Run("descending order with limit", func(t *testing.T) { + dbx := newTestDB(t) + ctx := context.TODO() + log := log.DefaultLogger + log.SetLevel(logrus.TraceLevel) + + writer := db.NewReadWriter(log, dbx, interfaces.MakeNoOpDeamon(), 10, 10, passphrase) + write, err := writer.NewTx(ctx) + require.NoError(t, err) + + ledgerW, eventW := write.LedgerWriter(), write.EventWriter() + store := db.NewEventReader(log, dbx, passphrase) + + contractID := xdr.ContractId([32]byte{}) + var txMeta []xdr.TransactionMeta + for i := range 10 { + number := xdr.Uint64(i) + txMeta = append(txMeta, transactionMetaWithEvents( + contractEvent( + contractID, + xdr.ScVec{ + xdr.ScVal{Type: xdr.ScValTypeScvSymbol, Sym: &counter}, + }, + xdr.ScVal{Type: xdr.ScValTypeScvU64, U64: &number}, + ), + )) + } + ledgerCloseMeta := ledgerCloseMetaWithEvents(1, now.Unix(), txMeta...) + require.NoError(t, ledgerW.InsertLedger(ledgerCloseMeta), "ingestion failed for ledger") + require.NoError(t, eventW.InsertEvents(ledgerCloseMeta), "ingestion failed for events") + require.NoError(t, write.Commit(ledgerCloseMeta, nil)) + + handler := eventsRPCHandler{ + dbReader: store, + maxLimit: 10000, + defaultLimit: 100, + ledgerReader: db.NewLedgerReader(dbx), + } + + // Test descending order with limit returns the N newest events + results, err := handler.getEvents(ctx, protocol.GetEventsRequest{ + StartLedger: 1, + Pagination: &protocol.PaginationOptions{ + Limit: 3, + Order: protocol.EventOrderDesc, + }, + }) + require.NoError(t, err) + require.Len(t, results.Events, 3) + + // Should return the 3 newest events (tx 10, 9, 8 in that order) + assert.Equal(t, uint32(10), results.Events[0].TxIndex) + assert.Equal(t, uint32(9), results.Events[1].TxIndex) + assert.Equal(t, uint32(8), results.Events[2].TxIndex) + }) + + t.Run("invalid order parameter", func(t *testing.T) { + dbx := newTestDB(t) + log := log.DefaultLogger + log.SetLevel(logrus.TraceLevel) + store := db.NewEventReader(log, dbx, passphrase) + + handler := eventsRPCHandler{ + dbReader: store, + maxLimit: 10000, + defaultLimit: 100, + ledgerReader: db.NewLedgerReader(dbx), + } + + _, err := handler.getEvents(context.TODO(), protocol.GetEventsRequest{ + StartLedger: 1, + Pagination: &protocol.PaginationOptions{ + Order: "invalid", + }, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "order must be 'asc' or 'desc'") + }) + + t.Run("ascending order explicitly set", func(t *testing.T) { + dbx := newTestDB(t) + ctx := context.TODO() + log := log.DefaultLogger + log.SetLevel(logrus.TraceLevel) + + writer := db.NewReadWriter(log, dbx, interfaces.MakeNoOpDeamon(), 10, 10, passphrase) + write, err := writer.NewTx(ctx) + require.NoError(t, err) + + ledgerW, eventW := write.LedgerWriter(), write.EventWriter() + store := db.NewEventReader(log, dbx, passphrase) + + contractID := xdr.ContractId([32]byte{}) + var txMeta []xdr.TransactionMeta + for i := range 5 { + number := xdr.Uint64(i) + txMeta = append(txMeta, transactionMetaWithEvents( + contractEvent( + contractID, + xdr.ScVec{ + xdr.ScVal{Type: xdr.ScValTypeScvSymbol, Sym: &counter}, + }, + xdr.ScVal{Type: xdr.ScValTypeScvU64, U64: &number}, + ), + )) + } + ledgerCloseMeta := ledgerCloseMetaWithEvents(1, now.Unix(), txMeta...) + require.NoError(t, ledgerW.InsertLedger(ledgerCloseMeta), "ingestion failed for ledger") + require.NoError(t, eventW.InsertEvents(ledgerCloseMeta), "ingestion failed for events") + require.NoError(t, write.Commit(ledgerCloseMeta, nil)) + + handler := eventsRPCHandler{ + dbReader: store, + maxLimit: 10000, + defaultLimit: 100, + ledgerReader: db.NewLedgerReader(dbx), + } + + // Test explicitly set ascending order + results, err := handler.getEvents(ctx, protocol.GetEventsRequest{ + StartLedger: 1, + Pagination: &protocol.PaginationOptions{ + Order: protocol.EventOrderAsc, + }, + }) + require.NoError(t, err) + require.Len(t, results.Events, 5) + + // Verify events are returned in ascending order (tx 1, 2, 3, 4, 5) + for i, event := range results.Events { + expectedTxIndex := uint32(i + 1) + assert.Equal(t, expectedTxIndex, event.TxIndex, + "event %d should have TxIndex %d", i, expectedTxIndex) + } + }) } func BenchmarkGetEvents(b *testing.B) { diff --git a/go.mod b/go.mod index 1e45d0721..1a8e25907 100644 --- a/go.mod +++ b/go.mod @@ -129,14 +129,14 @@ require ( go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.43.0 // indirect + golang.org/x/crypto v0.45.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/mod v0.29.0 - golang.org/x/net v0.46.0 // indirect + golang.org/x/net v0.47.0 // indirect golang.org/x/oauth2 v0.32.0 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.37.0 // indirect - golang.org/x/text v0.30.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/api v0.254.0 // indirect google.golang.org/genproto v0.0.0-20251029180050-ab9386a59fda // indirect @@ -150,3 +150,5 @@ require ( gopkg.in/tylerb/graceful.v1 v1.2.15 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +replace github.com/stellar/go-stellar-sdk => ../go-stellar-sdk diff --git a/go.sum b/go.sum index 4162620fd..284f62985 100644 --- a/go.sum +++ b/go.sum @@ -429,8 +429,6 @@ github.com/spf13/viper v1.17.0 h1:I5txKw7MJasPL/BrfkbA0Jyo/oELqVmux4pR/UxOMfI= github.com/spf13/viper v1.17.0/go.mod h1:BmMMMLQXSbcHK6KAOiFLz0l5JHrU89OdIRHvsk0+yVI= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= -github.com/stellar/go-stellar-sdk v0.0.0-20251208182759-7568ee53f4fd h1:90j0mtcO8J6v2v/EVgvyfK+L1WlLW9Fg2Vn4/zAv/0Q= -github.com/stellar/go-stellar-sdk v0.0.0-20251208182759-7568ee53f4fd/go.mod h1:fZPcxQZw1I0zZ+X76uFcVPqmQCaYbWc87lDFW/kQJaY= github.com/stellar/go-xdr v0.0.0-20231122183749-b53fb00bcac2 h1:OzCVd0SV5qE3ZcDeSFCmOWLZfEWZ3Oe8KtmSOYKEVWE= github.com/stellar/go-xdr v0.0.0-20231122183749-b53fb00bcac2/go.mod h1:yoxyU/M8nl9LKeWIoBrbDPQ7Cy+4jxRcWcOayZ4BMps= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -515,8 +513,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= -golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -587,8 +585,8 @@ golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= -golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -610,8 +608,8 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -650,11 +648,11 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= -golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -663,8 +661,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= From eae62a91f3accffb2e35311fc01c32dfb94cf288 Mon Sep 17 00:00:00 2001 From: Tomer Weller Date: Sun, 21 Dec 2025 16:06:52 -0500 Subject: [PATCH 2/2] Fix startLedger semantics for DESC order For DESC order, startLedger now acts as the upper bound (where we start scanning backwards from) and endLedger acts as the lower bound. This makes DESC order intuitive for 'get N most recent events' use case: - Set startLedger to the maximum ledger you want - Results will include events up to and including startLedger - Events are returned newest first For ASC order (default), behavior is unchanged: - startLedger is the lower bound (where we start scanning forwards from) - endLedger is the upper bound --- .../internal/methods/get_events.go | 93 ++++++++++++------- 1 file changed, 60 insertions(+), 33 deletions(-) diff --git a/cmd/stellar-rpc/internal/methods/get_events.go b/cmd/stellar-rpc/internal/methods/get_events.go index 38987a9aa..e88e1e2ae 100644 --- a/cmd/stellar-rpc/internal/methods/get_events.go +++ b/cmd/stellar-rpc/internal/methods/get_events.go @@ -123,40 +123,69 @@ func (h eventsRPCHandler) getEvents(ctx context.Context, request protocol.GetEve } isDescending := order == protocol.EventOrderDesc - start := protocol.Cursor{Ledger: request.StartLedger} limit := h.defaultLimit - if request.Pagination != nil { - if request.Pagination.Cursor != nil { - start = *request.Pagination.Cursor - // Adjust cursor for pagination based on order direction - if isDescending { - // For descending order, we move backwards from the cursor - if start.Event > 0 { - start.Event-- - } else { - // Need to move to previous tx/op/ledger - start = decrementCursor(start) - } + if request.Pagination != nil && request.Pagination.Limit > 0 { + limit = request.Pagination.Limit + } + + // Build cursor range based on order direction + // For ASC: startLedger is lower bound, endLedger is upper bound + // For DESC: startLedger is upper bound, endLedger is lower bound + var cursorRange protocol.CursorRange + var validationLedger uint32 // The ledger to validate against retention window + + if isDescending { + // DESC order: startLedger is upper bound, scan backwards + // Calculate lower bound + lowerBound := uint32(0) + if request.StartLedger > LedgerScanLimit { + lowerBound = request.StartLedger - LedgerScanLimit + } + // lowerBound should not be before ledger retention window + lowerBound = max(ledgerRange.FirstLedger.Sequence, lowerBound) + if request.EndLedger != 0 { + lowerBound = max(request.EndLedger, lowerBound) + } + + // Handle cursor-based pagination for DESC + upperCursor := protocol.Cursor{Ledger: request.StartLedger + 1} // +1 because end is exclusive + if request.Pagination != nil && request.Pagination.Cursor != nil { + upperCursor = *request.Pagination.Cursor + // For descending order, we move backwards from the cursor + if upperCursor.Event > 0 { + upperCursor.Event-- } else { - // For ascending order, we move forward from the cursor - start.Event++ + upperCursor = decrementCursor(upperCursor) } } - if request.Pagination.Limit > 0 { - limit = request.Pagination.Limit + + cursorRange = protocol.CursorRange{ + Start: protocol.Cursor{Ledger: lowerBound}, + End: upperCursor, + } + validationLedger = request.StartLedger + } else { + // ASC order: startLedger is lower bound, scan forwards (original behavior) + start := protocol.Cursor{Ledger: request.StartLedger} + if request.Pagination != nil && request.Pagination.Cursor != nil { + start = *request.Pagination.Cursor + start.Event++ } - } - endLedger := start.Ledger + LedgerScanLimit - // endLedger should not exceed ledger retention window - endLedger = min(ledgerRange.LastLedger.Sequence+1, endLedger) - if request.EndLedger != 0 { - endLedger = min(request.EndLedger, endLedger) - } - end := protocol.Cursor{Ledger: endLedger} - cursorRange := protocol.CursorRange{Start: start, End: end} + endLedger := start.Ledger + LedgerScanLimit + endLedger = min(ledgerRange.LastLedger.Sequence+1, endLedger) + if request.EndLedger != 0 { + endLedger = min(request.EndLedger, endLedger) + } + + cursorRange = protocol.CursorRange{ + Start: start, + End: protocol.Cursor{Ledger: endLedger}, + } + validationLedger = request.StartLedger + } - if start.Ledger < ledgerRange.FirstLedger.Sequence || start.Ledger > ledgerRange.LastLedger.Sequence { + if validationLedger < ledgerRange.FirstLedger.Sequence || validationLedger > ledgerRange.LastLedger.Sequence { return protocol.GetEventsResponse{}, &jrpc2.Error{ Code: jrpc2.InvalidRequest, Message: fmt.Sprintf( @@ -229,15 +258,13 @@ func (h eventsRPCHandler) getEvents(ctx context.Context, request protocol.GetEve cursor = lastEvent.ID } else { // cursor represents end of the search window if events does not reach limit - // here endLedger is always exclusive when fetching events - // so search window is max Cursor value with endLedger - 1 if isDescending { - // For descending order, the cursor represents the start of the search window - minCursor := protocol.Cursor{Ledger: start.Ledger} - cursor = minCursor.String() + // For descending order, the cursor represents the lower bound of the search window + cursor = cursorRange.Start.String() } else { + // For ascending order, the cursor represents the upper bound of the search window maxCursor := protocol.MaxCursor - maxCursor.Ledger = endLedger - 1 + maxCursor.Ledger = cursorRange.End.Ledger - 1 cursor = maxCursor.String() } }