diff --git a/cmd/stellar-rpc/internal/methods/differential_test.go b/cmd/stellar-rpc/internal/methods/differential_test.go new file mode 100644 index 000000000..827e1d200 --- /dev/null +++ b/cmd/stellar-rpc/internal/methods/differential_test.go @@ -0,0 +1,362 @@ +package methods + +import ( + "context" + "encoding/json" + "errors" + "slices" + "testing" + + "github.com/creachadair/jrpc2" + "github.com/stretchr/testify/require" + + "github.com/stellar/go-stellar-sdk/network" + protocol "github.com/stellar/go-stellar-sdk/protocols/rpc" + "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" +) + +// Machinery shared by the view-migration differential suites: the two-sided +// comparator, the cursor-chain driver, the corpus builders and sqlite seeding. +// Each suite freezes its own reference path and assembles its own corpus. + +// diffFormats are the two response encodings every differential sweeps. +var diffFormats = []string{"", protocol.FormatJSON} + +// rpcMethod is the shape both sides of a differential share. +type rpcMethod[Req, Resp any] func(context.Context, Req) (Resp, error) + +// differential pairs a frozen reference implementation with the path under +// test. Both are called with the same request and must agree byte for byte. +type differential[Req, Resp any] struct { + want rpcMethod[Req, Resp] // the frozen reference + got rpcMethod[Req, Resp] // the path under test +} + +// assertSame runs both sides over req and asserts their responses serialize +// identically. Both sides must succeed: a reference error means a broken +// corpus cell, or a case that belongs in assertSameError. +func (d differential[Req, Resp]) assertSame(t *testing.T, req Req) Resp { + t.Helper() + wantResp, wantErr := d.want(context.TODO(), req) + require.NoError(t, wantErr, "reference path errored: broken corpus cell, or a case for assertSameError") + gotResp, gotErr := d.got(context.TODO(), req) + require.NoError(t, gotErr) + requireSameJSON(t, wantResp, gotResp) + return gotResp +} + +// assertSameError runs both sides over a request both are expected to reject +// and asserts they fail identically: same message and, for a *jrpc2.Error, +// the same code. Error behavior is wire-visible, so it is a differential +// axis of its own. +func (d differential[Req, Resp]) assertSameError(t *testing.T, req Req) { + t.Helper() + _, wantErr := d.want(context.TODO(), req) + require.Error(t, wantErr, "the reference path accepted a request assertSameError expects it to reject") + _, gotErr := d.got(context.TODO(), req) + require.Error(t, gotErr) + require.Equal(t, wantErr.Error(), gotErr.Error()) + var wantRPC, gotRPC *jrpc2.Error + if errors.As(wantErr, &wantRPC) { + require.ErrorAs(t, gotErr, &gotRPC) + require.Equal(t, wantRPC.Code, gotRPC.Code) + } +} + +// assertSameChain pages both sides from first, each following its OWN cursors +// through next (which returns false on the last page), and returns the page count. +func (d differential[Req, Resp]) assertSameChain( + t *testing.T, first Req, next func(Resp) (Req, bool), +) int { + t.Helper() + wantReq, gotReq := first, first + pages := 0 + for { + wantResp, err := d.want(context.TODO(), wantReq) + require.NoError(t, err, "reference page %d", pages) + gotResp, err := d.got(context.TODO(), gotReq) + require.NoError(t, err, "page %d", pages) + requireSameJSON(t, wantResp, gotResp, "page %d", pages) + + pages++ + require.Less(t, pages, 200, "paging did not terminate") + + // The pages are identical, so both sides make the same call here. + var more bool + if wantReq, more = next(wantResp); !more { + return pages + } + gotReq, _ = next(gotResp) + } +} + +// requireSameJSON asserts want and got serialize to the same bytes. +func requireSameJSON(t *testing.T, want, got any, msgAndArgs ...any) { + t.Helper() + wantJSON, err := json.Marshal(want) + require.NoError(t, err) + gotJSON, err := json.Marshal(got) + require.NoError(t, err) + // Byte equality, not JSONEq: a field that appears, vanishes or reorders must fail. + require.Equal(t, string(wantJSON), string(gotJSON), msgAndArgs...) //nolint:testifylint // see above +} + +// seedDifferentialDB writes corpus, a contiguous ledger run, and its events into a fresh sqlite store. +func seedDifferentialDB(t *testing.T, corpus []xdr.LedgerCloseMeta) *sqlitedb.DB { + t.Helper() + require.NotEmpty(t, corpus) + testDB := NewTestDB(t) + rw := sqlitedb.NewReadWriter(log.DefaultLogger, testDB, host.MakeNoOpDaemon(), 1000, passphrase) + first := corpus[0].LedgerSequence() + for i, lcm := range corpus { + require.Equal(t, first+uint32(i), lcm.LedgerSequence(), "the corpus must be one contiguous run") + tx, err := rw.NewTx(t.Context()) + require.NoError(t, err) + require.NoError(t, tx.LedgerWriter().InsertLedger(lcm)) + require.NoError(t, tx.EventWriter().InsertEvents(lcm)) + require.NoError(t, tx.Commit(lcm, nil)) + } + return testDB +} + +// +// ---- corpus builders ---- +// + +// diffTxSpec is one transaction in a corpus ledger: an envelope shape paired +// with an apply-processing meta. +type diffTxSpec struct { + envelope xdr.TransactionEnvelope + meta xdr.TransactionMeta + succeeds bool +} + +func diffSym(name string) xdr.ScVal { + sym := xdr.ScSymbol(name) + return xdr.ScVal{Type: xdr.ScValTypeScvSymbol, Sym: &sym} +} + +// diffEvent builds a V0-bodied event. A nil id is an event with no contract. +func diffEvent( + typ xdr.ContractEventType, id *xdr.ContractId, data xdr.ScVal, topics ...xdr.ScVal, +) xdr.ContractEvent { + return xdr.ContractEvent{ + ContractId: id, + Type: typ, + Body: xdr.ContractEventBody{ + V: 0, + V0: &xdr.ContractEventV0{Topics: topics, Data: data}, + }, + } +} + +// diffContractEvent is the one-topic contract event the transactions corpus +// sprinkles everywhere an event will do. +func diffContractEvent() xdr.ContractEvent { + val := diffSym("COUNTER") + id := xdr.ContractId{7} + return diffEvent(xdr.ContractEventTypeContract, &id, val, val) +} + +func diffTxEvent(stage xdr.TransactionEventStage, event xdr.ContractEvent) xdr.TransactionEvent { + return xdr.TransactionEvent{Stage: stage, Event: event} +} + +// diffClassicEnvelope is a plain (non-Soroban) v1 envelope: Tx.Ext stays at +// discriminant 0, which is what makes it classic. +func diffClassicEnvelope(acctSeq uint32) xdr.TransactionEnvelope { + env, 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 env +} + +// diffV0Envelope is a pre-protocol-13 TX_V0 envelope: an ed25519 source, no Ext. +func diffV0Envelope(acctSeq uint32) xdr.TransactionEnvelope { + env, err := xdr.NewTransactionEnvelope(xdr.EnvelopeTypeEnvelopeTypeTxV0, xdr.TransactionV0Envelope{ + Tx: xdr.TransactionV0{ + SourceAccountEd25519: xdr.Uint256{1}, + Fee: 1, + SeqNum: xdr.SequenceNumber(acctSeq), + }, + }) + if err != nil { + panic(err) + } + return env +} + +// diffFeeBumpEnvelope wraps inner in a fee bump. +func diffFeeBumpEnvelope(inner xdr.TransactionEnvelope) xdr.TransactionEnvelope { + return xdr.TransactionEnvelope{ + Type: xdr.EnvelopeTypeEnvelopeTypeTxFeeBump, + FeeBump: &xdr.FeeBumpTransactionEnvelope{ + Tx: xdr.FeeBumpTransaction{ + FeeSource: xdr.MustMuxedAddress("MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVAAAAAAAAAAAAAJLK"), + Fee: 200, + InnerTx: xdr.FeeBumpTransactionInnerTx{ + Type: xdr.EnvelopeTypeEnvelopeTypeTx, + V1: inner.V1, + }, + }, + }, + } +} + +// diffMetaV0 is the early-pubnet meta the legacy reader rejects and the view serves. +func diffMetaV0() xdr.TransactionMeta { + return xdr.TransactionMeta{V: 0, Operations: &[]xdr.OperationMeta{}} +} + +func diffMetaV1() xdr.TransactionMeta { + return xdr.TransactionMeta{V: 1, V1: &xdr.TransactionMetaV1{Operations: []xdr.OperationMeta{}}} +} + +// diffMetaV2 is the protocol 13 to 19 meta: no events anywhere. +func diffMetaV2() xdr.TransactionMeta { + return xdr.TransactionMeta{V: 2, V2: &xdr.TransactionMetaV2{Operations: []xdr.OperationMeta{}}} +} + +// diffMetaV3NoSoroban is the straggler corner: a V3 meta with no SorobanMeta +// at all. Paired with a Soroban envelope it is the one shape where the SDK's +// view extractor historically disagreed with the parsed reader on +// operation-slice arity, so the transactions corpus pins it deliberately. +func diffMetaV3NoSoroban() xdr.TransactionMeta { + return xdr.TransactionMeta{V: 3, Operations: &[]xdr.OperationMeta{}, V3: &xdr.TransactionMetaV3{}} +} + +func diffMetaV3WithEvents(events []xdr.ContractEvent, diags []xdr.DiagnosticEvent) xdr.TransactionMeta { + return xdr.TransactionMeta{ + V: 3, + Operations: &[]xdr.OperationMeta{}, + V3: &xdr.TransactionMetaV3{SorobanMeta: &xdr.SorobanTransactionMeta{ + Events: events, + DiagnosticEvents: diags, + ReturnValue: diffSym("COUNTER"), + }}, + } +} + +func diffMetaV4(ops []xdr.OperationMetaV2, txEvents []xdr.TransactionEvent, + diags []xdr.DiagnosticEvent, +) xdr.TransactionMeta { + return xdr.TransactionMeta{V: 4, V4: &xdr.TransactionMetaV4{ + Operations: ops, + Events: txEvents, + DiagnosticEvents: diags, + }} +} + +// diffResultFor builds the TransactionResultPair for spec: a fee bump carries +// an inner result pair, a plain transaction does not. +func diffResultFor(t *testing.T, spec diffTxSpec) xdr.TransactionResultPair { + t.Helper() + hash, err := network.HashTransactionInEnvelope(spec.envelope, NetworkPassphrase) + require.NoError(t, err) + + code := xdr.TransactionResultCodeTxSuccess + if !spec.succeeds { + code = xdr.TransactionResultCodeTxBadSeq + } + opResults := []xdr.OperationResult{} + res := xdr.TransactionResultResult{Code: code, Results: &opResults} + + if spec.envelope.Type == xdr.EnvelopeTypeEnvelopeTypeTxFeeBump { + innerHash, ierr := network.HashTransactionInEnvelope(xdr.TransactionEnvelope{ + Type: xdr.EnvelopeTypeEnvelopeTypeTx, V1: spec.envelope.FeeBump.Tx.InnerTx.V1, + }, NetworkPassphrase) + require.NoError(t, ierr) + outer := xdr.TransactionResultCodeTxFeeBumpInnerSuccess + if !spec.succeeds { + outer = xdr.TransactionResultCodeTxFeeBumpInnerFailed + } + res = xdr.TransactionResultResult{ + Code: outer, + InnerResultPair: &xdr.InnerTransactionResultPair{ + TransactionHash: innerHash, + Result: xdr.InnerTransactionResult{ + FeeCharged: 100, + Result: xdr.InnerTransactionResultResult{Code: code, Results: &opResults}, + }, + }, + } + } + + return xdr.TransactionResultPair{ + TransactionHash: hash, + Result: xdr.TransactionResult{FeeCharged: 100, Result: res}, + } +} + +// diffLCM assembles a LedgerCloseMeta of wire version 0, 1 or 2 at sequence +// seq holding specs in apply order; no specs is an empty ledger. Every version +// matters: V0 carries a plain TransactionSet, and V1 and V2 differ in their +// TxProcessing element type, so the view dispatcher walks each with different code. +func diffLCM(t *testing.T, version int32, seq uint32, specs ...diffTxSpec) xdr.LedgerCloseMeta { + t.Helper() + envs := diffTxSetEnvelopes(specs) + header := xdr.LedgerHeaderHistoryEntry{Header: xdr.LedgerHeader{ + ScpValue: xdr.StellarValue{CloseTime: xdr.TimePoint(ledgerCloseTime(seq))}, + LedgerSeq: xdr.Uint32(seq), + }} + txSet := xdr.GeneralizedTransactionSet{V: 1, V1TxSet: &xdr.TransactionSetV1{ + PreviousLedgerHash: xdr.Hash{1}, + Phases: []xdr.TransactionPhase{diffClassicPhase(envs)}, + }} + + if version == 2 { + proc := make([]xdr.TransactionResultMetaV1, 0, len(specs)) + for _, spec := range specs { + proc = append(proc, xdr.TransactionResultMetaV1{Result: diffResultFor(t, spec), TxApplyProcessing: spec.meta}) + } + return xdr.LedgerCloseMeta{V: 2, V2: &xdr.LedgerCloseMetaV2{ + LedgerHeader: header, TxSet: txSet, TxProcessing: proc, + }} + } + // V0 and V1 share the TxProcessing element type. + proc := make([]xdr.TransactionResultMeta, 0, len(specs)) + for _, spec := range specs { + proc = append(proc, xdr.TransactionResultMeta{Result: diffResultFor(t, spec), TxApplyProcessing: spec.meta}) + } + if version == 0 { + return xdr.LedgerCloseMeta{V: 0, V0: &xdr.LedgerCloseMetaV0{ + LedgerHeader: header, + TxSet: xdr.TransactionSet{PreviousLedgerHash: xdr.Hash{1}, Txs: envs}, + TxProcessing: proc, + }} + } + return xdr.LedgerCloseMeta{V: 1, V1: &xdr.LedgerCloseMetaV1{ + LedgerHeader: header, TxSet: txSet, TxProcessing: proc, + }} +} + +// diffTxSetEnvelopes is specs' envelopes in TxSet order: reversed, since real +// ledgers do not keep apply order there, so a positional/zip pairing +// regression fails on every multi-transaction ledger. +func diffTxSetEnvelopes(specs []diffTxSpec) []xdr.TransactionEnvelope { + envs := make([]xdr.TransactionEnvelope, 0, len(specs)) + for _, spec := range slices.Backward(specs) { + envs = append(envs, spec.envelope) + } + return envs +} + +// diffClassicPhase is a V0 phase of one fee group holding envs. +func diffClassicPhase(envs []xdr.TransactionEnvelope) xdr.TransactionPhase { + components := []xdr.TxSetComponent{{ + Type: xdr.TxSetComponentTypeTxsetCompTxsMaybeDiscountedFee, + TxsMaybeDiscountedFee: &xdr.TxSetComponentTxsMaybeDiscountedFee{Txs: envs}, + }} + return xdr.TransactionPhase{V: 0, V0Components: &components} +} diff --git a/cmd/stellar-rpc/internal/methods/get_transactions.go b/cmd/stellar-rpc/internal/methods/get_transactions.go index 96c3eea64..719667b16 100644 --- a/cmd/stellar-rpc/internal/methods/get_transactions.go +++ b/cmd/stellar-rpc/internal/methods/get_transactions.go @@ -131,9 +131,10 @@ func (h transactionsRPCHandler) processTransactionsInLedger( if ledgerSeqInt32 == start.LedgerSequence { startTxIdx = int(start.TransactionOrder) } - cursor := toid.New(ledgerSeqInt32, int32(startTxIdx-1), 1) + cursor := toid.New(ledgerSeqInt32, 0, 1) remaining := limitInt - len(*txns) if remaining <= 0 { + cursor.TransactionOrder = int32(startTxIdx - 1) return cursor, true, nil } diff --git a/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go b/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go new file mode 100644 index 000000000..b0ec747b7 --- /dev/null +++ b/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go @@ -0,0 +1,789 @@ +package methods + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "slices" + "strconv" + "testing" + + "github.com/creachadair/jrpc2" + "github.com/stretchr/testify/require" + + "github.com/stellar/go-stellar-sdk/ingest" + protocol "github.com/stellar/go-stellar-sdk/protocols/rpc" + "github.com/stellar/go-stellar-sdk/support/log" + "github.com/stellar/go-stellar-sdk/toid" + "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/store" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/xdr2json" +) + +// This file is the correctness harness for the getTransactions page loop's +// move from "unmarshal the whole LedgerCloseMeta, then range over it" to "walk +// the raw bytes through the SDK's zero-copy views." Both sides read sqlite, so +// the suite pins the shared handler's extraction, not the v2 adapter's walk. +// The latter is pinned in rpcv2/adapters with its own tests. +// +// legacyGetTransactionsByLedgerSequence and legacyProcessTransactionsInLedger +// below are the decode-based extraction as it stood before that change, +// reconstructed here rather than left behind in the production file: they +// exist only as the differential's reference, and the tests assert that the +// two paths' protocol.GetTransactionsResponse values serialize to byte- +// identical JSON over a corpus that sweeps ledger and meta versions, tx-set +// phases, envelope shapes, event shapes, page boundaries, empty ledgers and +// cursor round-trips. +// Shared machinery lives in differential_test.go. + +// legacyGetTransactionsByLedgerSequence is the pre-view-walk pagination loop: +// same handler, same cursor math, but reading each ledger's bytes through +// LedgerReaderTx.WithLedgerRaw and decoding them into an xdr.LedgerCloseMeta. +func legacyGetTransactionsByLedgerSequence( + ctx context.Context, h transactionsRPCHandler, request protocol.GetTransactionsRequest, +) (protocol.GetTransactionsResponse, error) { + readTx, err := h.ledgerReader.NewTx(ctx) + if err != nil { + return protocol.GetTransactionsResponse{}, &jrpc2.Error{Code: jrpc2.InternalError, Message: err.Error()} + } + defer func() { _ = readTx.Done() }() + + ledgerRange, err := readTx.GetLedgerRange(ctx) + if err != nil { + return protocol.GetTransactionsResponse{}, &jrpc2.Error{Code: jrpc2.InternalError, Message: err.Error()} + } + + if err := request.IsValid(h.maxLimit, ledgerRange.ToLedgerSeqRange()); err != nil { + return protocol.GetTransactionsResponse{}, &jrpc2.Error{Code: jrpc2.InvalidRequest, Message: err.Error()} + } + + start, requestCursor, limit, err := h.initializePagination(request) + if err != nil { + return protocol.GetTransactionsResponse{}, err + } + + txns := make([]protocol.TransactionInfo, 0, limit) + var done bool + cursor := toid.New(0, 0, 0) + endLedger := min(int64(ledgerRange.LastLedger.Sequence), int64(start.LedgerSequence)+LedgerScanLimit-1) + for ledgerSeq := start.LedgerSequence; int64(ledgerSeq) <= endLedger; ledgerSeq++ { + if ledgerSeq < 0 { + return protocol.GetTransactionsResponse{}, &jrpc2.Error{ + Code: jrpc2.InvalidParams, + Message: "cursor ledger sequence cannot be negative", + } + } + var ledger xdr.LedgerCloseMeta + found, gerr := readTx.WithLedgerRaw(ctx, uint32(ledgerSeq), ledger.UnmarshalBinary) + if gerr != nil { + return protocol.GetTransactionsResponse{}, &jrpc2.Error{Code: jrpc2.InternalError, Message: gerr.Error()} + } else if !found { + return protocol.GetTransactionsResponse{}, &jrpc2.Error{ + Code: jrpc2.InvalidParams, + Message: fmt.Sprintf("database does not contain metadata for ledger: %d", ledgerSeq), + } + } + + cursor, done, err = legacyProcessTransactionsInLedger(h, ledger, start, &txns, limit, request.Format) + if err != nil { + return protocol.GetTransactionsResponse{}, err + } + if done { + break + } + } + + if requestCursor != nil && cursor.ToInt64() < requestCursor.ToInt64() { + cursor = requestCursor + } + + return protocol.GetTransactionsResponse{ + Transactions: txns, + LatestLedger: ledgerRange.LastLedger.Sequence, + LatestLedgerCloseTime: ledgerRange.LastLedger.CloseTime, + OldestLedger: ledgerRange.FirstLedger.Sequence, + OldestLedgerCloseTime: ledgerRange.FirstLedger.CloseTime, + Cursor: cursor.String(), + }, nil +} + +// legacyProcessTransactionsInLedger is the pre-view-walk per-ledger extraction: +// ingest.NewLedgerTransactionReaderFromLedgerCloseMeta over a decoded ledger, +// then store.ParseTransaction per transaction. Everything downstream of that — +// rendering a store.Transaction into a page entry — is the production +// renderer, unchanged by this migration and shared here deliberately. +func legacyProcessTransactionsInLedger( + h transactionsRPCHandler, ledger xdr.LedgerCloseMeta, start toid.ID, + txns *[]protocol.TransactionInfo, limit uint, format string, +) (*toid.ID, bool, error) { + limitInt, err := strconv.Atoi(strconv.FormatUint(uint64(limit), 10)) + if err != nil { + return nil, false, &jrpc2.Error{Code: jrpc2.InvalidParams, Message: err.Error()} + } + + reader, err := ingest.NewLedgerTransactionReaderFromLedgerCloseMeta(h.networkPassphrase, ledger) + if err != nil { + 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()} + } + 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()} + } + } + + txCount := ledger.CountTransactions() + cursor := toid.New(ledgerSeqInt32, 0, 1) + for i := startTxIdx; i <= txCount; i++ { + cursor.TransactionOrder = int32(i) + + ingestTx, rerr := reader.Read() + if rerr != nil { + if errors.Is(rerr, io.EOF) { + break + } + return nil, false, &jrpc2.Error{Code: jrpc2.InvalidParams, Message: rerr.Error()} + } + + tx, perr := legacyParseTransaction(ledger, ingestTx) + if perr != nil { + return nil, false, &jrpc2.Error{Code: jrpc2.InternalError, Message: perr.Error()} + } + + // The renderer is shared with the production path on purpose: it is + // pure formatting of a store.Transaction and this change does not + // touch it, so what the differential compares is the EXTRACTION that + // produced tx, not the rendering of it. + txInfo, ferr := transactionInfo(tx, format) + if ferr != nil { + return nil, false, &jrpc2.Error{Code: jrpc2.InternalError, Message: ferr.Error()} + } + + *txns = append(*txns, txInfo) + if len(*txns) >= limitInt { + return cursor, true, nil + } + } + + return cursor, false, nil +} + +// legacyParseTransaction is the decode-based store.ParseTransaction as it +// stood before the view migration, frozen here as the reference extraction. +func legacyParseTransaction(lcm xdr.LedgerCloseMeta, ingestTx ingest.LedgerTransaction) (store.Transaction, error) { + var tx store.Transaction + var err error + + tx.FeeBump = ingestTx.Envelope.IsFeeBump() + tx.ApplicationOrder = int32(ingestTx.Index) + tx.Successful = ingestTx.Result.Successful() + tx.Ledger = store.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, err + } + if tx.Meta, err = ingestTx.UnsafeMeta.MarshalBinary(); err != nil { + return tx, err + } + if tx.Envelope, err = ingestTx.Envelope.MarshalBinary(); err != nil { + return tx, err + } + + allEvents, err := ingestTx.GetTransactionEvents() + if err != nil { + return tx, err + } + diagEvents, err := ingestTx.GetDiagnosticEvents() + if err != nil { + return tx, err + } + tx.Events = make([][]byte, 0, len(diagEvents)) + for _, event := range diagEvents { + raw, ierr := event.MarshalBinary() + if ierr != nil { + return tx, ierr + } + tx.Events = append(tx.Events, raw) + } + tx.TransactionEvents = make([][]byte, 0, len(allEvents.TransactionEvents)) + for _, event := range allEvents.TransactionEvents { + raw, ierr := event.MarshalBinary() + if ierr != nil { + return tx, ierr + } + tx.TransactionEvents = append(tx.TransactionEvents, raw) + } + tx.ContractEvents = make([][][]byte, 0, len(allEvents.OperationEvents)) + for _, opEvents := range allEvents.OperationEvents { + events := make([][]byte, 0, len(opEvents)) + for _, event := range opEvents { + raw, ierr := event.MarshalBinary() + if ierr != nil { + return tx, ierr + } + events = append(events, raw) + } + tx.ContractEvents = append(tx.ContractEvents, events) + } + return tx, nil +} + +// +// ---- the corpus ---- +// + +// transactionsCorpus is the contiguous ledger run [transactionsCorpusFirst, +// transactionsCorpusLast] the differential sweeps over. +// Every ledger is a deliberate shape; the comment on each says which axis it +// covers. Account sequence numbers are unique across the corpus so no two +// transactions share an envelope hash (the TxSet is paired to TxProcessing by +// hash, so a collision would hide a mispairing). +func transactionsCorpus(t *testing.T) []xdr.LedgerCloseMeta { + t.Helper() + ev := diffContractEvent() + // A second, distinct payload: a reversed event order inside one list must show. + ev2 := diffEvent(xdr.ContractEventTypeContract, ev.ContractId, diffSym("SECOND"), diffSym("SECOND")) + diag := xdr.DiagnosticEvent{InSuccessfulContractCall: true, Event: ev} + failedDiag := xdr.DiagnosticEvent{InSuccessfulContractCall: false, Event: ev} + txEvent := diffTxEvent(xdr.TransactionEventStageTransactionEventStageAfterAllTxs, ev) + + return []xdr.LedgerCloseMeta{ + // 1: V3 meta with NO SorobanMeta on a Soroban envelope (the straggler + // corner) plus a second, distinct tx. Unique hashes so the pre-existing + // duplicate-hash fixture shape stays pinned by the handler tests. + diffLCM(t, 2, 101, + diffTxSpec{txEnvelope(198), diffMetaV3NoSoroban(), true}, + diffTxSpec{diffClassicEnvelope(199), diffMetaV1(), false}, + ), + // 2: LCM V1, classic envelopes, V1 meta — no events anywhere. + diffLCM(t, 1, 102, + diffTxSpec{diffClassicEnvelope(200), diffMetaV1(), true}, + diffTxSpec{diffClassicEnvelope(201), diffMetaV1(), false}, + ), + // 3: an empty ledger mid-corpus — the page must walk straight past it. + diffLCM(t, 1, 103), + // 4: LCM V2, Soroban envelope, V3 meta WITH SorobanMeta: contract + // events and diagnostic events both present. + diffLCM(t, 2, 104, + diffTxSpec{txEnvelope(202), diffMetaV3WithEvents( + []xdr.ContractEvent{ev, ev2}, []xdr.DiagnosticEvent{diag, failedDiag}), true}, + ), + // 5: V3 SorobanMeta present but with no events at all — the "with + // SorobanMeta, empty lists" case, distinct from the absent one. + diffLCM(t, 2, 105, + diffTxSpec{txEnvelope(203), diffMetaV3WithEvents(nil, nil), true}, + ), + // 6: V4 meta — per-operation events across two operations, top-level + // transaction events, and diagnostics. + diffLCM(t, 2, 106, + diffTxSpec{diffClassicEnvelope(204), diffMetaV4( + []xdr.OperationMetaV2{{Events: []xdr.ContractEvent{ev}}, {}}, + []xdr.TransactionEvent{txEvent}, + []xdr.DiagnosticEvent{diag}, + ), true}, + diffTxSpec{diffClassicEnvelope(205), diffMetaV4(nil, nil, nil), false}, + ), + // 7: fee bumps — over a classic inner and over Soroban inners, two + // succeeding and one failing, on V4 and V3 metas. + diffLCM(t, 2, 107, + diffTxSpec{ + diffFeeBumpEnvelope(diffClassicEnvelope(206)), + diffMetaV4([]xdr.OperationMetaV2{{Events: []xdr.ContractEvent{ev}}}, nil, nil), true, + }, + diffTxSpec{ + diffFeeBumpEnvelope(txEnvelope(207)), + diffMetaV3WithEvents([]xdr.ContractEvent{ev}, []xdr.DiagnosticEvent{diag}), false, + }, + diffTxSpec{diffFeeBumpEnvelope(txEnvelope(208)), diffMetaV3NoSoroban(), true}, + ), + // 8: five transactions in one ledger, so a limit can land anywhere + // inside it and the next page has to resume mid-ledger. + diffLCM(t, 2, 108, + diffTxSpec{diffClassicEnvelope(209), diffMetaV1(), true}, + diffTxSpec{txEnvelope(210), diffMetaV3NoSoroban(), false}, + diffTxSpec{txEnvelope(211), diffMetaV3WithEvents([]xdr.ContractEvent{ev}, nil), true}, + diffTxSpec{diffClassicEnvelope(212), diffMetaV4( + []xdr.OperationMetaV2{{Events: []xdr.ContractEvent{ev, ev2}}}, nil, + []xdr.DiagnosticEvent{diag}), true}, + diffTxSpec{diffFeeBumpEnvelope(diffClassicEnvelope(213)), diffMetaV1(), false}, + ), + // 9: LCM V1 carrying a V3 meta — the older ledger envelope with a + // newer transaction meta, which real history contains. + diffLCM(t, 1, 109, + diffTxSpec{txEnvelope(214), diffMetaV3WithEvents( + []xdr.ContractEvent{ev}, []xdr.DiagnosticEvent{diag}), true}, + diffTxSpec{diffClassicEnvelope(215), diffMetaV3NoSoroban(), true}, + ), + // 10: LCM V0 with a plain TransactionSet and TX_V0 envelopes on V1 meta: + // the pre-protocol-13 shape, which the view walks through v0TxSetEnvelopes. + diffLCM(t, 0, 110, + diffTxSpec{diffV0Envelope(216), diffMetaV1(), true}, + diffTxSpec{diffV0Envelope(217), diffMetaV1(), false}, + ), + // 11: LCM V0 still, with V2 meta over v1, fee-bump and V0 envelopes: + // the protocol 13 to 19 shape. + diffLCM(t, 0, 111, + diffTxSpec{diffClassicEnvelope(218), diffMetaV2(), true}, + diffTxSpec{diffFeeBumpEnvelope(diffClassicEnvelope(219)), diffMetaV2(), false}, + diffTxSpec{diffV0Envelope(220), diffMetaV2(), true}, + ), + // 12: LCM V2 whose set carries both phase kinds: classic in the V0 + // phase, Soroban in the protocol-23 parallel phase. + parallelPhaseLedger(t, 112, + []diffTxSpec{ + {diffClassicEnvelope(221), diffMetaV4(nil, nil, nil), true}, + {diffClassicEnvelope(222), diffMetaV4([]xdr.OperationMetaV2{{Events: []xdr.ContractEvent{ev}}}, nil, nil), false}, + }, + diffTxSpec{txEnvelope(223), diffMetaV4( + []xdr.OperationMetaV2{{Events: []xdr.ContractEvent{ev2, ev}}}, + []xdr.TransactionEvent{txEvent}, + []xdr.DiagnosticEvent{diag}, + ), true}, + diffTxSpec{txEnvelope(224), diffMetaV4(nil, nil, []xdr.DiagnosticEvent{failedDiag}), false}, + diffTxSpec{txEnvelope(225), diffMetaV4([]xdr.OperationMetaV2{{Events: []xdr.ContractEvent{ev2}}}, nil, nil), true}, + diffTxSpec{diffFeeBumpEnvelope(txEnvelope(226)), diffMetaV4(nil, nil, []xdr.DiagnosticEvent{diag}), true}, + ), + // 13: another empty ledger, on LCM V2 and at the tip, so a walk that + // runs off the end of the corpus ends on one. + diffLCM(t, 2, 113), + } +} + +// transactionsCorpusFirst / transactionsCorpusLast bracket transactionsCorpus. +const ( + transactionsCorpusFirst = 101 + transactionsCorpusLast = 113 +) + +// parallelPhaseLedger is the protocol-23 shape at seq: classic stays in the V0 +// phase while Soroban transactions a to d run in a parallel phase of two +// stages, the second holding two clusters, with c and d dependent. +func parallelPhaseLedger( + t *testing.T, seq uint32, classic []diffTxSpec, a, b, c, d diffTxSpec, +) xdr.LedgerCloseMeta { + t.Helper() + lcm := diffLCM(t, 2, seq, append(slices.Clone(classic), a, b, c, d)...) + lcm.V2.TxSet.V1TxSet.Phases = []xdr.TransactionPhase{ + diffClassicPhase(diffTxSetEnvelopes(classic)), + {V: 1, ParallelTxsComponent: &xdr.ParallelTxsComponent{ + ExecutionStages: []xdr.ParallelTxExecutionStage{ + {xdr.DependentTxCluster{a.envelope}}, + {xdr.DependentTxCluster{b.envelope}, xdr.DependentTxCluster{c.envelope, d.envelope}}, + }, + }}, + } + return lcm +} + +type transactionsDifferential = differential[protocol.GetTransactionsRequest, protocol.GetTransactionsResponse] + +// newTransactionsDifferential pairs the frozen reference with the production +// handler, both reading testDB. +func newTransactionsDifferential(testDB *sqlitedb.DB) transactionsDifferential { + h := transactionsRPCHandler{ + ledgerReader: sqlitedb.NewLedgerReader(testDB), + maxLimit: 100, + defaultLimit: 10, + networkPassphrase: NetworkPassphrase, + } + return transactionsDifferential{ + want: func(ctx context.Context, req protocol.GetTransactionsRequest) (protocol.GetTransactionsResponse, error) { + return legacyGetTransactionsByLedgerSequence(ctx, h, req) + }, + got: h.getTransactionsByLedgerSequence, + } +} + +func seededTransactionsDifferential(t *testing.T) transactionsDifferential { + t.Helper() + corpus := transactionsCorpus(t) + require.Len(t, corpus, transactionsCorpusLast-transactionsCorpusFirst+1) + return newTransactionsDifferential(seedDifferentialDB(t, corpus)) +} + +// TestGetTransactions_ViewWalkErrorParity pins that both paths reject an +// invalid request identically: same message, same jrpc2 code. Only the cursor +// below the oldest ledger reaches the walk: ValidatePagination does not +// range-check cursors, so a poller resuming after retention moved past it gets +// the walk's own miss on both sides. +func TestGetTransactions_ViewWalkErrorParity(t *testing.T) { + diff := seededTransactionsDifferential(t) + page := func(cursor string, limit uint) *protocol.LedgerPaginationOptions { + return &protocol.LedgerPaginationOptions{Cursor: cursor, Limit: limit} + } + belowOldest := toid.New(transactionsCorpusFirst-1, 0, 1).String() + + for name, req := range map[string]protocol.GetTransactionsRequest{ + "start below oldest": {StartLedger: transactionsCorpusFirst - 1}, + "start above latest": {StartLedger: transactionsCorpusLast + 1}, + "cursor below oldest": {Pagination: page(belowOldest, 10)}, + "malformed cursor": {Pagination: page("abc", 10)}, + "limit above max": {StartLedger: transactionsCorpusFirst, Pagination: page("", 101)}, + "invalid format": {StartLedger: transactionsCorpusFirst, Format: "yaml"}, + } { + t.Run(name, func(t *testing.T) { + diff.assertSameError(t, req) + }) + } + + // The exact answer a retention follow-up must keep: the walk's miss, not a range error. + _, err := diff.got(t.Context(), protocol.GetTransactionsRequest{Pagination: page(belowOldest, 10)}) + var rpcErr *jrpc2.Error + require.ErrorAs(t, err, &rpcErr) + require.Equal(t, jrpc2.InvalidParams, rpcErr.Code) + require.Equal(t, + fmt.Sprintf("database does not contain metadata for ledger: %d", transactionsCorpusFirst-1), rpcErr.Message) +} + +// TestGetTransactions_ViewWalkMatchesParsedPath sweeps start ledgers, page +// limits and both response formats over the corpus, asserting byte-identical +// responses. The limits are chosen so pages end inside a ledger as well as on +// its boundary. +func TestGetTransactions_ViewWalkMatchesParsedPath(t *testing.T) { + diff := seededTransactionsDifferential(t) + limits := []uint{1, 2, 3, 4, 5, 7, 11, 100} + + for _, format := range diffFormats { + for startLedger := transactionsCorpusFirst; startLedger <= transactionsCorpusLast; startLedger++ { + for _, limit := range limits { + name := fmt.Sprintf("format=%q/start=%d/limit=%d", format, startLedger, limit) + t.Run(name, func(t *testing.T) { + diff.assertSame(t, protocol.GetTransactionsRequest{ + Format: format, + StartLedger: uint32(startLedger), + Pagination: &protocol.LedgerPaginationOptions{Limit: limit}, + }) + }) + } + } + } +} + +// TestGetTransactions_ViewWalkMatchesParsedPath_DefaultPagination covers the +// requests that carry no pagination block at all, so the handler's default +// limit applies. +func TestGetTransactions_ViewWalkMatchesParsedPath_DefaultPagination(t *testing.T) { + diff := seededTransactionsDifferential(t) + + for _, format := range diffFormats { + for startLedger := transactionsCorpusFirst; startLedger <= transactionsCorpusLast; startLedger++ { + t.Run(fmt.Sprintf("format=%q/start=%d", format, startLedger), func(t *testing.T) { + diff.assertSame(t, protocol.GetTransactionsRequest{ + Format: format, + StartLedger: uint32(startLedger), + }) + }) + } + } +} + +// TestGetTransactions_ViewWalkCursorRoundTrip pages through the whole corpus +// with each extraction driving its own cursor chain. An empty page ends the +// chain, and the corpus must take more than one page to get there. +func TestGetTransactions_ViewWalkCursorRoundTrip(t *testing.T) { + diff := seededTransactionsDifferential(t) + + for _, limit := range []uint{1, 2, 3, 5} { + t.Run(fmt.Sprintf("limit=%d", limit), func(t *testing.T) { + first := protocol.GetTransactionsRequest{ + StartLedger: transactionsCorpusFirst, + Pagination: &protocol.LedgerPaginationOptions{Limit: limit}, + } + next := func(page protocol.GetTransactionsResponse) (protocol.GetTransactionsRequest, bool) { + if len(page.Transactions) == 0 { + return protocol.GetTransactionsRequest{}, false + } + return protocol.GetTransactionsRequest{ + Pagination: &protocol.LedgerPaginationOptions{Cursor: page.Cursor, Limit: limit}, + }, true + } + pages := diff.assertSameChain(t, first, next) + require.Greater(t, pages, 1, "the corpus must take more than one page") + }) + } +} + +// TestGetTransactions_ViewWalkMatchesParsedPath_Cursors starts each request +// from an explicit cursor, including cursors that land mid-ledger, on a +// ledger's last transaction, past a ledger's last transaction, and past the +// tip (where the request's own cursor has to be echoed back). The server issues +// operation order 1; 0 and 2 are client-built, and the echo must return their +// exact bytes, so all three are swept. +func TestGetTransactions_ViewWalkMatchesParsedPath_Cursors(t *testing.T) { + diff := seededTransactionsDifferential(t) + + for ledger := transactionsCorpusFirst; ledger <= transactionsCorpusLast; ledger++ { + for txOrder := range 7 { + for opOrder := range int32(3) { + for _, limit := range []uint{1, 3, 10} { + cursor := toid.New(int32(ledger), int32(txOrder), opOrder).String() + name := fmt.Sprintf("cursor=%d.%d.%d/limit=%d", ledger, txOrder, opOrder, limit) + t.Run(name, func(t *testing.T) { + diff.assertSame(t, protocol.GetTransactionsRequest{ + Pagination: &protocol.LedgerPaginationOptions{Cursor: cursor, Limit: limit}, + }) + }) + } + } + } + } +} + +// TestGetTransactions_ViewWalkMatchesParsedPath_EmptyLedgersOnly pins the +// all-empty corpus separately: with no transaction anywhere, the response is +// driven entirely by the cursor math, which the view walk must not have +// shifted. +func TestGetTransactions_ViewWalkMatchesParsedPath_EmptyLedgersOnly(t *testing.T) { + diff := newTransactionsDifferential(setupDBNoTxs(t, 5)) + + for start := 1; start <= 5; start++ { + for _, limit := range []uint{1, 10} { + t.Run(fmt.Sprintf("start=%d/limit=%d", start, limit), func(t *testing.T) { + diff.assertSame(t, protocol.GetTransactionsRequest{ + StartLedger: uint32(start), + Pagination: &protocol.LedgerPaginationOptions{Limit: limit}, + }) + }) + } + } +} + +// TestGetTransactions_ViewWalkLegacyV0MetaDivergence pins this migration's one +// intentional divergence: a TransactionMeta V0 transaction (early pubnet +// history) failed the reference's whole page, and the view walk serves it +// event-free beside its ledger-mates. Seeded by hand: seedDifferentialDB's event +// writer decodes through the reference's reader and rejects V0 the same way. +func TestGetTransactions_ViewWalkLegacyV0MetaDivergence(t *testing.T) { + lcm := diffLCM(t, 0, 101, + diffTxSpec{diffV0Envelope(301), diffMetaV0(), true}, + diffTxSpec{diffV0Envelope(302), diffMetaV1(), false}, + ) + testDB := NewTestDB(t) + rw := sqlitedb.NewReadWriter(log.DefaultLogger, testDB, host.MakeNoOpDaemon(), 100, passphrase) + write, err := rw.NewTx(t.Context()) + require.NoError(t, err) + require.NoError(t, write.LedgerWriter().InsertLedger(lcm)) + require.NoError(t, write.Commit(lcm, nil)) + diff := newTransactionsDifferential(testDB) + + for _, format := range diffFormats { + t.Run(fmt.Sprintf("format=%q", format), func(t *testing.T) { + req := protocol.GetTransactionsRequest{Format: format, StartLedger: 101} + _, wantErr := diff.want(t.Context(), req) + got, err := diff.got(t.Context(), req) + require.NoError(t, err) + var rpcErr *jrpc2.Error + require.ErrorAs(t, wantErr, &rpcErr) + require.Equal(t, jrpc2.InternalError, rpcErr.Code) + require.Contains(t, rpcErr.Message, "unsupported TransactionMeta version: 0") + + require.Len(t, got.Transactions, 2, "the V0 transaction must not sink its ledger-mates") + v0 := got.Transactions[0] + require.Equal(t, int32(1), v0.ApplicationOrder) + require.Equal(t, protocol.TransactionStatusSuccess, v0.Status) + require.Empty(t, v0.DiagnosticEventsXDR) + require.Empty(t, v0.DiagnosticEventsJSON) + require.Empty(t, v0.Events.ContractEventsXDR) + require.Empty(t, v0.Events.ContractEventsJSON) + require.Empty(t, v0.Events.TransactionEventsXDR) + require.Empty(t, v0.Events.TransactionEventsJSON) + }) + } + + // Paging in past the V0 transaction, the two sides agree again: the + // divergence is that one transaction, not the ledger. + diff.assertSame(t, protocol.GetTransactionsRequest{ + Pagination: &protocol.LedgerPaginationOptions{Cursor: toid.New(101, 1, 1).String()}, + }) +} + +// TestGetTransactions_ViewWalkCorpusIsNotVacuous guards the differential +// itself: a corpus that silently produced no transactions, or only one shape +// of them, would make every comparison above pass for the wrong reason. +func TestGetTransactions_ViewWalkCorpusIsNotVacuous(t *testing.T) { + diff := seededTransactionsDifferential(t) + + resp, err := diff.got(context.TODO(), protocol.GetTransactionsRequest{ + StartLedger: transactionsCorpusFirst, + Pagination: &protocol.LedgerPaginationOptions{Limit: 100}, + }) + require.NoError(t, err) + require.GreaterOrEqual(t, len(resp.Transactions), 25, "the corpus must carry real transactions") + + var feeBumps, failed, withContractEvents, withTxEvents, withDiagnostics, v0Envelopes, v2Metas int + for _, tx := range resp.Transactions { + var env xdr.TransactionEnvelope + require.NoError(t, xdr.SafeUnmarshalBase64(tx.EnvelopeXDR, &env)) + if env.Type == xdr.EnvelopeTypeEnvelopeTypeTxV0 { + v0Envelopes++ + } + var meta xdr.TransactionMeta + require.NoError(t, xdr.SafeUnmarshalBase64(tx.ResultMetaXDR, &meta)) + if meta.V == 2 { + v2Metas++ + } + if tx.FeeBump { + feeBumps++ + } + if tx.Status == protocol.TransactionStatusFailed { + failed++ + } + for _, op := range tx.Events.ContractEventsXDR { + if len(op) > 0 { + withContractEvents++ + break + } + } + if len(tx.Events.TransactionEventsXDR) > 0 { + withTxEvents++ + } + if len(tx.DiagnosticEventsXDR) > 0 { + withDiagnostics++ + } + } + require.Positive(t, feeBumps, "fee-bump transactions") + require.Positive(t, failed, "failed transactions") + require.Positive(t, withContractEvents, "transactions with contract events") + require.Positive(t, withTxEvents, "transactions with transaction events") + require.Positive(t, withDiagnostics, "transactions with diagnostic events") + require.Positive(t, v0Envelopes, "TX_V0 envelopes") + require.Positive(t, v2Metas, "V2 metas") +} + +// TestTransactionInfo_FieldMapping pins the renderer both extractions share. +// The differential above cannot see a bug in shared code — it would show up +// identically on both sides — so the renderer's field-by-field wiring is +// pinned here instead, with every byte source distinct so that crossing any +// two of them fails. +// +//nolint:funlen // one field-by-field pin; splitting it would scatter the wiring +func TestTransactionInfo_FieldMapping(t *testing.T) { + marshal := func(v interface{ MarshalBinary() ([]byte, error) }) []byte { + raw, err := v.MarshalBinary() + require.NoError(t, err) + return raw + } + symEvent := func(name string) xdr.ContractEvent { + id := xdr.ContractId{9} + return diffEvent(xdr.ContractEventTypeContract, &id, diffSym(name), diffSym(name)) + } + + result := xdr.TransactionResult{ + FeeCharged: 4242, + Result: xdr.TransactionResultResult{ + Code: xdr.TransactionResultCodeTxSuccess, + Results: &[]xdr.OperationResult{}, + }, + } + meta := diffMetaV4(nil, nil, nil) + envelope := diffClassicEnvelope(999) + diagnostic := xdr.DiagnosticEvent{InSuccessfulContractCall: true, Event: symEvent("DIAGNOSTIC")} + txEvent := diffTxEvent(xdr.TransactionEventStageTransactionEventStageAfterAllTxs, symEvent("TRANSACTION")) + opEventA, opEventB := symEvent("OPERATION_A"), symEvent("OPERATION_B") + + tx := store.Transaction{ + TransactionHash: "deadbeef", + Result: marshal(&result), + Meta: marshal(&meta), + Envelope: marshal(&envelope), + Events: [][]byte{marshal(&diagnostic)}, + TransactionEvents: [][]byte{marshal(&txEvent)}, + ContractEvents: [][][]byte{{marshal(&opEventA)}, {marshal(&opEventB)}}, + FeeBump: true, + ApplicationOrder: 7, + Successful: true, + Ledger: store.LedgerInfo{Sequence: 4242, CloseTime: 987}, + } + + // Every byte source distinct, so any crossed assignment below changes an + // encoding rather than reproducing it. + seen := map[string]string{} + for name, raw := range map[string][]byte{ + "result": tx.Result, "meta": tx.Meta, "envelope": tx.Envelope, + "diagnostic": tx.Events[0], "txEvent": tx.TransactionEvents[0], + "opA": tx.ContractEvents[0][0], "opB": tx.ContractEvents[1][0], + } { + encoded := base64.StdEncoding.EncodeToString(raw) + require.NotContains(t, seen, encoded, "%s collides with %s", name, seen[encoded]) + seen[encoded] = name + } + + t.Run("xdr", func(t *testing.T) { + got, err := transactionInfo(tx, "") + require.NoError(t, err) + + require.Equal(t, "deadbeef", got.TransactionHash) + require.Equal(t, int32(7), got.ApplicationOrder) + require.True(t, got.FeeBump) + require.Equal(t, uint32(4242), got.Ledger) + require.Equal(t, int64(987), got.LedgerCloseTime) + require.Equal(t, protocol.TransactionStatusSuccess, got.Status) + + b64 := base64.StdEncoding.EncodeToString + require.Equal(t, b64(tx.Result), got.ResultXDR) + require.Equal(t, b64(tx.Meta), got.ResultMetaXDR) + require.Equal(t, b64(tx.Envelope), got.EnvelopeXDR) + require.Equal(t, []string{b64(tx.Events[0])}, got.DiagnosticEventsXDR) + require.Equal(t, []string{b64(tx.TransactionEvents[0])}, got.Events.TransactionEventsXDR) + require.Equal(t, [][]string{ + {b64(tx.ContractEvents[0][0])}, + {b64(tx.ContractEvents[1][0])}, + }, got.Events.ContractEventsXDR) + + require.Empty(t, got.ResultJSON) + require.Empty(t, got.EnvelopeJSON) + require.Empty(t, got.ResultMetaJSON) + require.Empty(t, got.DiagnosticEventsJSON) + require.Empty(t, got.Events.ContractEventsJSON) + require.Empty(t, got.Events.TransactionEventsJSON) + }) + + t.Run("json", func(t *testing.T) { + failed := tx + failed.Successful = false + got, err := transactionInfo(failed, protocol.FormatJSON) + require.NoError(t, err) + require.Equal(t, protocol.TransactionStatusFailed, got.Status) + + // Each JSON field must be the conversion of ITS OWN source, so compare + // against the same converter run on that source directly. + wantJSON := func(typ any, raw []byte) string { + converted, cerr := xdr2json.ConvertBytes(typ, raw) + require.NoError(t, cerr) + return string(converted) + } + require.JSONEq(t, wantJSON(xdr.TransactionResult{}, tx.Result), string(got.ResultJSON)) + require.JSONEq(t, wantJSON(xdr.TransactionMeta{}, tx.Meta), string(got.ResultMetaJSON)) + require.JSONEq(t, wantJSON(xdr.TransactionEnvelope{}, tx.Envelope), string(got.EnvelopeJSON)) + require.Len(t, got.DiagnosticEventsJSON, 1) + require.JSONEq(t, wantJSON(xdr.DiagnosticEvent{}, tx.Events[0]), string(got.DiagnosticEventsJSON[0])) + require.Len(t, got.Events.TransactionEventsJSON, 1) + require.JSONEq(t, wantJSON(xdr.TransactionEvent{}, tx.TransactionEvents[0]), + string(got.Events.TransactionEventsJSON[0])) + require.Len(t, got.Events.ContractEventsJSON, 2) + require.JSONEq(t, wantJSON(xdr.ContractEvent{}, tx.ContractEvents[0][0]), + string(got.Events.ContractEventsJSON[0][0])) + require.JSONEq(t, wantJSON(xdr.ContractEvent{}, tx.ContractEvents[1][0]), + string(got.Events.ContractEventsJSON[1][0])) + + require.Empty(t, got.ResultXDR) + require.Empty(t, got.EnvelopeXDR) + require.Empty(t, got.ResultMetaXDR) + require.Empty(t, got.DiagnosticEventsXDR) + require.Empty(t, got.Events.ContractEventsXDR) + require.Empty(t, got.Events.TransactionEventsXDR) + }) +} diff --git a/cmd/stellar-rpc/internal/methods/get_transactions_test.go b/cmd/stellar-rpc/internal/methods/get_transactions_test.go index 33de52266..dd9ba5a66 100644 --- a/cmd/stellar-rpc/internal/methods/get_transactions_test.go +++ b/cmd/stellar-rpc/internal/methods/get_transactions_test.go @@ -154,6 +154,10 @@ func TestGetTransactions_CaughtUpCursorIsEchoed(t *testing.T) { "above the tip": toid.New(15, 1, 1).String(), "at the consumed tip": toid.New(10, 2, 1).String(), "past the tip's last tx": toid.New(10, 5, 1).String(), + // The server issues op order 1; 0 and 2 are client-built and must echo byte for byte. + "client-built op 0 at consumed tip": toid.New(10, 2, 0).String(), + "client-built op 0 past last tx": toid.New(10, 5, 0).String(), + "client-built op 2 at consumed tip": toid.New(10, 2, 2).String(), } for name, cursor := range cursors { t.Run(name, func(t *testing.T) {