diff --git a/cmd/soroban-rpc/internal/config/config.go b/cmd/soroban-rpc/internal/config/config.go index 5d753b45..b522e424 100644 --- a/cmd/soroban-rpc/internal/config/config.go +++ b/cmd/soroban-rpc/internal/config/config.go @@ -25,6 +25,7 @@ type Config struct { CheckpointFrequency uint32 CoreRequestTimeout time.Duration DefaultEventsLimit uint + DefaultTransactionsLimit uint EventLedgerRetentionWindow uint32 FriendbotURL string HistoryArchiveURLs []string @@ -33,6 +34,7 @@ type Config struct { LogFormat LogFormat LogLevel logrus.Level MaxEventsLimit uint + MaxTransactionsLimit uint MaxHealthyLedgerLatency time.Duration NetworkPassphrase string PreflightWorkerCount uint @@ -48,6 +50,7 @@ type Config struct { RequestBacklogGetLatestLedgerQueueLimit uint RequestBacklogGetLedgerEntriesQueueLimit uint RequestBacklogGetTransactionQueueLimit uint + RequestBacklogGetTransactionsQueueLimit uint RequestBacklogSendTransactionQueueLimit uint RequestBacklogSimulateTransactionQueueLimit uint RequestExecutionWarningThreshold time.Duration @@ -59,6 +62,7 @@ type Config struct { MaxGetLatestLedgerExecutionDuration time.Duration MaxGetLedgerEntriesExecutionDuration time.Duration MaxGetTransactionExecutionDuration time.Duration + MaxGetTransactionsExecutionDuration time.Duration MaxSendTransactionExecutionDuration time.Duration MaxSimulateTransactionExecutionDuration time.Duration diff --git a/cmd/soroban-rpc/internal/config/options.go b/cmd/soroban-rpc/internal/config/options.go index 77443c24..20370012 100644 --- a/cmd/soroban-rpc/internal/config/options.go +++ b/cmd/soroban-rpc/internal/config/options.go @@ -251,6 +251,28 @@ func (cfg *Config) options() ConfigOptions { return nil }, }, + { + Name: "max-transactions-limit", + Usage: "Maximum amount of transactions allowed in a single getTransactions response", + ConfigKey: &cfg.MaxTransactionsLimit, + DefaultValue: uint(200), + }, + { + Name: "default-transactions-limit", + Usage: "Default cap on the amount of transactions included in a single getTransactions response", + ConfigKey: &cfg.DefaultTransactionsLimit, + DefaultValue: uint(50), + Validate: func(co *ConfigOption) error { + if cfg.DefaultTransactionsLimit > cfg.MaxTransactionsLimit { + return fmt.Errorf( + "default-transactions-limit (%v) cannot exceed max-transactions-limit (%v)", + cfg.DefaultTransactionsLimit, + cfg.MaxTransactionsLimit, + ) + } + return nil + }, + }, { Name: "max-healthy-ledger-latency", Usage: "maximum ledger latency (i.e. time elapsed since the last known ledger closing time) considered to be healthy" + @@ -334,6 +356,13 @@ func (cfg *Config) options() ConfigOptions { DefaultValue: uint(1000), Validate: positive, }, + { + TomlKey: strutils.KebabToConstantCase("request-backlog-get-transactions-queue-limit"), + Usage: "Maximum number of outstanding GetTransactions requests", + ConfigKey: &cfg.RequestBacklogGetTransactionsQueueLimit, + DefaultValue: uint(1000), + Validate: positive, + }, { TomlKey: strutils.KebabToConstantCase("request-backlog-send-transaction-queue-limit"), Usage: "Maximum number of outstanding SendTransaction requests", @@ -402,6 +431,12 @@ func (cfg *Config) options() ConfigOptions { ConfigKey: &cfg.MaxGetTransactionExecutionDuration, DefaultValue: 5 * time.Second, }, + { + TomlKey: strutils.KebabToConstantCase("max-get-transactions-execution-duration"), + Usage: "The maximum duration of time allowed for processing a getTransactions request. When that time elapses, the rpc server would return -32001 and abort the request's execution", + ConfigKey: &cfg.MaxGetTransactionsExecutionDuration, + DefaultValue: 5 * time.Second, + }, { TomlKey: strutils.KebabToConstantCase("max-send-transaction-execution-duration"), Usage: "The maximum duration of time allowed for processing a sendTransaction request. When that time elapses, the rpc server would return -32001 and abort the request's execution", diff --git a/cmd/soroban-rpc/internal/db/mock_transaction.go b/cmd/soroban-rpc/internal/db/mocks.go similarity index 63% rename from cmd/soroban-rpc/internal/db/mock_transaction.go rename to cmd/soroban-rpc/internal/db/mocks.go index 4cfb4a63..d2dfba4f 100644 --- a/cmd/soroban-rpc/internal/db/mock_transaction.go +++ b/cmd/soroban-rpc/internal/db/mocks.go @@ -15,20 +15,24 @@ import ( type mockTransactionHandler struct { passphrase string - ledgerRange ledgerbucketwindow.LedgerRange - txs map[string]ingest.LedgerTransaction - ledgers map[string]*xdr.LedgerCloseMeta + ledgerRange ledgerbucketwindow.LedgerRange + txs map[string]ingest.LedgerTransaction + txHashToMeta map[string]*xdr.LedgerCloseMeta + ledgerSeqToMeta map[uint32]*xdr.LedgerCloseMeta } func NewMockTransactionStore(passphrase string) *mockTransactionHandler { return &mockTransactionHandler{ - passphrase: passphrase, - txs: make(map[string]ingest.LedgerTransaction), - ledgers: make(map[string]*xdr.LedgerCloseMeta), + passphrase: passphrase, + txs: make(map[string]ingest.LedgerTransaction), + txHashToMeta: make(map[string]*xdr.LedgerCloseMeta), + ledgerSeqToMeta: make(map[uint32]*xdr.LedgerCloseMeta), } } func (txn *mockTransactionHandler) InsertTransactions(lcm xdr.LedgerCloseMeta) error { + txn.ledgerSeqToMeta[lcm.LedgerSequence()] = &lcm + reader, err := ingest.NewLedgerTransactionReaderFromLedgerCloseMeta(txn.passphrase, lcm) if err != nil { return err @@ -44,7 +48,7 @@ func (txn *mockTransactionHandler) InsertTransactions(lcm xdr.LedgerCloseMeta) e h := tx.Result.TransactionHash.HexString() txn.txs[h] = tx - txn.ledgers[h] = &lcm + txn.txHashToMeta[h] = &lcm } if lcmSeq := lcm.LedgerSequence(); lcmSeq < txn.ledgerRange.FirstLedger.Sequence || @@ -72,12 +76,35 @@ func (txn *mockTransactionHandler) GetTransaction(ctx context.Context, hash xdr. if tx, ok := txn.txs[hash.HexString()]; !ok { return Transaction{}, txn.ledgerRange, ErrNoTransaction } else { - itx, err := ParseTransaction(*txn.ledgers[hash.HexString()], tx) + itx, err := ParseTransaction(*txn.txHashToMeta[hash.HexString()], tx) return itx, txn.ledgerRange, err } } func (txn *mockTransactionHandler) RegisterMetrics(_, _ prometheus.Observer) {} +type mockLedgerReader struct { + txn mockTransactionHandler +} + +func NewMockLedgerReader(txn *mockTransactionHandler) *mockLedgerReader { + return &mockLedgerReader{ + txn: *txn, + } +} + +func (m *mockLedgerReader) GetLedger(ctx context.Context, sequence uint32) (xdr.LedgerCloseMeta, bool, error) { + lcm, ok := m.txn.ledgerSeqToMeta[sequence] + if !ok { + return xdr.LedgerCloseMeta{}, false, nil + } + return *lcm, true, nil +} + +func (m *mockLedgerReader) StreamAllLedgers(ctx context.Context, f StreamLedgerFn) error { + return nil +} + var _ TransactionReader = &mockTransactionHandler{} var _ TransactionWriter = &mockTransactionHandler{} +var _ LedgerReader = &mockLedgerReader{} diff --git a/cmd/soroban-rpc/internal/jsonrpc.go b/cmd/soroban-rpc/internal/jsonrpc.go index 6fb392ec..6529e79b 100644 --- a/cmd/soroban-rpc/internal/jsonrpc.go +++ b/cmd/soroban-rpc/internal/jsonrpc.go @@ -204,6 +204,13 @@ func NewJSONRPCHandler(cfg *config.Config, params HandlerParams) Handler { queueLimit: cfg.RequestBacklogGetTransactionQueueLimit, requestDurationLimit: cfg.MaxGetTransactionExecutionDuration, }, + { + methodName: "getTransactions", + underlyingHandler: methods.NewGetTransactionsHandler(params.Logger, params.LedgerReader, params.TransactionReader, cfg.MaxTransactionsLimit, cfg.DefaultTransactionsLimit, cfg.NetworkPassphrase), + longName: "get_transactions", + queueLimit: cfg.RequestBacklogGetTransactionsQueueLimit, + requestDurationLimit: cfg.MaxGetTransactionsExecutionDuration, + }, { methodName: "sendTransaction", underlyingHandler: methods.NewSendTransactionHandler( diff --git a/cmd/soroban-rpc/internal/methods/get_transactions.go b/cmd/soroban-rpc/internal/methods/get_transactions.go new file mode 100644 index 00000000..f8c38dd8 --- /dev/null +++ b/cmd/soroban-rpc/internal/methods/get_transactions.go @@ -0,0 +1,241 @@ +package methods + +import ( + "context" + "encoding/base64" + "fmt" + "io" + "strconv" + + "github.com/creachadair/jrpc2" + "github.com/creachadair/jrpc2/handler" + "github.com/stellar/go/ingest" + "github.com/stellar/go/support/errors" + "github.com/stellar/go/support/log" + "github.com/stellar/go/toid" + + "github.com/stellar/soroban-rpc/cmd/soroban-rpc/internal/db" + "github.com/stellar/soroban-rpc/cmd/soroban-rpc/internal/ledgerbucketwindow" +) + +// TransactionsPaginationOptions defines the available options for paginating through transactions. +type TransactionsPaginationOptions struct { + Cursor string `json:"cursor,omitempty"` + Limit uint `json:"limit,omitempty"` +} + +// GetTransactionsRequest represents the request parameters for fetching transactions within a range of ledgers. +type GetTransactionsRequest struct { + StartLedger uint32 `json:"startLedger"` + Pagination *TransactionsPaginationOptions `json:"pagination,omitempty"` +} + +// isValid checks the validity of the request parameters. +func (req GetTransactionsRequest) isValid(maxLimit uint, ledgerRange ledgerbucketwindow.LedgerRange) error { + if req.Pagination != nil && req.Pagination.Cursor != "" { + if req.StartLedger != 0 { + return errors.New("startLedger and cursor cannot both be set") + } + } else if req.StartLedger < ledgerRange.FirstLedger.Sequence || req.StartLedger > ledgerRange.LastLedger.Sequence { + return errors.Errorf("start ledger must be between the oldest ledger: %d and the latest ledger: %d for this rpc instance.", ledgerRange.FirstLedger.Sequence, ledgerRange.LastLedger.Sequence) + } + + if req.Pagination != nil && req.Pagination.Limit > maxLimit { + return fmt.Errorf("limit must not exceed %d", maxLimit) + } + + return nil +} + +type TransactionInfo struct { + // Successful indicates whether the transaction was successful or not + Successful bool `json:"status"` + // ApplicationOrder is the index of the transaction among all the transactions + // for that ledger. + ApplicationOrder int32 `json:"applicationOrder"` + // FeeBump indicates whether the transaction is a feebump transaction + FeeBump bool `json:"feeBump"` + // EnvelopeXdr is the TransactionEnvelope XDR value. + EnvelopeXdr string `json:"envelopeXdr"` + // ResultXdr is the TransactionResult XDR value. + ResultXdr string `json:"resultXdr"` + // ResultMetaXdr is the TransactionMeta XDR value. + ResultMetaXdr string `json:"resultMetaXdr"` + // DiagnosticEventsXDR is present only if transaction was not successful. + // DiagnosticEventsXDR is a base64-encoded slice of xdr.DiagnosticEvent + DiagnosticEventsXDR []string `json:"diagnosticEventsXdr,omitempty"` + // Ledger is the sequence of the ledger which included the transaction. + Ledger uint32 `json:"ledger"` + // LedgerCloseTime is the unix timestamp of when the transaction was included in the ledger. + LedgerCloseTime int64 `json:"createdAt"` +} + +// GetTransactionsResponse encapsulates the response structure for getTransactions queries. +type GetTransactionsResponse struct { + Transactions []TransactionInfo `json:"transactions"` + LatestLedger uint32 `json:"latestLedger"` + LatestLedgerCloseTime int64 `json:"latestLedgerCloseTimestamp"` + OldestLedger uint32 `json:"oldestLedger"` + OldestLedgerCloseTime int64 `json:"oldestLedgerCloseTimestamp"` + Cursor string `json:"cursor"` +} + +type transactionsRPCHandler struct { + ledgerReader db.LedgerReader + dbReader db.TransactionReader + maxLimit uint + defaultLimit uint + logger *log.Entry + networkPassphrase string +} + +// getTransactionsByLedgerSequence fetches transactions between the start and end ledgers, inclusive of both. +// The number of ledgers returned can be tuned using the pagination options - cursor and limit. +func (h transactionsRPCHandler) getTransactionsByLedgerSequence(ctx context.Context, request GetTransactionsRequest) (GetTransactionsResponse, error) { + ledgerRange, err := h.dbReader.GetLedgerRange(ctx) + if err != nil { + return GetTransactionsResponse{}, &jrpc2.Error{ + Code: jrpc2.InternalError, + Message: err.Error(), + } + } + + err = request.isValid(h.maxLimit, ledgerRange) + if err != nil { + return GetTransactionsResponse{}, &jrpc2.Error{ + Code: jrpc2.InvalidRequest, + Message: err.Error(), + } + } + + // Move start to pagination cursor + start := toid.New(int32(request.StartLedger), 1, 1) + limit := h.defaultLimit + if request.Pagination != nil { + if request.Pagination.Cursor != "" { + cursorInt, err := strconv.ParseInt(request.Pagination.Cursor, 10, 64) + if err != nil { + return GetTransactionsResponse{}, &jrpc2.Error{ + Code: jrpc2.InvalidParams, + Message: err.Error(), + } + } + + *start = toid.Parse(cursorInt) + // increment tx index because, when paginating, + // we start with the item right after the cursor + start.TransactionOrder++ + } + if request.Pagination.Limit > 0 { + limit = request.Pagination.Limit + } + } + + // Iterate through each ledger and its transactions until limit or end range is reached. + // The latest ledger acts as the end ledger range for the request. + var txns []TransactionInfo + var cursor *toid.ID +LedgerLoop: + for ledgerSeq := start.LedgerSequence; ledgerSeq <= int32(ledgerRange.LastLedger.Sequence); ledgerSeq++ { + // Get ledger close meta from db + ledger, found, err := h.ledgerReader.GetLedger(ctx, uint32(ledgerSeq)) + if err != nil { + return GetTransactionsResponse{}, &jrpc2.Error{ + Code: jrpc2.InternalError, + Message: err.Error(), + } + } else if !found { + return GetTransactionsResponse{}, &jrpc2.Error{ + Code: jrpc2.InvalidParams, + Message: errors.Errorf("ledger close meta not found: %d", ledgerSeq).Error(), + } + } + + // Initialise tx reader. + reader, err := ingest.NewLedgerTransactionReaderFromLedgerCloseMeta(h.networkPassphrase, ledger) + if err != nil { + return GetTransactionsResponse{}, &jrpc2.Error{ + Code: jrpc2.InternalError, + Message: err.Error(), + } + } + + // Move the reader to specific tx idx + startTxIdx := 1 + if ledgerSeq == start.LedgerSequence { + startTxIdx = int(start.TransactionOrder) + if ierr := reader.Seek(startTxIdx - 1); ierr != nil && ierr != io.EOF { + return GetTransactionsResponse{}, &jrpc2.Error{ + Code: jrpc2.InternalError, + Message: err.Error(), + } + } + } + + // Decode transaction info from ledger meta + txCount := ledger.CountTransactions() + for i := startTxIdx; i <= txCount; i++ { + cursor = toid.New(int32(ledger.LedgerSequence()), int32(i), 1) + + ingestTx, err := reader.Read() + if err != nil { + if err == io.EOF { + // No more transactions to read. Start from next ledger + break + } + return GetTransactionsResponse{}, &jrpc2.Error{ + Code: jrpc2.InvalidParams, + Message: err.Error(), + } + } + + tx, err := db.ParseTransaction(ledger, ingestTx) + if err != nil { + return GetTransactionsResponse{}, &jrpc2.Error{ + Code: jrpc2.InternalError, + Message: err.Error(), + } + } + + txInfo := TransactionInfo{ + Successful: tx.Successful, + ApplicationOrder: tx.ApplicationOrder, + FeeBump: tx.FeeBump, + ResultXdr: base64.StdEncoding.EncodeToString(tx.Result), + ResultMetaXdr: base64.StdEncoding.EncodeToString(tx.Meta), + EnvelopeXdr: base64.StdEncoding.EncodeToString(tx.Envelope), + DiagnosticEventsXDR: base64EncodeSlice(tx.Events), + Ledger: tx.Ledger.Sequence, + LedgerCloseTime: tx.Ledger.CloseTime, + } + txns = append(txns, txInfo) + if len(txns) >= int(limit) { + break LedgerLoop + } + } + } + + return GetTransactionsResponse{ + Transactions: txns, + LatestLedger: ledgerRange.LastLedger.Sequence, + LatestLedgerCloseTime: ledgerRange.LastLedger.CloseTime, + OldestLedger: ledgerRange.FirstLedger.Sequence, + OldestLedgerCloseTime: ledgerRange.FirstLedger.CloseTime, + Cursor: cursor.String(), + }, nil +} + +func NewGetTransactionsHandler(logger *log.Entry, ledgerReader db.LedgerReader, dbReader db.TransactionReader, maxLimit, defaultLimit uint, networkPassphrase string) jrpc2.Handler { + transactionsHandler := transactionsRPCHandler{ + ledgerReader: ledgerReader, + dbReader: dbReader, + maxLimit: maxLimit, + defaultLimit: defaultLimit, + logger: logger, + networkPassphrase: networkPassphrase, + } + + return handler.New(func(context context.Context, request GetTransactionsRequest) (GetTransactionsResponse, error) { + return transactionsHandler.getTransactionsByLedgerSequence(context, request) + }) +} diff --git a/cmd/soroban-rpc/internal/methods/get_transactions_test.go b/cmd/soroban-rpc/internal/methods/get_transactions_test.go new file mode 100644 index 00000000..f6f7fea2 --- /dev/null +++ b/cmd/soroban-rpc/internal/methods/get_transactions_test.go @@ -0,0 +1,302 @@ +package methods + +import ( + "context" + "testing" + + "github.com/creachadair/jrpc2" + "github.com/stellar/go/support/errors" + "github.com/stellar/go/toid" + "github.com/stellar/go/xdr" + "github.com/stretchr/testify/assert" + + "github.com/stellar/soroban-rpc/cmd/soroban-rpc/internal/db" +) + +const ( + NetworkPassphrase string = "passphrase" +) + +// createTestLedger Creates a test ledger with 2 transactions +func createTestLedger(sequence uint32) xdr.LedgerCloseMeta { + sequence = sequence - 100 + meta := txMeta(sequence, true) + meta.V1.TxProcessing = append(meta.V1.TxProcessing, xdr.TransactionResultMeta{ + TxApplyProcessing: xdr.TransactionMeta{ + V: 3, + Operations: &[]xdr.OperationMeta{}, + V3: &xdr.TransactionMetaV3{}, + }, + Result: xdr.TransactionResultPair{ + TransactionHash: txHash(sequence), + Result: transactionResult(false), + }, + }) + return meta +} + +func TestGetTransactions_DefaultLimit(t *testing.T) { + mockDbReader := db.NewMockTransactionStore(NetworkPassphrase) + mockLedgerReader := db.NewMockLedgerReader(mockDbReader) + for i := 1; i <= 10; i++ { + meta := createTestLedger(uint32(i)) + err := mockDbReader.InsertTransactions(meta) + assert.NoError(t, err) + } + + handler := transactionsRPCHandler{ + ledgerReader: mockLedgerReader, + dbReader: mockDbReader, + maxLimit: 100, + defaultLimit: 10, + networkPassphrase: NetworkPassphrase, + } + + request := GetTransactionsRequest{ + StartLedger: 1, + } + + response, err := handler.getTransactionsByLedgerSequence(context.TODO(), request) + assert.NoError(t, err) + + // assert latest ledger details + assert.Equal(t, uint32(10), response.LatestLedger) + assert.Equal(t, int64(350), response.LatestLedgerCloseTime) + + // assert pagination + assert.Equal(t, toid.New(5, 2, 1).String(), response.Cursor) + + // assert transactions result + assert.Equal(t, 10, len(response.Transactions)) +} + +func TestGetTransactions_DefaultLimitExceedsLatestLedger(t *testing.T) { + mockDbReader := db.NewMockTransactionStore(NetworkPassphrase) + mockLedgerReader := db.NewMockLedgerReader(mockDbReader) + for i := 1; i <= 3; i++ { + meta := createTestLedger(uint32(i)) + err := mockDbReader.InsertTransactions(meta) + assert.NoError(t, err) + } + + handler := transactionsRPCHandler{ + ledgerReader: mockLedgerReader, + dbReader: mockDbReader, + maxLimit: 100, + defaultLimit: 10, + networkPassphrase: NetworkPassphrase, + } + + request := GetTransactionsRequest{ + StartLedger: 1, + } + + response, err := handler.getTransactionsByLedgerSequence(context.TODO(), request) + assert.NoError(t, err) + + // assert latest ledger details + assert.Equal(t, uint32(3), response.LatestLedger) + assert.Equal(t, int64(175), response.LatestLedgerCloseTime) + + // assert pagination + assert.Equal(t, toid.New(3, 2, 1).String(), response.Cursor) + + // assert transactions result + assert.Equal(t, 6, len(response.Transactions)) +} + +func TestGetTransactions_CustomLimit(t *testing.T) { + mockDbReader := db.NewMockTransactionStore(NetworkPassphrase) + mockLedgerReader := db.NewMockLedgerReader(mockDbReader) + for i := 1; i <= 10; i++ { + meta := createTestLedger(uint32(i)) + err := mockDbReader.InsertTransactions(meta) + assert.NoError(t, err) + } + + handler := transactionsRPCHandler{ + ledgerReader: mockLedgerReader, + dbReader: mockDbReader, + maxLimit: 100, + defaultLimit: 10, + networkPassphrase: NetworkPassphrase, + } + + request := GetTransactionsRequest{ + StartLedger: 1, + Pagination: &TransactionsPaginationOptions{ + Limit: 2, + }, + } + + response, err := handler.getTransactionsByLedgerSequence(context.TODO(), request) + assert.NoError(t, err) + + // assert latest ledger details + assert.Equal(t, uint32(10), response.LatestLedger) + assert.Equal(t, int64(350), response.LatestLedgerCloseTime) + + // assert pagination + assert.Equal(t, toid.New(1, 2, 1).String(), response.Cursor) + + // assert transactions result + assert.Equal(t, 2, len(response.Transactions)) + assert.Equal(t, uint32(1), response.Transactions[0].Ledger) + assert.Equal(t, uint32(1), response.Transactions[1].Ledger) +} + +func TestGetTransactions_CustomLimitAndCursor(t *testing.T) { + mockDbReader := db.NewMockTransactionStore(NetworkPassphrase) + mockLedgerReader := db.NewMockLedgerReader(mockDbReader) + for i := 1; i <= 10; i++ { + meta := createTestLedger(uint32(i)) + err := mockDbReader.InsertTransactions(meta) + assert.NoError(t, err) + } + + handler := transactionsRPCHandler{ + ledgerReader: mockLedgerReader, + dbReader: mockDbReader, + maxLimit: 100, + defaultLimit: 10, + networkPassphrase: NetworkPassphrase, + } + + request := GetTransactionsRequest{ + Pagination: &TransactionsPaginationOptions{ + Cursor: toid.New(1, 2, 1).String(), + Limit: 3, + }, + } + + response, err := handler.getTransactionsByLedgerSequence(context.TODO(), request) + assert.NoError(t, err) + + // assert latest ledger details + assert.Equal(t, uint32(10), response.LatestLedger) + assert.Equal(t, int64(350), response.LatestLedgerCloseTime) + + // assert pagination + assert.Equal(t, toid.New(3, 1, 1).String(), response.Cursor) + + // assert transactions result + assert.Equal(t, 3, len(response.Transactions)) + assert.Equal(t, uint32(2), response.Transactions[0].Ledger) + assert.Equal(t, uint32(2), response.Transactions[1].Ledger) + assert.Equal(t, uint32(3), response.Transactions[2].Ledger) +} + +func TestGetTransactions_InvalidStartLedger(t *testing.T) { + mockDbReader := db.NewMockTransactionStore(NetworkPassphrase) + mockLedgerReader := db.NewMockLedgerReader(mockDbReader) + for i := 1; i <= 3; i++ { + meta := createTestLedger(uint32(i)) + err := mockDbReader.InsertTransactions(meta) + assert.NoError(t, err) + } + + handler := transactionsRPCHandler{ + ledgerReader: mockLedgerReader, + dbReader: mockDbReader, + maxLimit: 100, + defaultLimit: 10, + networkPassphrase: NetworkPassphrase, + } + + request := GetTransactionsRequest{ + StartLedger: 4, + } + + response, err := handler.getTransactionsByLedgerSequence(context.TODO(), request) + expectedErr := errors.Errorf("[%d] start ledger must be between the oldest ledger: 1 and the latest ledger: 3 for this rpc instance.", jrpc2.InvalidRequest) + assert.Equal(t, expectedErr.Error(), err.Error()) + assert.Nil(t, response.Transactions) +} + +func TestGetTransactions_LedgerNotFound(t *testing.T) { + mockDbReader := db.NewMockTransactionStore(NetworkPassphrase) + mockLedgerReader := db.NewMockLedgerReader(mockDbReader) + for i := 1; i <= 3; i++ { + // Skip creation of ledger 2 + if i == 2 { + continue + } + meta := createTestLedger(uint32(i)) + err := mockDbReader.InsertTransactions(meta) + assert.NoError(t, err) + } + + handler := transactionsRPCHandler{ + ledgerReader: mockLedgerReader, + dbReader: mockDbReader, + maxLimit: 100, + defaultLimit: 10, + networkPassphrase: NetworkPassphrase, + } + + request := GetTransactionsRequest{ + StartLedger: 1, + } + + response, err := handler.getTransactionsByLedgerSequence(context.TODO(), request) + expectedErr := errors.Errorf("[%d] ledger close meta not found: 2", jrpc2.InvalidParams) + assert.Equal(t, expectedErr.Error(), err.Error()) + assert.Nil(t, response.Transactions) +} + +func TestGetTransactions_LimitGreaterThanMaxLimit(t *testing.T) { + mockDbReader := db.NewMockTransactionStore(NetworkPassphrase) + mockLedgerReader := db.NewMockLedgerReader(mockDbReader) + for i := 1; i <= 3; i++ { + meta := createTestLedger(uint32(i)) + err := mockDbReader.InsertTransactions(meta) + assert.NoError(t, err) + } + + handler := transactionsRPCHandler{ + ledgerReader: mockLedgerReader, + dbReader: mockDbReader, + maxLimit: 100, + defaultLimit: 10, + networkPassphrase: NetworkPassphrase, + } + + request := GetTransactionsRequest{ + StartLedger: 1, + Pagination: &TransactionsPaginationOptions{ + Limit: 200, + }, + } + + _, err := handler.getTransactionsByLedgerSequence(context.TODO(), request) + expectedErr := errors.Errorf("[%d] limit must not exceed 100", jrpc2.InvalidRequest) + assert.Equal(t, expectedErr.Error(), err.Error()) +} + +func TestGetTransactions_InvalidCursorString(t *testing.T) { + mockDbReader := db.NewMockTransactionStore(NetworkPassphrase) + mockLedgerReader := db.NewMockLedgerReader(mockDbReader) + for i := 1; i <= 3; i++ { + meta := createTestLedger(uint32(i)) + err := mockDbReader.InsertTransactions(meta) + assert.NoError(t, err) + } + + handler := transactionsRPCHandler{ + ledgerReader: mockLedgerReader, + dbReader: mockDbReader, + maxLimit: 100, + defaultLimit: 10, + networkPassphrase: NetworkPassphrase, + } + + request := GetTransactionsRequest{ + Pagination: &TransactionsPaginationOptions{ + Cursor: "abc", + }, + } + + _, err := handler.getTransactionsByLedgerSequence(context.TODO(), request) + expectedErr := errors.Errorf("[%d] strconv.ParseInt: parsing \"abc\": invalid syntax", jrpc2.InvalidParams) + assert.Equal(t, expectedErr.Error(), err.Error()) +} diff --git a/cmd/soroban-rpc/internal/test/get_transactions_test.go b/cmd/soroban-rpc/internal/test/get_transactions_test.go new file mode 100644 index 00000000..f3da92dd --- /dev/null +++ b/cmd/soroban-rpc/internal/test/get_transactions_test.go @@ -0,0 +1,101 @@ +package test + +import ( + "context" + "testing" + + "github.com/creachadair/jrpc2" + "github.com/creachadair/jrpc2/jhttp" + "github.com/stellar/go/keypair" + "github.com/stellar/go/txnbuild" + "github.com/stretchr/testify/assert" + + "github.com/stellar/soroban-rpc/cmd/soroban-rpc/internal/methods" +) + +// buildSetOptionsTxParams constructs the parameters necessary for creating a transaction from the given account. +// +// account - the source account from which the transaction will originate. This account provides the starting sequence number. +// +// Returns a fully populated TransactionParams structure. +func buildSetOptionsTxParams(account txnbuild.SimpleAccount) txnbuild.TransactionParams { + params := txnbuild.TransactionParams{ + SourceAccount: &account, + IncrementSequenceNum: true, + Operations: []txnbuild.Operation{ + &txnbuild.SetOptions{HomeDomain: txnbuild.NewHomeDomain("soroban.com")}, + }, + BaseFee: txnbuild.MinBaseFee, + Preconditions: txnbuild.Preconditions{TimeBounds: txnbuild.NewInfiniteTimeout()}, + } + return params +} + +// sendTransactions sends multiple transactions for testing purposes. +// It sends a total of three transactions, each from a new account sequence, and gathers the ledger +// numbers where these transactions were recorded. +// +// t - the testing framework handle for assertions. +// client - the JSON-RPC client used to send the transactions. +// +// Returns a slice of ledger numbers corresponding to where each transaction was recorded. +func sendTransactions(t *testing.T, client *jrpc2.Client) []uint32 { + kp := keypair.Root(StandaloneNetworkPassphrase) + address := kp.Address() + + var ledgers []uint32 + for i := 0; i <= 2; i++ { + account := txnbuild.NewSimpleAccount(address, int64(i)) + tx, err := txnbuild.NewTransaction(buildSetOptionsTxParams(account)) + assert.NoError(t, err) + + txResponse := sendSuccessfulTransaction(t, client, kp, tx) + ledgers = append(ledgers, txResponse.Ledger) + } + return ledgers +} + +func TestGetTransactions(t *testing.T) { + test := NewTest(t, nil) + ch := jhttp.NewChannel(test.sorobanRPCURL(), nil) + client := jrpc2.NewClient(ch, nil) + + ledgers := sendTransactions(t, client) + + // Get transactions across multiple ledgers + var result methods.GetTransactionsResponse + request := methods.GetTransactionsRequest{ + StartLedger: ledgers[0], + } + err := client.CallResult(context.Background(), "getTransactions", request, &result) + assert.NoError(t, err) + assert.Equal(t, len(result.Transactions), 3) + assert.Equal(t, result.Transactions[0].Ledger, ledgers[0]) + assert.Equal(t, result.Transactions[1].Ledger, ledgers[1]) + assert.Equal(t, result.Transactions[2].Ledger, ledgers[2]) + + // Get transactions with limit + request = methods.GetTransactionsRequest{ + StartLedger: ledgers[0], + Pagination: &methods.TransactionsPaginationOptions{ + Limit: 1, + }, + } + err = client.CallResult(context.Background(), "getTransactions", request, &result) + assert.NoError(t, err) + assert.Equal(t, len(result.Transactions), 1) + assert.Equal(t, result.Transactions[0].Ledger, ledgers[0]) + + // Get transactions using previous result's cursor + request = methods.GetTransactionsRequest{ + Pagination: &methods.TransactionsPaginationOptions{ + Cursor: result.Cursor, + Limit: 5, + }, + } + err = client.CallResult(context.Background(), "getTransactions", request, &result) + assert.NoError(t, err) + assert.Equal(t, len(result.Transactions), 2) + assert.Equal(t, result.Transactions[0].Ledger, ledgers[1]) + assert.Equal(t, result.Transactions[1].Ledger, ledgers[2]) +}