diff --git a/CHANGELOG.md b/CHANGELOG.md index bd86abb64..64aaf0af6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ ### 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)). +* `getTransactions` now returns `-32603` (internal error) instead of `-32602` (invalid params) when the node cannot parse a transaction out of its own stored ledger meta in both rpcv1 and rpcv2. Because the request parameters are valid in that case, the previous error was misleading ([#962](https://github.com/stellar/stellar-rpc/pull/962)). +* `getTransaction` and `getTransactions` now serve transactions with legacy `TransactionMeta` V0. This changes wire behavior for BOTH rpcv1 and rpcv2 (though in practice only a full-history node retains ledgers old enough to carry V0 meta). Such a transaction used to fail the request with `-32603` ("unsupported TransactionMeta version: 0"), and in `getTransactions` one V0 transaction failed the whole page. They are now returned like any other pre-Soroban transaction, with no events ([#962](https://github.com/stellar/stellar-rpc/pull/962)). ## [v28.0.1](https://github.com/stellar/stellar-rpc/compare/v28.0.0...v28.0.1) diff --git a/cmd/stellar-rpc/internal/methods/get_events_test.go b/cmd/stellar-rpc/internal/methods/get_events_test.go index 2dbd60e36..2286905fe 100644 --- a/cmd/stellar-rpc/internal/methods/get_events_test.go +++ b/cmd/stellar-rpc/internal/methods/get_events_test.go @@ -146,16 +146,16 @@ func TestGetEvents(t *testing.T) { }) require.NoError(t, err) expected = append(expected, protocol.EventInfo{ - EventType: protocol.EventTypeContract, - Ledger: 1, - LedgerClosedAt: now.Format(time.RFC3339), - ContractID: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4", - ID: id, - TopicXDR: []string{value}, - ValueXDR: value, - TransactionHash: ledgerCloseMeta.TransactionHash(i).HexString(), - OpIndex: 0, - TxIndex: uint32(i + 1), + EventType: protocol.EventTypeContract, + Ledger: 1, + LedgerClosedAt: now.Format(time.RFC3339), + ContractID: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4", + ID: id, + TopicXDR: []string{value}, + ValueXDR: value, + TransactionHash: ledgerCloseMeta.TransactionHash(i).HexString(), + OpIndex: 0, + TxIndex: uint32(i + 1), }) } cursor := protocol.MaxCursor @@ -305,16 +305,16 @@ func TestGetEvents(t *testing.T) { require.NoError(t, err) expected := []protocol.EventInfo{ { - EventType: protocol.EventTypeContract, - Ledger: 1, - LedgerClosedAt: now.Format(time.RFC3339), - ContractID: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4", - ID: id, - TopicXDR: []string{counterXdr, value}, - ValueXDR: value, - TransactionHash: ledgerCloseMeta.TransactionHash(4).HexString(), - TxIndex: 5, - OpIndex: 0, + EventType: protocol.EventTypeContract, + Ledger: 1, + LedgerClosedAt: now.Format(time.RFC3339), + ContractID: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4", + ID: id, + TopicXDR: []string{counterXdr, value}, + ValueXDR: value, + TransactionHash: ledgerCloseMeta.TransactionHash(4).HexString(), + TxIndex: 5, + OpIndex: 0, }, } @@ -496,16 +496,16 @@ func TestGetEvents(t *testing.T) { // flexible topic length matching expected := []protocol.EventInfo{ { - EventType: protocol.EventTypeContract, - Ledger: 1, - LedgerClosedAt: now.Format(time.RFC3339), - ContractID: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4", - ID: id, - TopicXDR: []string{counterXdr, value, value, counterXdr}, - ValueXDR: value, - TransactionHash: ledgerCloseMeta.TransactionHash(4).HexString(), - TxIndex: 5, - OpIndex: 0, + EventType: protocol.EventTypeContract, + Ledger: 1, + LedgerClosedAt: now.Format(time.RFC3339), + ContractID: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4", + ID: id, + TopicXDR: []string{counterXdr, value, value, counterXdr}, + ValueXDR: value, + TransactionHash: ledgerCloseMeta.TransactionHash(4).HexString(), + TxIndex: 5, + OpIndex: 0, }, } require.NoError(t, err) @@ -670,16 +670,16 @@ func TestGetEvents(t *testing.T) { require.NoError(t, err) expected := []protocol.EventInfo{ { - EventType: protocol.EventTypeContract, - Ledger: 1, - LedgerClosedAt: now.Format(time.RFC3339), - ContractID: strkey.MustEncode(strkey.VersionByteContract, contractID[:]), - ID: id, - TopicXDR: []string{counterXdr, value}, - ValueXDR: value, - TransactionHash: ledgerCloseMeta.TransactionHash(3).HexString(), - TxIndex: 4, - OpIndex: 0, + EventType: protocol.EventTypeContract, + Ledger: 1, + LedgerClosedAt: now.Format(time.RFC3339), + ContractID: strkey.MustEncode(strkey.VersionByteContract, contractID[:]), + ID: id, + TopicXDR: []string{counterXdr, value}, + ValueXDR: value, + TransactionHash: ledgerCloseMeta.TransactionHash(3).HexString(), + TxIndex: 4, + OpIndex: 0, }, } cursor := protocol.MaxCursor @@ -758,16 +758,16 @@ func TestGetEvents(t *testing.T) { id := protocol.Cursor{Ledger: 1, Tx: 1, Op: 0, Event: 1}.String() expected := []protocol.EventInfo{ { - EventType: protocol.EventTypeSystem, - Ledger: 1, - LedgerClosedAt: now.Format(time.RFC3339), - ContractID: strkey.MustEncode(strkey.VersionByteContract, contractID[:]), - ID: id, - TopicXDR: []string{counterXdr}, - ValueXDR: counterXdr, - TransactionHash: ledgerCloseMeta.TransactionHash(0).HexString(), - TxIndex: 1, - OpIndex: 0, + EventType: protocol.EventTypeSystem, + Ledger: 1, + LedgerClosedAt: now.Format(time.RFC3339), + ContractID: strkey.MustEncode(strkey.VersionByteContract, contractID[:]), + ID: id, + TopicXDR: []string{counterXdr}, + ValueXDR: counterXdr, + TransactionHash: ledgerCloseMeta.TransactionHash(0).HexString(), + TxIndex: 1, + OpIndex: 0, }, } cursor := protocol.MaxCursor @@ -842,16 +842,16 @@ func TestGetEvents(t *testing.T) { value, err := xdr.MarshalBase64(txMeta[i].MustV3().SorobanMeta.Events[0].Body.MustV0().Data) require.NoError(t, err) expected = append(expected, protocol.EventInfo{ - EventType: protocol.EventTypeContract, - Ledger: 1, - LedgerClosedAt: now.Format(time.RFC3339), - ContractID: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4", - ID: id, - TopicXDR: []string{value}, - ValueXDR: value, - TransactionHash: ledgerCloseMeta.TransactionHash(i).HexString(), - TxIndex: uint32(i + 1), - OpIndex: 0, + EventType: protocol.EventTypeContract, + Ledger: 1, + LedgerClosedAt: now.Format(time.RFC3339), + ContractID: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4", + ID: id, + TopicXDR: []string{value}, + ValueXDR: value, + TransactionHash: ledgerCloseMeta.TransactionHash(i).HexString(), + TxIndex: uint32(i + 1), + OpIndex: 0, }) } cursor := expected[len(expected)-1].ID @@ -954,16 +954,16 @@ func TestGetEvents(t *testing.T) { expectedXdr, err := xdr.MarshalBase64(xdr.ScVal{Type: xdr.ScValTypeScvSymbol, Sym: &symbols[i]}) require.NoError(t, err) expected = append(expected, protocol.EventInfo{ - EventType: protocol.EventTypeContract, - Ledger: 5, - LedgerClosedAt: now.Format(time.RFC3339), - ContractID: strkey.MustEncode(strkey.VersionByteContract, contractID[:]), - ID: id, - TopicXDR: []string{counterXdr}, - ValueXDR: expectedXdr, - TransactionHash: ledgerCloseMeta.TransactionHash(i).HexString(), - TxIndex: uint32(i + 1), - OpIndex: 0, + EventType: protocol.EventTypeContract, + Ledger: 5, + LedgerClosedAt: now.Format(time.RFC3339), + ContractID: strkey.MustEncode(strkey.VersionByteContract, contractID[:]), + ID: id, + TopicXDR: []string{counterXdr}, + ValueXDR: expectedXdr, + TransactionHash: ledgerCloseMeta.TransactionHash(i).HexString(), + TxIndex: uint32(i + 1), + OpIndex: 0, }) } cursor := expected[len(expected)-1].ID diff --git a/cmd/stellar-rpc/internal/methods/get_transaction.go b/cmd/stellar-rpc/internal/methods/get_transaction.go index d9becbf9c..499a15fa9 100644 --- a/cmd/stellar-rpc/internal/methods/get_transaction.go +++ b/cmd/stellar-rpc/internal/methods/get_transaction.go @@ -2,7 +2,6 @@ package methods import ( "context" - "encoding/base64" "encoding/hex" "errors" "fmt" @@ -16,7 +15,6 @@ import ( "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/store" ) -//nolint:funlen func GetTransaction( ctx context.Context, log *log.Entry, @@ -63,15 +61,12 @@ func GetTransaction( LatestLedgerCloseTime: storeRange.LastLedger.CloseTime, OldestLedger: storeRange.FirstLedger.Sequence, OldestLedgerCloseTime: storeRange.FirstLedger.CloseTime, - TransactionDetails: protocol.TransactionDetails{ - TransactionHash: request.Hash, - }, } - if errors.Is(getTxErr, store.ErrNoTransaction) { + switch { + case errors.Is(getTxErr, store.ErrNoTransaction): response.Status = protocol.TransactionStatusNotFound - return response, nil - } else if getTxErr != nil { + case getTxErr != nil: log.WithError(getTxErr). WithField("hash", txHash). Errorf("failed to fetch transaction") @@ -79,54 +74,18 @@ func GetTransaction( Code: jrpc2.InternalError, Message: getTxErr.Error(), } - } - - response.ApplicationOrder = tx.ApplicationOrder - response.FeeBump = tx.FeeBump - response.Ledger = tx.Ledger.Sequence - response.LedgerCloseTime = tx.Ledger.CloseTime - - switch request.Format { - case protocol.FormatJSON: - result, envelope, meta, convErr := transactionToJSON(tx) - if convErr != nil { - return response, &jrpc2.Error{ - Code: jrpc2.InternalError, - Message: convErr.Error(), - } - } - diagEvents, convErr := jsonifySlice(xdr.DiagnosticEvent{}, tx.Events) - if convErr != nil { - return response, &jrpc2.Error{ - Code: jrpc2.InternalError, - Message: convErr.Error(), - } - } - - response.ResultJSON = result - response.EnvelopeJSON = envelope - response.ResultMetaJSON = meta - response.DiagnosticEventsJSON = diagEvents - - response.Events, convErr = BuildEventsJSONFromTransaction(tx) - if convErr != nil { + default: + txInfo, ferr := transactionInfo(tx, request.Format) + if ferr != nil { return response, &jrpc2.Error{ Code: jrpc2.InternalError, - Message: convErr.Error(), + Message: ferr.Error(), } } - default: - response.ResultXDR = base64.StdEncoding.EncodeToString(tx.Result) - response.EnvelopeXDR = base64.StdEncoding.EncodeToString(tx.Envelope) - response.ResultMetaXDR = base64.StdEncoding.EncodeToString(tx.Meta) - response.DiagnosticEventsXDR = base64EncodeSlice(tx.Events) - response.Events = BuildEventsXDRFromTransaction(tx) - } - - response.Status = protocol.TransactionStatusFailed - if tx.Successful { - response.Status = protocol.TransactionStatusSuccess + response.TransactionDetails = txInfo.TransactionDetails + response.LedgerCloseTime = txInfo.LedgerCloseTime } + response.TransactionHash = request.Hash return response, nil } diff --git a/cmd/stellar-rpc/internal/methods/get_transaction_test.go b/cmd/stellar-rpc/internal/methods/get_transaction_test.go index c365e2001..b33fc3b28 100644 --- a/cmd/stellar-rpc/internal/methods/get_transaction_test.go +++ b/cmd/stellar-rpc/internal/methods/get_transaction_test.go @@ -14,6 +14,7 @@ import ( "github.com/stellar/go-stellar-sdk/support/log" "github.com/stellar/go-stellar-sdk/xdr" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/host" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv1/sqlitedb" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/xdr2json" ) @@ -234,19 +235,10 @@ func txHash(acctSeq uint32) xdr.Hash { } func txEnvelope(acctSeq uint32) xdr.TransactionEnvelope { - envelope, err := xdr.NewTransactionEnvelope(xdr.EnvelopeTypeEnvelopeTypeTx, xdr.TransactionV1Envelope{ - Tx: xdr.Transaction{ - Fee: 1, - SeqNum: xdr.SequenceNumber(acctSeq), - SourceAccount: xdr.MustMuxedAddress("MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVAAAAAAAAAAAAAJLK"), - Ext: xdr.TransactionExt{ - V: 1, - SorobanData: &xdr.SorobanTransactionData{}, - }, - }, - }) - if err != nil { - panic(err) + envelope := classicTxEnvelope(acctSeq) + envelope.V1.Tx.Ext = xdr.TransactionExt{ + V: 1, + SorobanData: &xdr.SorobanTransactionData{}, } return envelope } @@ -267,22 +259,28 @@ func transactionResult(successful bool) xdr.TransactionResult { } func txMeta(acctSeq uint32, successful bool) xdr.LedgerCloseMeta { - envelope := txEnvelope(acctSeq) - - txProcessing := []xdr.TransactionResultMetaV1{ - { - TxApplyProcessing: xdr.TransactionMeta{ - V: 3, - Operations: &[]xdr.OperationMeta{}, - V3: &xdr.TransactionMetaV3{}, - }, - Result: xdr.TransactionResultPair{ - TransactionHash: txHash(acctSeq), - Result: transactionResult(successful), - }, + processing := xdr.TransactionResultMetaV1{ + TxApplyProcessing: xdr.TransactionMeta{ + V: 3, + Operations: &[]xdr.OperationMeta{}, + // Soroban envelope with NO SorobanMeta: a Soroban tx charged + // but never executed (real on protocol 20-22 history). Pins + // the [[]] contractEventsXdr arity the view path must serve. + V3: &xdr.TransactionMetaV3{}, + }, + Result: xdr.TransactionResultPair{ + TransactionHash: txHash(acctSeq), + Result: transactionResult(successful), }, } + return singleTxLedger(acctSeq+100, txEnvelope(acctSeq), processing) +} +// singleTxLedger assembles a one-transaction ledger from an envelope and its +// apply-processing pair. +func singleTxLedger( + ledgerSeq uint32, envelope xdr.TransactionEnvelope, processing xdr.TransactionResultMetaV1, +) xdr.LedgerCloseMeta { components := []xdr.TxSetComponent{ { Type: xdr.TxSetComponentTypeTxsetCompTxsMaybeDiscountedFee, @@ -300,12 +298,12 @@ func txMeta(acctSeq uint32, successful bool) xdr.LedgerCloseMeta { LedgerHeader: xdr.LedgerHeaderHistoryEntry{ Header: xdr.LedgerHeader{ ScpValue: xdr.StellarValue{ - CloseTime: xdr.TimePoint(ledgerCloseTime(acctSeq + 100)), + CloseTime: xdr.TimePoint(ledgerCloseTime(ledgerSeq)), }, - LedgerSeq: xdr.Uint32(acctSeq + 100), + LedgerSeq: xdr.Uint32(ledgerSeq), }, }, - TxProcessing: txProcessing, + TxProcessing: []xdr.TransactionResultMetaV1{processing}, TxSet: xdr.GeneralizedTransactionSet{ V: 1, V1TxSet: &xdr.TransactionSetV1{ @@ -322,6 +320,90 @@ func txMeta(acctSeq uint32, successful bool) xdr.LedgerCloseMeta { } } +// classicTxEnvelope is txEnvelope without SorobanTransactionData — the +// pre-Soroban shape that accompanies a legacy meta. +func classicTxEnvelope(acctSeq uint32) xdr.TransactionEnvelope { + envelope, err := xdr.NewTransactionEnvelope(xdr.EnvelopeTypeEnvelopeTypeTx, xdr.TransactionV1Envelope{ + Tx: xdr.Transaction{ + Fee: 1, + SeqNum: xdr.SequenceNumber(acctSeq), + SourceAccount: xdr.MustMuxedAddress("MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVAAAAAAAAAAAAAJLK"), + }, + }) + if err != nil { + panic(err) + } + return envelope +} + +// txMetaV0 is a one-transaction ledger whose TxApplyProcessing is legacy +// TransactionMeta V0, as on early pubnet history. +func txMetaV0(acctSeq uint32) (xdr.LedgerCloseMeta, xdr.Hash) { + envelope := classicTxEnvelope(acctSeq) + hash, err := network.HashTransactionInEnvelope(envelope, NetworkPassphrase) + if err != nil { + panic(err) + } + processing := xdr.TransactionResultMetaV1{ + TxApplyProcessing: xdr.TransactionMeta{V: 0, Operations: &[]xdr.OperationMeta{}}, + Result: xdr.TransactionResultPair{ + TransactionHash: hash, + Result: transactionResult(true), + }, + } + return singleTxLedger(acctSeq+100, envelope, processing), hash +} + +// TestTransactionsWithLegacyV0Meta pins an intentional divergence from the +// deleted decode-path extraction: TransactionMeta V0 (early pubnet history) is +// served as an event-free transaction, where GetTransactionEvents errored. +func TestTransactionsWithLegacyV0Meta(t *testing.T) { + testDB := NewTestDB(t) + lcm, hash := txMetaV0(1) // ledger 101 + write, err := sqlitedb.NewReadWriter( + log.DefaultLogger, testDB, host.MakeNoOpDaemon(), 100, NetworkPassphrase, + ).NewTx(t.Context()) + require.NoError(t, err) + require.NoError(t, write.LedgerWriter().InsertLedger(lcm)) + require.NoError(t, write.TransactionWriter().InsertTransactions(lcm)) + require.NoError(t, write.Commit(lcm, nil)) + + eventFree := protocol.Events{ContractEventsXDR: [][]string{}, TransactionEventsXDR: []string{}} + expectedMetaXDR, err := xdr.MarshalBase64(lcm.V2.TxProcessing[0].TxApplyProcessing) + require.NoError(t, err) + + requireServedEmpty := func(status, metaXDR string, diagnosticEventsXDR []string, events protocol.Events) { + require.Equal(t, protocol.TransactionStatusSuccess, status) + require.Equal(t, expectedMetaXDR, metaXDR) + require.Equal(t, []string{}, diagnosticEventsXDR) + require.Equal(t, eventFree, events) + } + + t.Run("getTransaction", func(t *testing.T) { + reader := sqlitedb.NewTransactionReader(log.DefaultLogger, testDB, NetworkPassphrase) + tx, err := GetTransaction(t.Context(), log.DefaultLogger, reader, + sqlitedb.NewLedgerReader(testDB), + protocol.GetTransactionRequest{Hash: hex.EncodeToString(hash[:])}) + require.NoError(t, err) + requireServedEmpty(tx.Status, tx.ResultMetaXDR, tx.DiagnosticEventsXDR, tx.Events) + }) + + t.Run("getTransactions", func(t *testing.T) { + handler := transactionsRPCHandler{ + ledgerReader: sqlitedb.NewLedgerReader(testDB), + maxLimit: 100, + defaultLimit: 10, + networkPassphrase: NetworkPassphrase, + } + response, err := handler.getTransactionsByLedgerSequence(t.Context(), + protocol.GetTransactionsRequest{StartLedger: 101}) + require.NoError(t, err) + require.Len(t, response.Transactions, 1) + got := response.Transactions[0] + requireServedEmpty(got.Status, got.ResultMetaXDR, got.DiagnosticEventsXDR, got.Events) + }) +} + func txMetaWithEvents(acctSeq uint32, successful bool) (xdr.LedgerCloseMeta, xdr.ContractEvent) { meta := txMeta(acctSeq, successful) diff --git a/cmd/stellar-rpc/internal/methods/get_transactions.go b/cmd/stellar-rpc/internal/methods/get_transactions.go index 217ebb43c..96c3eea64 100644 --- a/cmd/stellar-rpc/internal/methods/get_transactions.go +++ b/cmd/stellar-rpc/internal/methods/get_transactions.go @@ -3,9 +3,7 @@ package methods import ( "context" "encoding/base64" - "errors" "fmt" - "io" "strconv" "github.com/creachadair/jrpc2" @@ -74,31 +72,43 @@ func (h transactionsRPCHandler) initializePagination( return *start, requestCursor, limit, nil } -// fetchLedgerData calls the meta table to fetch the corresponding ledger data. -func (h transactionsRPCHandler) fetchLedgerData(ctx context.Context, ledgerSeq uint32, - readTx store.LedgerReaderTx, -) (xdr.LedgerCloseMeta, error) { - ledger, found, err := readTx.GetLedger(ctx, ledgerSeq) - if err != nil { - return ledger, &jrpc2.Error{ +// readLedgerPage borrows ledgerSeq's raw LedgerCloseMeta and runs the page +// extraction inside the loan. Extraction errors are already *jrpc2.Error and +// pass through unchanged; only the read's own outcomes are classified here. +func (h transactionsRPCHandler) readLedgerPage( + ctx context.Context, ledgerSeq uint32, readTx store.LedgerReaderTx, + start toid.ID, txns *[]protocol.TransactionInfo, limit uint, format string, +) (*toid.ID, bool, error) { + var cursor *toid.ID + var done bool + var procErr error + found, err := readTx.WithLedgerRaw(ctx, ledgerSeq, func(raw []byte) error { + cursor, done, procErr = h.processTransactionsInLedger(raw, start, txns, limit, format) + return procErr + }) + switch { + case procErr != nil: + return nil, false, procErr + case err != nil: + return nil, false, &jrpc2.Error{ Code: jrpc2.InternalError, Message: err.Error(), } - } else if !found { - return ledger, &jrpc2.Error{ + case !found: + return nil, false, &jrpc2.Error{ Code: jrpc2.InvalidParams, Message: fmt.Sprintf("database does not contain metadata for ledger: %d", ledgerSeq), } } - return ledger, nil + return cursor, done, nil } -// processTransactionsInLedger cycles through all the transactions in a ledger, extracts the transaction info -// and builds the list of transactions. -// -//nolint:cyclop,funlen // one linear pagination pass; splitting would scatter the cursor math +// processTransactionsInLedger extracts the page's worth of transactions from +// raw — a marshaled LedgerCloseMeta on loan from WithLedgerRaw — through the +// SDK's zero-copy views. Every byte field below aliases raw until it is +// base64- or JSON-encoded into txns; nothing that aliases raw outlives this call. func (h transactionsRPCHandler) processTransactionsInLedger( - ledger xdr.LedgerCloseMeta, start toid.ID, + raw []byte, start toid.ID, txns *[]protocol.TransactionInfo, limit uint, format string, ) (*toid.ID, bool, error) { @@ -106,117 +116,109 @@ func (h transactionsRPCHandler) processTransactionsInLedger( if err != nil { return nil, false, &jrpc2.Error{Code: jrpc2.InvalidParams, Message: err.Error()} } - - reader, err := ingest.NewLedgerTransactionReaderFromLedgerCloseMeta(h.networkPassphrase, ledger) + lcmView := xdr.LedgerCloseMetaView(raw) + ledgerSeq, err := lcmView.LedgerSequence() if err != nil { - return nil, false, &jrpc2.Error{ - Code: jrpc2.InternalError, - Message: err.Error(), - } + return nil, false, &jrpc2.Error{Code: jrpc2.InternalError, Message: err.Error()} } - - startTxIdx := 1 - ledgerSeq := ledger.LedgerSequence() ledgerSeqInt32, err := uint32ToInt32(ledgerSeq, "ledger sequence") if err != nil { return nil, false, &jrpc2.Error{Code: jrpc2.InternalError, Message: err.Error()} } + + // The cursor's tx-order offset only applies within the cursor's own ledger. + startTxIdx := 1 if ledgerSeqInt32 == start.LedgerSequence { startTxIdx = int(start.TransactionOrder) - if ierr := reader.Seek(startTxIdx - 1); ierr != nil && !errors.Is(ierr, io.EOF) { - return nil, false, &jrpc2.Error{ - Code: jrpc2.InternalError, - Message: ierr.Error(), - } - } + } + cursor := toid.New(ledgerSeqInt32, int32(startTxIdx-1), 1) + remaining := limitInt - len(*txns) + if remaining <= 0 { + return cursor, true, nil } - txCount := ledger.CountTransactions() - cursor := toid.New(ledgerSeqInt32, 0, 1) - for i := startTxIdx; i <= txCount; i++ { - cursor.TransactionOrder = int32(i) - - ingestTx, err := reader.Read() - if err != nil { - if errors.Is(err, io.EOF) { - break - } - return nil, false, &jrpc2.Error{ - Code: jrpc2.InvalidParams, - Message: err.Error(), - } + // One walk of this ledger's TxProcessing, materializing only the page's + // worth of transactions. Apply indices are 0-based here; application order + // (and the cursor) is 1-based. A startTxIdx past the last transaction + // yields nothing, which is how a spent cursor lands on an empty page. + txViews, err := ingest.LedgerTransactionViewRange(lcmView, startTxIdx-1, remaining, h.networkPassphrase) + if err != nil { + return nil, false, &jrpc2.Error{ + Code: jrpc2.InternalError, + Message: err.Error(), } + } - tx, err := store.ParseTransaction(ledger, ingestTx) - if err != nil { + for i, txView := range txViews { + cursor.TransactionOrder = int32(startTxIdx + i) + + tx := store.ParseTransactionView(txView) + txInfo, ferr := transactionInfo(tx, format) + if ferr != nil { return nil, false, &jrpc2.Error{ Code: jrpc2.InternalError, - Message: err.Error(), + Message: ferr.Error(), } } - txInfo := protocol.TransactionInfo{ - TransactionDetails: protocol.TransactionDetails{ - TransactionHash: tx.TransactionHash, - ApplicationOrder: tx.ApplicationOrder, - FeeBump: tx.FeeBump, - Ledger: tx.Ledger.Sequence, - }, - LedgerCloseTime: tx.Ledger.CloseTime, + *txns = append(*txns, txInfo) + if len(*txns) >= limitInt { + return cursor, true, nil } + } - switch format { - case protocol.FormatJSON: - result, envelope, meta, convErr := transactionToJSON(tx) - if convErr != nil { - return nil, false, &jrpc2.Error{ - Code: jrpc2.InternalError, - Message: convErr.Error(), - } - } + return cursor, false, nil +} - diagEvents, convErr := jsonifySlice(xdr.DiagnosticEvent{}, tx.Events) - if convErr != nil { - return nil, false, &jrpc2.Error{ - Code: jrpc2.InternalError, - Message: convErr.Error(), - } - } +// transactionInfo renders one extracted transaction as a page entry. Pure +// formatting. +func transactionInfo(tx store.Transaction, format string) (protocol.TransactionInfo, error) { + txInfo := protocol.TransactionInfo{ + TransactionDetails: protocol.TransactionDetails{ + TransactionHash: tx.TransactionHash, + ApplicationOrder: tx.ApplicationOrder, + FeeBump: tx.FeeBump, + Ledger: tx.Ledger.Sequence, + }, + LedgerCloseTime: tx.Ledger.CloseTime, + } - txInfo.ResultJSON = result - txInfo.ResultMetaJSON = meta - txInfo.EnvelopeJSON = envelope - txInfo.DiagnosticEventsJSON = diagEvents + switch format { + case protocol.FormatJSON: + result, envelope, meta, err := transactionToJSON(tx) + if err != nil { + return txInfo, err + } - txInfo.Events, convErr = BuildEventsJSONFromTransaction(tx) - if convErr != nil { - return nil, false, &jrpc2.Error{ - Code: jrpc2.InternalError, - Message: convErr.Error(), - } - } + diagEvents, err := jsonifySlice(xdr.DiagnosticEvent{}, tx.Events) + if err != nil { + return txInfo, err + } - default: - txInfo.ResultXDR = base64.StdEncoding.EncodeToString(tx.Result) - txInfo.ResultMetaXDR = base64.StdEncoding.EncodeToString(tx.Meta) - txInfo.EnvelopeXDR = base64.StdEncoding.EncodeToString(tx.Envelope) - txInfo.DiagnosticEventsXDR = base64EncodeSlice(tx.Events) + txInfo.ResultJSON = result + txInfo.ResultMetaJSON = meta + txInfo.EnvelopeJSON = envelope + txInfo.DiagnosticEventsJSON = diagEvents - txInfo.Events = BuildEventsXDRFromTransaction(tx) + txInfo.Events, err = BuildEventsJSONFromTransaction(tx) + if err != nil { + return txInfo, err } - txInfo.Status = protocol.TransactionStatusFailed - if tx.Successful { - txInfo.Status = protocol.TransactionStatusSuccess - } + default: + txInfo.ResultXDR = base64.StdEncoding.EncodeToString(tx.Result) + txInfo.ResultMetaXDR = base64.StdEncoding.EncodeToString(tx.Meta) + txInfo.EnvelopeXDR = base64.StdEncoding.EncodeToString(tx.Envelope) + txInfo.DiagnosticEventsXDR = base64EncodeSlice(tx.Events) - *txns = append(*txns, txInfo) - if len(*txns) >= limitInt { - return cursor, true, nil - } + txInfo.Events = BuildEventsXDRFromTransaction(tx) } - return cursor, false, nil + txInfo.Status = protocol.TransactionStatusFailed + if tx.Successful { + txInfo.Status = protocol.TransactionStatusSuccess + } + return txInfo, nil } // getTransactionsByLedgerSequence fetches transactions between the start and end ledgers, inclusive of both. @@ -272,12 +274,8 @@ func (h transactionsRPCHandler) getTransactionsByLedgerSequence(ctx context.Cont Message: "cursor ledger sequence cannot be negative", } } - ledger, err := h.fetchLedgerData(ctx, uint32(ledgerSeq), readTx) - if err != nil { - return protocol.GetTransactionsResponse{}, err - } - - cursor, done, err = h.processTransactionsInLedger(ledger, start, &txns, limit, request.Format) + cursor, done, err = h.readLedgerPage( + ctx, uint32(ledgerSeq), readTx, start, &txns, limit, request.Format) if err != nil { return protocol.GetTransactionsResponse{}, err } diff --git a/cmd/stellar-rpc/internal/methods/get_transactions_test.go b/cmd/stellar-rpc/internal/methods/get_transactions_test.go index 40a56fed6..33de52266 100644 --- a/cmd/stellar-rpc/internal/methods/get_transactions_test.go +++ b/cmd/stellar-rpc/internal/methods/get_transactions_test.go @@ -323,7 +323,10 @@ func createTestLedger(sequence uint32) xdr.LedgerCloseMeta { TxApplyProcessing: xdr.TransactionMeta{ V: 3, Operations: &[]xdr.OperationMeta{}, - V3: &xdr.TransactionMetaV3{}, + // Soroban envelope with NO SorobanMeta: a Soroban tx charged but + // never executed (real on protocol 20-22 history). This pins the + // [[]] contractEventsXdr arity the view path must preserve. + V3: &xdr.TransactionMetaV3{}, }, Result: xdr.TransactionResultPair{ TransactionHash: txHash(sequence), diff --git a/cmd/stellar-rpc/internal/methods/mocks.go b/cmd/stellar-rpc/internal/methods/mocks.go index 7f9094108..76291e021 100644 --- a/cmd/stellar-rpc/internal/methods/mocks.go +++ b/cmd/stellar-rpc/internal/methods/mocks.go @@ -71,9 +71,11 @@ func (m *MockLedgerReaderTx) BatchGetLedgers(ctx context.Context, start, end uin return args.Get(0).([]store.LedgerMetadataChunk), args.Error(1) //nolint:forcetypeassert } -func (m *MockLedgerReaderTx) GetLedger(ctx context.Context, sequence uint32) (xdr.LedgerCloseMeta, bool, error) { - args := m.Called(ctx, sequence) - return args.Get(0).(xdr.LedgerCloseMeta), args.Bool(1), args.Error(2) //nolint:forcetypeassert +func (m *MockLedgerReaderTx) WithLedgerRaw( + ctx context.Context, sequence uint32, fn store.WithLedgerRawFn, +) (bool, error) { + args := m.Called(ctx, sequence, fn) + return args.Bool(0), args.Error(1) } func (m *MockLedgerReaderTx) Done() error { diff --git a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/ledger.go b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/ledger.go index 8a235c89b..b04fa2a0e 100644 --- a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/ledger.go +++ b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/ledger.go @@ -87,9 +87,16 @@ func (l ledgerReaderTx) BatchGetLedgers( return batch, nil } -// GetLedger fetches a single ledger from the db using a transaction. -func (l ledgerReaderTx) GetLedger(ctx context.Context, sequence uint32) (xdr.LedgerCloseMeta, bool, error) { - return getLedgerFromDB(ctx, l.tx, sequence) +// WithLedgerRaw lends the ledger's stored meta blob without decoding it. The +// blob is ours to lend: database/sql clones each BLOB scanned into a *[]byte. +func (l ledgerReaderTx) WithLedgerRaw( + ctx context.Context, sequence uint32, fn store.WithLedgerRawFn, +) (bool, error) { + meta, found, err := getLedgerRawFromDB(ctx, l.tx, sequence) + if err != nil || !found { + return found, err + } + return true, fn(meta) } func (l ledgerReaderTx) Done() error { @@ -367,8 +374,8 @@ func getLedgerFromDB(ctx context.Context, db readDB, sequence uint32) (xdr.Ledge return lcm, true, nil } -// getLedgerRawFromDB is a helper function that encapsulates the common logic -// for fetching a single ledger's bytes from the database. +// getLedgerRawFromDB fetches a single ledger's meta blob. The bytes are owned: +// the driver and database/sql each copy the BLOB out of SQLite's memory. func getLedgerRawFromDB(ctx context.Context, db readDB, sequence uint32) ([]byte, bool, error) { sql := sq.Select("meta").From(ledgerCloseMetaTableName).Where(sq.Eq{"sequence": sequence}) var results [][]byte diff --git a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/mocks.go b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/mocks.go index 5930dd950..33600cb79 100644 --- a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/mocks.go +++ b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/mocks.go @@ -17,7 +17,6 @@ type MockTransactionHandler struct { passphrase string ledgerRange store.LedgerRange - txs map[string]ingest.LedgerTransaction txHashToMeta map[string]*xdr.LedgerCloseMeta ledgerSeqToMeta map[uint32]*xdr.LedgerCloseMeta } @@ -25,7 +24,6 @@ type MockTransactionHandler struct { func NewMockTransactionStore(passphrase string) *MockTransactionHandler { return &MockTransactionHandler{ passphrase: passphrase, - txs: make(map[string]ingest.LedgerTransaction), txHashToMeta: make(map[string]*xdr.LedgerCloseMeta), ledgerSeqToMeta: make(map[uint32]*xdr.LedgerCloseMeta), } @@ -47,9 +45,7 @@ func (txn *MockTransactionHandler) InsertTransactions(lcm xdr.LedgerCloseMeta) e return err } - h := tx.Result.TransactionHash.HexString() - txn.txs[h] = tx - txn.txHashToMeta[h] = &lcm + txn.txHashToMeta[tx.Result.TransactionHash.HexString()] = &lcm } if lcmSeq := lcm.LedgerSequence(); lcmSeq < txn.ledgerRange.FirstLedger.Sequence || @@ -69,12 +65,22 @@ func (txn *MockTransactionHandler) InsertTransactions(lcm xdr.LedgerCloseMeta) e func (txn *MockTransactionHandler) GetTransaction(_ context.Context, hash xdr.Hash) ( store.Transaction, error, ) { - tx, ok := txn.txs[hash.HexString()] + lcm, ok := txn.txHashToMeta[hash.HexString()] if !ok { return store.Transaction{}, store.ErrNoTransaction } - itx, err := store.ParseTransaction(*txn.txHashToMeta[hash.HexString()], tx) - return itx, err + raw, err := lcm.MarshalBinary() + if err != nil { + return store.Transaction{}, err + } + txView, found, err := ingest.LedgerTransactionViewByHash(xdr.LedgerCloseMetaView(raw), hash, txn.passphrase) + if err != nil { + return store.Transaction{}, err + } + if !found { + return store.Transaction{}, store.ErrNoTransaction + } + return store.ParseTransactionView(txView), nil } func (txn *MockTransactionHandler) RegisterMetrics(_, _ prometheus.Observer) {} diff --git a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/transaction.go b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/transaction.go index c0e949ed6..be3ba8c5c 100644 --- a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/transaction.go +++ b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/transaction.go @@ -74,11 +74,7 @@ func (txn *transactionHandler) InsertTransactions(lcm xdr.LedgerCloseMeta) error reader, err := ingest.NewLedgerTransactionReaderFromLedgerCloseMeta(txn.passphrase, lcm) if err != nil { - return fmt.Errorf( - "failed to open transaction reader for ledger %d: %w", - lcm.LedgerSequence(), - err, - ) + return fmt.Errorf("failed to open transaction reader for ledger %d: %w", lcm.LedgerSequence(), err) } transactions := make(map[xdr.Hash]ingest.LedgerTransaction, txCount) @@ -158,18 +154,12 @@ func (txn *transactionHandler) trimTransactions(latestLedgerSeq uint32, retentio // directly against the RPC handler. // // Errors occur if there are issues with the DB connection or the XDR is -// corrupted somehow. If the transaction is not found, io.EOF is returned. -func (txn *transactionHandler) GetTransaction(ctx context.Context, hash xdr.Hash) ( - store.Transaction, error, -) { +// corrupted somehow. If the transaction is not found, store.ErrNoTransaction +// is returned. +func (txn *transactionHandler) GetTransaction(ctx context.Context, hash xdr.Hash) (store.Transaction, error) { start := time.Now() - tx := store.Transaction{} - lcm, ingestTx, err := txn.getTransactionByHash(ctx, hash) - if err != nil { - return tx, err - } - tx, err = store.ParseTransaction(lcm, ingestTx) + tx, err := txn.getTransactionByHash(ctx, hash) if err != nil { return tx, err } @@ -177,7 +167,7 @@ func (txn *transactionHandler) GetTransaction(ctx context.Context, hash xdr.Hash txn.log. WithField("txhash", hex.EncodeToString(hash[:])). WithField("duration", time.Since(start)). - Debugf("Fetched and encoded transaction from ledger %d", lcm.LedgerSequence()) + Debugf("Fetched and encoded transaction from ledger %d", tx.Ledger.Sequence) return tx, nil } @@ -189,11 +179,11 @@ func (txn *transactionHandler) GetTransaction(ctx context.Context, hash xdr.Hash // // Note: Caller must do input sanitization on the hash. func (txn *transactionHandler) getTransactionByHash(ctx context.Context, hash xdr.Hash) ( - xdr.LedgerCloseMeta, ingest.LedgerTransaction, error, + store.Transaction, error, ) { var rows []struct { - TxIndex int `db:"application_order"` - Lcm xdr.LedgerCloseMeta `db:"meta"` + TxIndex int `db:"application_order"` + Lcm xdr.LedgerCloseMetaView `db:"meta"` } rowQ := sq. Select("t.application_order", "lcm.meta"). @@ -203,27 +193,28 @@ func (txn *transactionHandler) getTransactionByHash(ctx context.Context, hash xd Limit(1) if err := txn.db.Select(ctx, &rows, rowQ); err != nil { - return xdr.LedgerCloseMeta{}, ingest.LedgerTransaction{}, - fmt.Errorf("db read failed for txhash %s: %w", hex.EncodeToString(hash[:]), err) + return store.Transaction{}, fmt.Errorf("db read failed for txhash %s: %w", hex.EncodeToString(hash[:]), err) } else if len(rows) < 1 { - return xdr.LedgerCloseMeta{}, ingest.LedgerTransaction{}, store.ErrNoTransaction + return store.Transaction{}, store.ErrNoTransaction } txIndex, lcm := rows[0].TxIndex, rows[0].Lcm - reader, err := ingest.NewLedgerTransactionReaderFromLedgerCloseMeta(txn.passphrase, lcm) + ledgerSeq, err := lcm.LedgerSequence() if err != nil { - return lcm, ingest.LedgerTransaction{}, - fmt.Errorf("failed to create ledger reader: %w", err) + return store.Transaction{}, fmt.Errorf("failed to get ledger sequence: %w", err) } - err = reader.Seek(txIndex - 1) + txnViewRange, err := ingest.LedgerTransactionViewRange(lcm, txIndex-1, 1, txn.passphrase) if err != nil { - return lcm, ingest.LedgerTransaction{}, - fmt.Errorf("failed to index to tx %d in ledger %d (txhash=%s): %w", - txIndex, lcm.LedgerSequence(), hash, err) + return store.Transaction{}, fmt.Errorf("failed to index to tx %d in ledger %d (txhash=%s): %w", + txIndex, ledgerSeq, hex.EncodeToString(hash[:]), err) } - - ledgerTx, err := reader.Read() - return lcm, ledgerTx, err + if len(txnViewRange) == 0 { + return store.Transaction{}, fmt.Errorf( + "application_order %d does not resolve to a transaction in ledger %d (txhash=%s): index/meta mismatch", + txIndex, ledgerSeq, hex.EncodeToString(hash[:])) + } + txView := txnViewRange[0] + return store.ParseTransactionView(txView), nil } type transactionTableMigration struct { diff --git a/cmd/stellar-rpc/internal/rpcv2/adapters/ledger_reader.go b/cmd/stellar-rpc/internal/rpcv2/adapters/ledger_reader.go index b3efa9023..a239d7adb 100644 --- a/cmd/stellar-rpc/internal/rpcv2/adapters/ledger_reader.go +++ b/cmd/stellar-rpc/internal/rpcv2/adapters/ledger_reader.go @@ -19,11 +19,11 @@ import ( "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/store" ) -// walkSpanCap bounds the ledger span ledgerReaderTx.GetLedger primes its walk -// iterator with: one chunk's worth of ledgers, touching at most two chunks -// when the span straddles a boundary, so view.ScanLedgers can resolve every -// reader up front. Handler scan limits (methods.LedgerScanLimit) must stay -// ≤ this cap; the pairing test enforces it. +// walkSpanCap bounds the ledger span ledgerReaderTx.WithLedgerRaw primes its +// walk iterator with: one chunk's worth of ledgers, touching at most two +// chunks when the span straddles a boundary, so view.ScanLedgers can resolve +// every reader up front. Handler scan limits (methods.LedgerScanLimit) must +// stay ≤ this cap; the pairing test enforces it. const walkSpanCap = chunk.LedgersPerChunk // LedgerReader satisfies store.LedgerReader over the query router. Every @@ -125,74 +125,30 @@ func (r *LedgerReader) NewTx(ctx context.Context) (store.LedgerReaderTx, error) } // ledgerReaderTx satisfies store.LedgerReaderTx over the request's read view -// (the serving wrapper owns and releases it — Done does not). GetLedger serves -// getTransactions' ascending, contiguous per-ledger walk by pulling from a -// single ScanLedgers iterator primed on the first call; GetLedgerRange and -// BatchGetLedgers read through the same view but never touch that iterator. +// (the serving wrapper owns and releases it — Done does not). WithLedgerRaw +// serves getTransactions' ascending, contiguous per-ledger walk by pulling +// from a ScanLedgers iterator primed on the first call, see walk; +// GetLedgerRange and BatchGetLedgers read through the same view but never +// touch that iterator. type ledgerReaderTx struct { view *query.ReadView // next/stop are the pull ends of the walk iterator; nil until the first - // GetLedger primes them. + // walk step primes them. next func() (ledger.Entry, error, bool) stop func() } -func (tx *ledgerReaderTx) GetLedger(ctx context.Context, sequence uint32) (xdr.LedgerCloseMeta, bool, error) { - // The request duration limiter answers the client at the deadline but only - // abandons the handler goroutine — it cannot stop it. Without this check - // the abandoned getTransactions walk would keep decoding the rest of its - // primed span (up to a whole chunk) while holding its read view, and the - // deletion grace margin is sized assuming walks stop within one iteration - // of their deadline. - if err := ctx.Err(); err != nil { - return xdr.LedgerCloseMeta{}, false, err - } - // ClampRange is the only place the servable window is enforced and no - // point-read path calls it, so gate here: without this a view acquired - // between ingestion's commit and its SetLatestLedger could return a ledger - // above the view's frozen latest. - if !inWindow(tx.view, sequence) { - return xdr.LedgerCloseMeta{}, false, nil - } - - if tx.next == nil { - // ScanLedgers' end is inclusive; the -1 keeps the span at exactly - // walkSpanCap ledgers, so a chunk-aligned start opens one chunk - // reader, not two. - scan, err := tx.view.ScanLedgers(sequence, min(tx.view.LatestLedger(), sequence+walkSpanCap-1)) - if err != nil { - return xdr.LedgerCloseMeta{}, false, err - } - tx.next, tx.stop = iter.Pull2(scan) - } - - entry, err, ok := tx.next() - if err != nil { - return xdr.LedgerCloseMeta{}, false, err - } - if !ok { - // The iterator ran dry: the caller walked sequentially but past the span - // primed above. getTransactions' span cap (methods.LedgerScanLimit) keeps - // its walks inside the span, so reaching this means a new caller without - // that cap. Fail loudly rather than serve a wrong-position read. - return xdr.LedgerCloseMeta{}, false, fmt.Errorf( - "adapters: ledger walk exhausted its primed %d-ledger span at ledger %d"+ - " — the calling handler must cap the request's ledger range", - walkSpanCap, sequence) - } - if entry.Seq != sequence { - // The walk contract (ascending, contiguous from the priming sequence) - // was broken. Fail loudly rather than serve the wrong ledger's data. - return xdr.LedgerCloseMeta{}, false, fmt.Errorf( - "adapters: non-sequential GetLedger: asked for ledger %d, the walk is at ledger %d", - sequence, entry.Seq) - } - var lcm xdr.LedgerCloseMeta - if err := lcm.UnmarshalBinary(entry.Bytes); err != nil { - return xdr.LedgerCloseMeta{}, false, fmt.Errorf("adapters: unmarshal ledger %d: %w", sequence, err) +// WithLedgerRaw lends the step's bytes straight from the chunk reader's +// scratch buffer, which the next step overwrites — fn must not retain them. +func (tx *ledgerReaderTx) WithLedgerRaw( + ctx context.Context, sequence uint32, fn store.WithLedgerRawFn, +) (bool, error) { + entry, found, err := tx.walk(ctx, sequence) + if err != nil || !found { + return found, err } - return lcm, true, nil + return true, fn(entry.Bytes) } func (tx *ledgerReaderTx) GetLedgerRange(_ context.Context) (store.LedgerRange, error) { @@ -246,6 +202,61 @@ func (tx *ledgerReaderTx) Done() error { return nil } +// walk is the store.LedgerReaderTx walk: one ScanLedgers iterator primed on +// the first call. The returned Entry.Bytes are valid only until the next call. +func (tx *ledgerReaderTx) walk(ctx context.Context, sequence uint32) (ledger.Entry, bool, error) { + // The request duration limiter answers the client at the deadline but only + // abandons the handler goroutine — it cannot stop it. Without this check + // the abandoned getTransactions walk would keep decoding the rest of its + // primed span (up to a whole chunk) while holding its read view, and the + // deletion grace margin is sized assuming walks stop within one iteration + // of their deadline. + if err := ctx.Err(); err != nil { + return ledger.Entry{}, false, err + } + // ClampRange is the only place the servable window is enforced and no + // point-read path calls it, so gate here: without this a view acquired + // between ingestion's commit and its SetLatestLedger could return a ledger + // above the view's frozen latest. + if !inWindow(tx.view, sequence) { + return ledger.Entry{}, false, nil + } + + if tx.next == nil { + // ScanLedgers' end is inclusive; the -1 keeps the span at exactly + // walkSpanCap ledgers, so a chunk-aligned start opens one chunk + // reader, not two. + scan, err := tx.view.ScanLedgers(sequence, min(tx.view.LatestLedger(), sequence+walkSpanCap-1)) + if err != nil { + return ledger.Entry{}, false, err + } + tx.next, tx.stop = iter.Pull2(scan) + } + + entry, err, ok := tx.next() + if err != nil { + return ledger.Entry{}, false, err + } + if !ok { + // The iterator ran dry: the caller walked sequentially but past the span + // primed above. getTransactions' span cap (methods.LedgerScanLimit) keeps + // its walks inside the span, so reaching this means a new caller without + // that cap. Fail loudly rather than serve a wrong-position read. + return ledger.Entry{}, false, fmt.Errorf( + "adapters: ledger walk exhausted its primed %d-ledger span at ledger %d"+ + " — the calling handler must cap the request's ledger range", + walkSpanCap, sequence) + } + if entry.Seq != sequence { + // The walk contract (ascending, contiguous from the priming sequence) + // was broken. Fail loudly rather than serve the wrong ledger's data. + return ledger.Entry{}, false, fmt.Errorf( + "adapters: non-sequential ledger walk: asked for ledger %d, the walk is at ledger %d", + sequence, entry.Seq) + } + return entry, true, nil +} + // inWindow reports whether seq falls inside the view's servable window // [OldestLedger, LatestLedger] — the one gate every point read must apply. // OldestLedger is always ≥ 2 (the floor sits on a chunk, and chunk 0 starts at diff --git a/cmd/stellar-rpc/internal/rpcv2/adapters/ledger_reader_test.go b/cmd/stellar-rpc/internal/rpcv2/adapters/ledger_reader_test.go index 70f9ef6d2..c41f305a6 100644 --- a/cmd/stellar-rpc/internal/rpcv2/adapters/ledger_reader_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/adapters/ledger_reader_test.go @@ -181,14 +181,21 @@ func TestStreamLedgerRange_BelowFloorIsRangeError(t *testing.T) { assert.Equal(t, c0.FirstLedger(), rangeErr.Oldest) } -func TestTxGetLedger_WalksContiguously(t *testing.T) { +// txGetLedger is the Tx's retired GetLedger: one WithLedgerRaw step, decoded inside the loan. +func txGetLedger(ctx context.Context, tx store.LedgerReaderTx, seq uint32) (xdr.LedgerCloseMeta, bool, error) { + var lcm xdr.LedgerCloseMeta + found, err := tx.WithLedgerRaw(ctx, seq, lcm.UnmarshalBinary) + return lcm, found, err +} + +func TestTxWalk_Contiguous(t *testing.T) { ctx, reader, c0, _ := sparseFixture(t) tx, err := reader.NewTx(ctx) require.NoError(t, err) defer func() { _ = tx.Done() }() for seq := c0.FirstLedger(); seq <= c0.FirstLedger()+3; seq++ { - lcm, ok, err := tx.GetLedger(context.Background(), seq) + lcm, ok, err := txGetLedger(context.Background(), tx, seq) require.NoError(t, err) require.True(t, ok, "ledger %d", seq) assert.Equal(t, seq, lcm.LedgerSequence()) @@ -196,23 +203,23 @@ func TestTxGetLedger_WalksContiguously(t *testing.T) { } } -func TestTxGetLedger_NonSequentialFailsLoudly(t *testing.T) { +func TestTxWalk_NonSequentialFailsLoudly(t *testing.T) { ctx, reader, c0, _ := sparseFixture(t) tx, err := reader.NewTx(ctx) require.NoError(t, err) defer func() { _ = tx.Done() }() - _, ok, err := tx.GetLedger(context.Background(), c0.FirstLedger()) + _, ok, err := txGetLedger(context.Background(), tx, c0.FirstLedger()) require.NoError(t, err) require.True(t, ok) // Skipping ahead breaks the walk contract; serving the wrong ledger's // data would be worse than an error. - _, _, err = tx.GetLedger(context.Background(), c0.FirstLedger()+2) + _, _, err = txGetLedger(context.Background(), tx, c0.FirstLedger()+2) assert.ErrorContains(t, err, "non-sequential") } -func TestTxGetLedger_GuardsBeforePriming(t *testing.T) { +func TestTxWalk_GuardsBeforePriming(t *testing.T) { ctx, reader, c0, c1 := sparseFixture(t) tx, err := reader.NewTx(ctx) require.NoError(t, err) @@ -221,12 +228,12 @@ func TestTxGetLedger_GuardsBeforePriming(t *testing.T) { // Sub-genesis and out-of-window sequences return clean misses without // consuming the walk iterator... for _, seq := range []uint32{0, 1, c0.FirstLedger() - 1, c1.FirstLedger() + 1} { - _, ok, err := tx.GetLedger(context.Background(), seq) + _, ok, err := txGetLedger(context.Background(), tx, seq) assert.NoError(t, err) assert.False(t, ok) } // ...so a walk primed afterwards still starts at its own sequence. - lcm, ok, err := tx.GetLedger(context.Background(), c0.FirstLedger()) + lcm, ok, err := txGetLedger(context.Background(), tx, c0.FirstLedger()) require.NoError(t, err) require.True(t, ok) assert.Equal(t, c0.FirstLedger(), lcm.LedgerSequence()) @@ -245,7 +252,7 @@ func TestTxGetLedgerRange_DoesNotDisturbTheWalk(t *testing.T) { assert.Equal(t, c0.FirstLedger(), lr.FirstLedger.Sequence) assert.Equal(t, c1.FirstLedger(), lr.LastLedger.Sequence) - lcm, ok, err := tx.GetLedger(context.Background(), c0.FirstLedger()+1) + lcm, ok, err := txGetLedger(context.Background(), tx, c0.FirstLedger()+1) require.NoError(t, err) require.True(t, ok) assert.Equal(t, c0.FirstLedger()+1, lcm.LedgerSequence()) @@ -335,13 +342,13 @@ func TestTxDone_WithAndWithoutPriming(t *testing.T) { tx, err = reader.NewTx(ctx) require.NoError(t, err) - _, _, err = tx.GetLedger(context.Background(), c0.FirstLedger()) + _, _, err = txGetLedger(context.Background(), tx, c0.FirstLedger()) require.NoError(t, err) assert.NoError(t, tx.Done()) assert.NoError(t, tx.Done(), "a second Done must be a no-op, not a double release") } -func TestTxGetLedger_WalkCrossesChunkBorder(t *testing.T) { +func TestTxWalk_CrossesChunkBorder(t *testing.T) { if testing.Short() { t.Skip("seeds a full 10k-ledger chunk") } @@ -358,14 +365,14 @@ func TestTxGetLedger_WalkCrossesChunkBorder(t *testing.T) { defer func() { _ = tx.Done() }() for seq := c0.LastLedger() - 1; seq <= c1.FirstLedger()+1; seq++ { - lcm, ok, err := tx.GetLedger(context.Background(), seq) + lcm, ok, err := txGetLedger(context.Background(), tx, seq) require.NoError(t, err, "ledger %d", seq) require.True(t, ok, "ledger %d", seq) assert.Equal(t, seq, lcm.LedgerSequence()) } } -func TestTxGetLedger_WalkPastSpanCapIsExhaustedNotNonSequential(t *testing.T) { +func TestTxWalk_PastSpanCapIsExhaustedNotNonSequential(t *testing.T) { if testing.Short() { t.Skip("seeds a full 10k-ledger chunk") } @@ -383,12 +390,12 @@ func TestTxGetLedger_WalkPastSpanCapIsExhaustedNotNonSequential(t *testing.T) { start := c0.FirstLedger() for seq := start; seq < start+walkSpanCap; seq++ { - _, ok, err := tx.GetLedger(context.Background(), seq) + _, ok, err := txGetLedger(context.Background(), tx, seq) require.NoError(t, err, "ledger %d", seq) require.True(t, ok, "ledger %d", seq) } - _, _, err = tx.GetLedger(context.Background(), start+walkSpanCap) + _, _, err = txGetLedger(context.Background(), tx, start+walkSpanCap) require.ErrorContains(t, err, "exhausted its primed") assert.NotContains(t, err.Error(), "non-sequential") } @@ -412,22 +419,6 @@ func TestWalkSpanCapCoversTheHandlerScanLimit(t *testing.T) { "methods.LedgerScanLimit cannot derive from it (shared v1 code), so it is pinned here") } -func TestLedgerReaderTx_GetLedgerStopsOnCanceledContext(t *testing.T) { - baseCtx, reader, c0, _ := sparseFixture(t) - tx, err := reader.NewTx(baseCtx) - require.NoError(t, err) - defer func() { _ = tx.Done() }() - - ctx, cancel := context.WithCancel(baseCtx) - _, found, err := tx.GetLedger(ctx, c0.FirstLedger()) - require.NoError(t, err) - require.True(t, found) - - cancel() - _, _, err = tx.GetLedger(ctx, c0.FirstLedger()+1) - require.ErrorIs(t, err, context.Canceled) -} - func TestLedgerReaderTx_BatchGetLedgersStopsOnCanceledContext(t *testing.T) { r, first := sharedViewFixture(t) reader := NewLedgerReader() @@ -519,6 +510,49 @@ func TestWithLedgerRaw_MissDoesNotRunFn(t *testing.T) { } } +func TestTxWithLedgerRaw_LendsTheSameBytesGetLedgerDecodes(t *testing.T) { + ctx, reader, c0, _ := sparseFixture(t) + rawTx, err := reader.NewTx(ctx) + require.NoError(t, err) + defer func() { _ = rawTx.Done() }() + + for seq := c0.FirstLedger(); seq <= c0.FirstLedger()+3; seq++ { + lcm, ok, err := reader.GetLedger(ctx, seq) + require.NoError(t, err) + require.True(t, ok, "ledger %d", seq) + want, err := lcm.MarshalBinary() + require.NoError(t, err) + + var got []byte + found, err := rawTx.WithLedgerRaw(context.Background(), seq, func(raw []byte) error { + // The loan forbids retaining raw, so clone inside fn. + got = bytes.Clone(raw) + return nil + }) + require.NoError(t, err) + require.True(t, found, "ledger %d", seq) + assert.Equal(t, want, got, "ledger %d", seq) + } +} + +func TestTxWithLedgerRaw_MissDoesNotRunFn(t *testing.T) { + ctx, reader, c0, c1 := sparseFixture(t) + tx, err := reader.NewTx(ctx) + require.NoError(t, err) + defer func() { _ = tx.Done() }() + + for _, seq := range []uint32{0, 1, c0.FirstLedger() - 1, c1.FirstLedger() + 1} { + ran := false + found, err := tx.WithLedgerRaw(context.Background(), seq, func([]byte) error { + ran = true + return nil + }) + assert.NoError(t, err) + assert.False(t, found, "ledger %d", seq) + assert.False(t, ran, "fn must not run for an absent ledger %d", seq) + } +} + func TestWithLedgerRaw_CallbackErrorSurfacesAsFound(t *testing.T) { ctx, reader, c0, _ := sparseFixture(t) boom := errors.New("boom") @@ -527,3 +561,37 @@ func TestWithLedgerRaw_CallbackErrorSurfacesAsFound(t *testing.T) { assert.ErrorIs(t, err, boom) assert.True(t, found) } + +func TestTxWithLedgerRaw_CallbackErrorSurfacesAsFound(t *testing.T) { + ctx, reader, c0, _ := sparseFixture(t) + tx, err := reader.NewTx(ctx) + require.NoError(t, err) + defer func() { _ = tx.Done() }() + + boom := errors.New("boom") + // found stays true: the ledger WAS there, the caller's own callback failed. + found, err := tx.WithLedgerRaw(context.Background(), c0.FirstLedger(), + func([]byte) error { return boom }) + assert.ErrorIs(t, err, boom) + assert.True(t, found) +} + +func TestTxWithLedgerRaw_StopsOnCanceledContext(t *testing.T) { + baseCtx, reader, c0, _ := sparseFixture(t) + tx, err := reader.NewTx(baseCtx) + require.NoError(t, err) + defer func() { _ = tx.Done() }() + + ctx, cancel := context.WithCancel(baseCtx) + _, err = tx.WithLedgerRaw(ctx, c0.FirstLedger(), func([]byte) error { return nil }) + require.NoError(t, err) + + cancel() + ran := false + _, err = tx.WithLedgerRaw(ctx, c0.FirstLedger()+1, func([]byte) error { + ran = true + return nil + }) + require.ErrorIs(t, err, context.Canceled) + assert.False(t, ran) +} diff --git a/cmd/stellar-rpc/internal/rpcv2/adapters/transaction_reader.go b/cmd/stellar-rpc/internal/rpcv2/adapters/transaction_reader.go index 1929384d5..ef34399b4 100644 --- a/cmd/stellar-rpc/internal/rpcv2/adapters/transaction_reader.go +++ b/cmd/stellar-rpc/internal/rpcv2/adapters/transaction_reader.go @@ -4,7 +4,6 @@ import ( "context" "errors" - "github.com/stellar/go-stellar-sdk/ingest" "github.com/stellar/go-stellar-sdk/xdr" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/observability" @@ -51,26 +50,8 @@ func (r *TransactionReader) GetTransaction(ctx context.Context, hash xdr.Hash) ( if !found { return store.Transaction{}, store.ErrNoTransaction } - return transactionFromView(txv), nil -} - -// transactionFromView reshapes the SDK's view into the serving contract, -// aliasing its byte fields. -// -// Only a compactView-produced view may be passed: the type does not carry that, -// and a view straight off the SDK still aliases the ledger it was read from. -func transactionFromView(txv ingest.LedgerTransactionView) store.Transaction { - return store.Transaction{ - TransactionHash: xdr.Hash(txv.Hash).HexString(), - Result: txv.Result, - Meta: txv.Meta, - Envelope: txv.Envelope, - Events: txv.DiagnosticEvents, - FeeBump: txv.FeeBump, - ApplicationOrder: txv.ApplicationOrder, - Successful: txv.Successful, - Ledger: store.LedgerInfo{Sequence: txv.LedgerSequence, CloseTime: txv.LedgerCloseTime}, - TransactionEvents: txv.TransactionEvents, - ContractEvents: txv.ContractEvents, - } + // Only a compactView-produced view may be reshaped here: the type does not + // carry that, and a view straight off the SDK still aliases the ledger it + // was read from — probe.GetTransaction guarantees it (see compactView). + return store.ParseTransactionView(txv), nil } diff --git a/cmd/stellar-rpc/internal/store/ledger.go b/cmd/stellar-rpc/internal/store/ledger.go index e589ae752..c5ad5a4b8 100644 --- a/cmd/stellar-rpc/internal/store/ledger.go +++ b/cmd/stellar-rpc/internal/store/ledger.go @@ -50,14 +50,15 @@ type WithLedgerRawFn func(raw []byte) error // LedgerReaderTx is a read-only snapshot of the ledger store. Call Done to // release it. // -// GetLedger is a walk, not a free-form point read. Call it with ascending, -// contiguous sequences, starting from the first call's sequence. Read at most -// methods.LedgerScanLimit ledgers per Tx. This is getTransactions' access -// pattern. The v1 (SQL) backend accepts any pattern; the v2 backend serves -// the walk from a forward iterator primed on the first call and fails loudly -// on anything else. +// WithLedgerRaw is a walk, not a free-form point read: calls must be +// ascending and contiguous from the first call's sequence, and read at most +// methods.LedgerScanLimit ledgers per Tx. The v1 (SQL) backend accepts any +// pattern, while the v2 backend only walks from a forward iterator primed on +// the first call. fn borrows the marshaled LCM under WithLedgerRawFn's loan +// terms. found=false means fn never ran; fn's own error comes back verbatim +// with found=true. type LedgerReaderTx interface { - GetLedger(ctx context.Context, sequence uint32) (xdr.LedgerCloseMeta, bool, error) + WithLedgerRaw(ctx context.Context, sequence uint32, fn WithLedgerRawFn) (bool, error) GetLedgerRange(ctx context.Context) (LedgerRange, error) BatchGetLedgers(ctx context.Context, start uint32, end uint32) ([]LedgerMetadataChunk, error) Done() error diff --git a/cmd/stellar-rpc/internal/store/transaction.go b/cmd/stellar-rpc/internal/store/transaction.go index 7fc05cd13..af81ed4f1 100644 --- a/cmd/stellar-rpc/internal/store/transaction.go +++ b/cmd/stellar-rpc/internal/store/transaction.go @@ -2,9 +2,8 @@ package store import ( "context" + "encoding/hex" "errors" - "fmt" - "strconv" "github.com/stellar/go-stellar-sdk/ingest" "github.com/stellar/go-stellar-sdk/xdr" @@ -32,87 +31,23 @@ type TransactionReader interface { GetTransaction(ctx context.Context, hash xdr.Hash) (Transaction, error) } -func ParseTransaction(lcm xdr.LedgerCloseMeta, ingestTx ingest.LedgerTransaction) (Transaction, error) { - var tx Transaction - var err error - - // - // On-the-fly ingestion: extract all of the fields, return best effort. - // - tx.FeeBump = ingestTx.Envelope.IsFeeBump() - applicationOrder, convErr := strconv.ParseInt(strconv.FormatUint(uint64(ingestTx.Index), 10), 10, 32) - if convErr != nil { - return tx, fmt.Errorf("transaction index %d exceeds supported range", ingestTx.Index) - } - tx.ApplicationOrder = int32(applicationOrder) - tx.Successful = ingestTx.Result.Successful() - tx.Ledger = LedgerInfo{ - Sequence: lcm.LedgerSequence(), - CloseTime: lcm.LedgerCloseTime(), - } - tx.TransactionHash = ingestTx.Result.TransactionHash.HexString() - - if tx.Result, err = ingestTx.Result.Result.MarshalBinary(); err != nil { - return tx, fmt.Errorf("couldn't encode transaction Result: %w", err) - } - if tx.Meta, err = ingestTx.UnsafeMeta.MarshalBinary(); err != nil { - return tx, fmt.Errorf("couldn't encode transaction UnsafeMeta: %w", err) - } - if tx.Envelope, err = ingestTx.Envelope.MarshalBinary(); err != nil { - return tx, fmt.Errorf("couldn't encode transaction Envelope: %w", err) - } - - allEvents, err := ingestTx.GetTransactionEvents() - if err != nil { - return tx, fmt.Errorf("couldn't encode transaction Events: %w", err) - } - - diagEvents, err := ingestTx.GetDiagnosticEvents() - if err != nil { - return tx, errors.Join(errors.New("couldn't encode diagnostic events"), err) - } - - tx.Events = make([][]byte, 0, len(diagEvents)) - for i, event := range diagEvents { - bytes, ierr := event.MarshalBinary() - if ierr != nil { - return tx, fmt.Errorf("couldn't encode transaction DiagnosticEvent %d: %w", i, ierr) - } - tx.Events = append(tx.Events, bytes) - } - - if err = parseEvents(allEvents, &tx); err != nil { - return tx, err - } - - return tx, err -} - -// parseEvents parses diagnostic, transaction and contract events -func parseEvents(allEvents ingest.TransactionEvents, tx *Transaction) error { - // encode TransactionEvents - tx.TransactionEvents = make([][]byte, 0, len(allEvents.TransactionEvents)) - for i, event := range allEvents.TransactionEvents { - bytes, ierr := event.MarshalBinary() - if ierr != nil { - return fmt.Errorf("couldn't encode TransactionEvent %d: %w", i, ierr) - } - tx.TransactionEvents = append(tx.TransactionEvents, bytes) - } - - // encode ContractEvents (slice of slices) - tx.ContractEvents = make([][][]byte, 0, len(allEvents.OperationEvents)) - for opIndex, opEvents := range allEvents.OperationEvents { - events := make([][]byte, 0, len(opEvents)) - for i, event := range opEvents { - bytes, ierr := event.MarshalBinary() - if ierr != nil { - return fmt.Errorf("couldn't encode ContractEvent %d for operation %d: %w", i, opIndex, ierr) - } - events = append(events, bytes) - } - - tx.ContractEvents = append(tx.ContractEvents, events) +// ParseTransactionView reshapes an SDK transaction view into a Transaction; the +// byte fields alias the view's buffer. +func ParseTransactionView(txView ingest.LedgerTransactionView) Transaction { + return Transaction{ + TransactionHash: hex.EncodeToString(txView.Hash[:]), + Result: txView.Result, + Meta: txView.Meta, + Envelope: txView.Envelope, + Events: txView.DiagnosticEvents, + FeeBump: txView.FeeBump, + ApplicationOrder: txView.ApplicationOrder, + Successful: txView.Successful, + Ledger: LedgerInfo{ + Sequence: txView.LedgerSequence, + CloseTime: txView.LedgerCloseTime, + }, + TransactionEvents: txView.TransactionEvents, + ContractEvents: txView.ContractEvents, } - return nil }