From 399046dbdeb9bc02b287f84c5fa90911693f841f Mon Sep 17 00:00:00 2001 From: Christian Jonas Date: Fri, 4 Sep 2026 14:11:30 -0400 Subject: [PATCH 01/12] add getTransactions view-walk differential suite Byte-compares the view-walk getTransactions page loop against a test-local copy of the pre-view decode path over a corpus sweeping LCM/meta versions, envelope and event shapes, page boundaries and cursor round-trips. Split out of #962; the suite is @tamirms's work from tamirms/gettransactions-view-walk. Co-Authored-By: Claude Fable 5.1 --- .../get_transactions_differential_test.go | 887 ++++++++++++++++++ 1 file changed, 887 insertions(+) create mode 100644 cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go 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..e0b5e2793 --- /dev/null +++ b/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go @@ -0,0 +1,887 @@ +package methods + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "strconv" + "testing" + + "github.com/creachadair/jrpc2" + "github.com/stretchr/testify/require" + + "github.com/stellar/go-stellar-sdk/ingest" + "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/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". +// +// 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 meta versions, envelope shapes, +// event shapes, page boundaries, empty ledgers and cursor round-trips. + +// legacyGetTransactionsByLedgerSequence is the pre-view-walk pagination loop: +// same handler, same cursor math, but reading each ledger through +// LedgerReaderTx.GetLedger's decoded 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", + } + } + ledger, found, gerr := readTx.GetLedger(ctx, uint32(ledgerSeq)) + 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 ---- +// + +// 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 diffSymbolVal() xdr.ScVal { + sym := xdr.ScSymbol("COUNTER") + return xdr.ScVal{Type: xdr.ScValTypeScvSymbol, Sym: &sym} +} + +func diffContractEvent() xdr.ContractEvent { + val := diffSymbolVal() + id := xdr.ContractId{7} + return xdr.ContractEvent{ + ContractId: &id, + Type: xdr.ContractEventTypeContract, + Body: xdr.ContractEventBody{ + V: 0, + V0: &xdr.ContractEventV0{Topics: []xdr.ScVal{val}, Data: val}, + }, + } +} + +// 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 +} + +// 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, + }, + }, + }, + } +} + +func diffMetaV1() xdr.TransactionMeta { + return xdr.TransactionMeta{V: 1, V1: &xdr.TransactionMetaV1{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 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: diffSymbolVal(), + }}, + } +} + +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 the given wire version (1 or 2) at +// sequence seq holding specs. Both versions matter: their TxProcessing arrays +// are different element types, which the view dispatcher walks with different +// code. +func diffLCM(t *testing.T, version int32, seq uint32, specs ...diffTxSpec) xdr.LedgerCloseMeta { + t.Helper() + envs := make([]xdr.TransactionEnvelope, 0, len(specs)) + for _, spec := range specs { + envs = append(envs, spec.envelope) + } + components := []xdr.TxSetComponent{{ + Type: xdr.TxSetComponentTypeTxsetCompTxsMaybeDiscountedFee, + TxsMaybeDiscountedFee: &xdr.TxSetComponentTxsMaybeDiscountedFee{Txs: envs}, + }} + 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{{V: 0, V0Components: &components}}, + }} + + if version == 1 { + proc := make([]xdr.TransactionResultMeta, 0, len(specs)) + for _, spec := range specs { + proc = append(proc, xdr.TransactionResultMeta{ + Result: diffResultFor(t, spec), + TxApplyProcessing: spec.meta, + }) + } + return xdr.LedgerCloseMeta{V: 1, V1: &xdr.LedgerCloseMetaV1{ + LedgerHeader: header, TxSet: txSet, TxProcessing: proc, + }} + } + + 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, + }} +} + +// differentialCorpus is the contiguous ledger run [corpusFirstLedger, +// corpusLastLedger] 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 differentialCorpus(t *testing.T) []xdr.LedgerCloseMeta { + t.Helper() + ev := diffContractEvent() + diag := xdr.DiagnosticEvent{InSuccessfulContractCall: true, Event: ev} + failedDiag := xdr.DiagnosticEvent{InSuccessfulContractCall: false, Event: ev} + txEvent := xdr.TransactionEvent{ + Stage: xdr.TransactionEventStageTransactionEventStageAfterAllTxs, + Event: ev, + } + + return []xdr.LedgerCloseMeta{ + // 1: the shape the pre-existing tests use — one TxSet envelope, two + // TxProcessing entries sharing its hash, V3 meta with no SorobanMeta + // on a Soroban envelope (the straggler corner). + createTestLedger(101), + // 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. + createEmptyTestLedger(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, ev}, []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 a Soroban inner, one + // 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, ev}}}, 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: another empty ledger, this time at the tip, so a walk that runs + // off the end of the corpus ends on one. + createEmptyTestLedger(110), + } +} + +// corpusFirstLedger / corpusLastLedger bracket differentialCorpus. The run +// starts at 101 because the shared fixture helpers (createTestLedger, +// createEmptyTestLedger) offset their sequences by 100. +const ( + corpusFirstLedger = 101 + corpusLastLedger = 110 +) + +// setupDifferentialDB writes differentialCorpus into a fresh sqlite store. +func setupDifferentialDB(t *testing.T) *sqlitedb.DB { + t.Helper() + corpus := differentialCorpus(t) + require.Len(t, corpus, corpusLastLedger-corpusFirstLedger+1) + testDB := NewTestDB(t) + daemon := host.MakeNoOpDaemon() + for i, lcm := range corpus { + require.Equal(t, uint32(corpusFirstLedger+i), lcm.LedgerSequence(), + "the corpus must be one contiguous run") + tx, err := sqlitedb.NewReadWriter(log.DefaultLogger, testDB, daemon, 100, passphrase).NewTx(t.Context()) + require.NoError(t, err) + require.NoError(t, tx.LedgerWriter().InsertLedger(lcm)) + require.NoError(t, tx.Commit(lcm, nil)) + } + return testDB +} + +func differentialHandler(testDB *sqlitedb.DB) transactionsRPCHandler { + return transactionsRPCHandler{ + ledgerReader: sqlitedb.NewLedgerReader(testDB), + maxLimit: 100, + defaultLimit: 10, + networkPassphrase: NetworkPassphrase, + } +} + +// assertSameResponse runs both extractions over the same request and asserts +// their responses serialize identically, byte for byte. +func assertSameResponse( + t *testing.T, h transactionsRPCHandler, request protocol.GetTransactionsRequest, +) protocol.GetTransactionsResponse { + t.Helper() + wantResp, wantErr := legacyGetTransactionsByLedgerSequence(context.TODO(), h, request) + gotResp, gotErr := h.getTransactionsByLedgerSequence(context.TODO(), request) + + if wantErr != nil { + require.Error(t, gotErr) + require.Equal(t, wantErr.Error(), gotErr.Error()) + return gotResp + } + require.NoError(t, gotErr) + + wantJSON, err := json.Marshal(wantResp) + require.NoError(t, err) + gotJSON, err := json.Marshal(gotResp) + require.NoError(t, err) + // Byte equality, deliberately, not require.JSONEq's semantic equality: the + // point of the differential is that the wire bytes are unchanged, and + // JSONEq would forgive a field that appeared, vanished, or reordered. + require.Equal(t, string(wantJSON), string(gotJSON)) //nolint:testifylint // see above + return gotResp +} + +// 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) { + testDB := setupDifferentialDB(t) + h := differentialHandler(testDB) + + formats := []string{"", protocol.FormatJSON} + limits := []uint{1, 2, 3, 4, 5, 7, 11, 100} + + for _, format := range formats { + for startLedger := corpusFirstLedger; startLedger <= corpusLastLedger; startLedger++ { + for _, limit := range limits { + name := fmt.Sprintf("format=%q/start=%d/limit=%d", format, startLedger, limit) + t.Run(name, func(t *testing.T) { + assertSameResponse(t, h, 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) { + testDB := setupDifferentialDB(t) + h := differentialHandler(testDB) + + for _, format := range []string{"", protocol.FormatJSON} { + for startLedger := corpusFirstLedger; startLedger <= corpusLastLedger; startLedger++ { + t.Run(fmt.Sprintf("format=%q/start=%d", format, startLedger), func(t *testing.T) { + assertSameResponse(t, h, protocol.GetTransactionsRequest{ + Format: format, + StartLedger: uint32(startLedger), + }) + }) + } + } +} + +// TestGetTransactions_ViewWalkCursorRoundTrip pages through the whole corpus +// with each extraction driving its OWN cursor chain, so a cursor that differed +// by one would send the two paths down diverging pages. Every page must match +// byte for byte, and both must terminate on the same page count. +func TestGetTransactions_ViewWalkCursorRoundTrip(t *testing.T) { + testDB := setupDifferentialDB(t) + h := differentialHandler(testDB) + + for _, limit := range []uint{1, 2, 3, 5} { + t.Run(fmt.Sprintf("limit=%d", limit), func(t *testing.T) { + request := protocol.GetTransactionsRequest{ + StartLedger: corpusFirstLedger, + Pagination: &protocol.LedgerPaginationOptions{Limit: limit}, + } + legacyReq, viewReq := request, request + + var legacyPages, viewPages int + for { + want, err := legacyGetTransactionsByLedgerSequence(context.TODO(), h, legacyReq) + require.NoError(t, err) + got, err := h.getTransactionsByLedgerSequence(context.TODO(), viewReq) + require.NoError(t, err) + + wantJSON, err := json.Marshal(want) + require.NoError(t, err) + gotJSON, err := json.Marshal(got) + require.NoError(t, err) + //nolint:testifylint // byte equality, not JSONEq's semantic equality + require.Equal(t, string(wantJSON), string(gotJSON), "page %d", legacyPages) + + legacyPages++ + viewPages++ + if len(want.Transactions) == 0 { + break + } + require.Less(t, legacyPages, 200, "paging did not terminate") + + legacyReq = protocol.GetTransactionsRequest{ + Pagination: &protocol.LedgerPaginationOptions{Cursor: want.Cursor, Limit: limit}, + } + viewReq = protocol.GetTransactionsRequest{ + Pagination: &protocol.LedgerPaginationOptions{Cursor: got.Cursor, Limit: limit}, + } + } + require.Equal(t, legacyPages, viewPages) + require.Greater(t, legacyPages, 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). +func TestGetTransactions_ViewWalkMatchesParsedPath_Cursors(t *testing.T) { + testDB := setupDifferentialDB(t) + h := differentialHandler(testDB) + + for ledger := corpusFirstLedger; ledger <= corpusLastLedger; ledger++ { + for txOrder := range 7 { + for _, limit := range []uint{1, 3, 10} { + cursor := toid.New(int32(ledger), int32(txOrder), 1).String() + name := fmt.Sprintf("cursor=%d.%d/limit=%d", ledger, txOrder, limit) + t.Run(name, func(t *testing.T) { + assertSameResponse(t, h, 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) { + testDB := setupDBNoTxs(t, 5) + h := differentialHandler(testDB) + + 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) { + assertSameResponse(t, h, protocol.GetTransactionsRequest{ + StartLedger: uint32(start), + Pagination: &protocol.LedgerPaginationOptions{Limit: limit}, + }) + }) + } + } +} + +// 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) { + testDB := setupDifferentialDB(t) + h := differentialHandler(testDB) + + resp, err := h.getTransactionsByLedgerSequence(context.TODO(), protocol.GetTransactionsRequest{ + StartLedger: corpusFirstLedger, + Pagination: &protocol.LedgerPaginationOptions{Limit: 100}, + }) + require.NoError(t, err) + require.GreaterOrEqual(t, len(resp.Transactions), 15, "the corpus must carry real transactions") + + var feeBumps, failed, withContractEvents, withTxEvents, withDiagnostics int + for _, tx := range resp.Transactions { + 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") +} + +// 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 { + sym := xdr.ScSymbol(name) + val := xdr.ScVal{Type: xdr.ScValTypeScvSymbol, Sym: &sym} + id := xdr.ContractId{9} + return xdr.ContractEvent{ + ContractId: &id, + Type: xdr.ContractEventTypeContract, + Body: xdr.ContractEventBody{V: 0, V0: &xdr.ContractEventV0{Topics: []xdr.ScVal{val}, Data: val}}, + } + } + + 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 := xdr.TransactionEvent{ + Stage: xdr.TransactionEventStageTransactionEventStageAfterAllTxs, + Event: 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) + }) +} From 5216e33023c70e31eb17496986d3d0fc7e77da70 Mon Sep 17 00:00:00 2001 From: Christian Jonas Date: Tue, 8 Sep 2026 15:50:24 -0400 Subject: [PATCH 02/12] refactor to-be-shared methods out of differential test --- .../internal/methods/differential_test.go | 313 +++++++++++++++ .../get_transactions_differential_test.go | 366 +++--------------- 2 files changed, 368 insertions(+), 311 deletions(-) create mode 100644 cmd/stellar-rpc/internal/methods/differential_test.go 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..b4fb2330f --- /dev/null +++ b/cmd/stellar-rpc/internal/methods/differential_test.go @@ -0,0 +1,313 @@ +package methods + +import ( + "context" + "encoding/json" + "errors" + "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, or that they fail with the same error. +func (d differential[Req, Resp]) assertSame(t *testing.T, req Req) Resp { + t.Helper() + wantResp, wantErr := d.want(context.TODO(), req) + gotResp, gotErr := d.got(context.TODO(), req) + + if wantErr != nil { + 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) + } + return gotResp + } + require.NoError(t, gotErr) + requireSameJSON(t, wantResp, gotResp) + return gotResp +} + +// 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 +} + +// 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, + }, + }, + }, + } +} + +func diffMetaV1() xdr.TransactionMeta { + return xdr.TransactionMeta{V: 1, V1: &xdr.TransactionMetaV1{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 the given wire version (1 or 2) at +// sequence seq holding specs; no specs is an empty ledger. Both versions +// matter: their TxProcessing arrays are different element types, which the +// view dispatcher walks with different code. +func diffLCM(t *testing.T, version int32, seq uint32, specs ...diffTxSpec) xdr.LedgerCloseMeta { + t.Helper() + envs := make([]xdr.TransactionEnvelope, 0, len(specs)) + for _, spec := range specs { + envs = append(envs, spec.envelope) + } + components := []xdr.TxSetComponent{{ + Type: xdr.TxSetComponentTypeTxsetCompTxsMaybeDiscountedFee, + TxsMaybeDiscountedFee: &xdr.TxSetComponentTxsMaybeDiscountedFee{Txs: envs}, + }} + 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{{V: 0, V0Components: &components}}, + }} + + if version == 1 { + proc := make([]xdr.TransactionResultMeta, 0, len(specs)) + for _, spec := range specs { + proc = append(proc, xdr.TransactionResultMeta{ + Result: diffResultFor(t, spec), + TxApplyProcessing: spec.meta, + }) + } + return xdr.LedgerCloseMeta{V: 1, V1: &xdr.LedgerCloseMetaV1{ + LedgerHeader: header, TxSet: txSet, TxProcessing: proc, + }} + } + + 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, + }} +} diff --git a/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go b/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go index e0b5e2793..795f1a68c 100644 --- a/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go +++ b/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go @@ -3,7 +3,6 @@ package methods import ( "context" "encoding/base64" - "encoding/json" "errors" "fmt" "io" @@ -14,13 +13,10 @@ import ( "github.com/stretchr/testify/require" "github.com/stellar/go-stellar-sdk/ingest" - "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/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" @@ -37,6 +33,7 @@ import ( // two paths' protocol.GetTransactionsResponse values serialize to byte- // identical JSON over a corpus that sweeps meta versions, 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 through @@ -241,203 +238,18 @@ func legacyParseTransaction(lcm xdr.LedgerCloseMeta, ingestTx ingest.LedgerTrans // ---- the corpus ---- // -// 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 diffSymbolVal() xdr.ScVal { - sym := xdr.ScSymbol("COUNTER") - return xdr.ScVal{Type: xdr.ScValTypeScvSymbol, Sym: &sym} -} - -func diffContractEvent() xdr.ContractEvent { - val := diffSymbolVal() - id := xdr.ContractId{7} - return xdr.ContractEvent{ - ContractId: &id, - Type: xdr.ContractEventTypeContract, - Body: xdr.ContractEventBody{ - V: 0, - V0: &xdr.ContractEventV0{Topics: []xdr.ScVal{val}, Data: val}, - }, - } -} - -// 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 -} - -// 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, - }, - }, - }, - } -} - -func diffMetaV1() xdr.TransactionMeta { - return xdr.TransactionMeta{V: 1, V1: &xdr.TransactionMetaV1{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 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: diffSymbolVal(), - }}, - } -} - -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 the given wire version (1 or 2) at -// sequence seq holding specs. Both versions matter: their TxProcessing arrays -// are different element types, which the view dispatcher walks with different -// code. -func diffLCM(t *testing.T, version int32, seq uint32, specs ...diffTxSpec) xdr.LedgerCloseMeta { - t.Helper() - envs := make([]xdr.TransactionEnvelope, 0, len(specs)) - for _, spec := range specs { - envs = append(envs, spec.envelope) - } - components := []xdr.TxSetComponent{{ - Type: xdr.TxSetComponentTypeTxsetCompTxsMaybeDiscountedFee, - TxsMaybeDiscountedFee: &xdr.TxSetComponentTxsMaybeDiscountedFee{Txs: envs}, - }} - 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{{V: 0, V0Components: &components}}, - }} - - if version == 1 { - proc := make([]xdr.TransactionResultMeta, 0, len(specs)) - for _, spec := range specs { - proc = append(proc, xdr.TransactionResultMeta{ - Result: diffResultFor(t, spec), - TxApplyProcessing: spec.meta, - }) - } - return xdr.LedgerCloseMeta{V: 1, V1: &xdr.LedgerCloseMetaV1{ - LedgerHeader: header, TxSet: txSet, TxProcessing: proc, - }} - } - - 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, - }} -} - -// differentialCorpus is the contiguous ledger run [corpusFirstLedger, -// corpusLastLedger] the differential sweeps over. +// 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 differentialCorpus(t *testing.T) []xdr.LedgerCloseMeta { +func transactionsCorpus(t *testing.T) []xdr.LedgerCloseMeta { t.Helper() ev := diffContractEvent() diag := xdr.DiagnosticEvent{InSuccessfulContractCall: true, Event: ev} failedDiag := xdr.DiagnosticEvent{InSuccessfulContractCall: false, Event: ev} - txEvent := xdr.TransactionEvent{ - Stage: xdr.TransactionEventStageTransactionEventStageAfterAllTxs, - Event: ev, - } + txEvent := diffTxEvent(xdr.TransactionEventStageTransactionEventStageAfterAllTxs, ev) return []xdr.LedgerCloseMeta{ // 1: the shape the pre-existing tests use — one TxSet envelope, two @@ -509,66 +321,38 @@ func differentialCorpus(t *testing.T) []xdr.LedgerCloseMeta { } } -// corpusFirstLedger / corpusLastLedger bracket differentialCorpus. The run -// starts at 101 because the shared fixture helpers (createTestLedger, +// transactionsCorpusFirst / transactionsCorpusLast bracket transactionsCorpus. +// The run starts at 101 because the shared fixture helpers (createTestLedger, // createEmptyTestLedger) offset their sequences by 100. const ( - corpusFirstLedger = 101 - corpusLastLedger = 110 + transactionsCorpusFirst = 101 + transactionsCorpusLast = 110 ) -// setupDifferentialDB writes differentialCorpus into a fresh sqlite store. -func setupDifferentialDB(t *testing.T) *sqlitedb.DB { - t.Helper() - corpus := differentialCorpus(t) - require.Len(t, corpus, corpusLastLedger-corpusFirstLedger+1) - testDB := NewTestDB(t) - daemon := host.MakeNoOpDaemon() - for i, lcm := range corpus { - require.Equal(t, uint32(corpusFirstLedger+i), lcm.LedgerSequence(), - "the corpus must be one contiguous run") - tx, err := sqlitedb.NewReadWriter(log.DefaultLogger, testDB, daemon, 100, passphrase).NewTx(t.Context()) - require.NoError(t, err) - require.NoError(t, tx.LedgerWriter().InsertLedger(lcm)) - require.NoError(t, tx.Commit(lcm, nil)) - } - return testDB -} +type transactionsDifferential = differential[protocol.GetTransactionsRequest, protocol.GetTransactionsResponse] -func differentialHandler(testDB *sqlitedb.DB) transactionsRPCHandler { - return transactionsRPCHandler{ +// 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, + } } -// assertSameResponse runs both extractions over the same request and asserts -// their responses serialize identically, byte for byte. -func assertSameResponse( - t *testing.T, h transactionsRPCHandler, request protocol.GetTransactionsRequest, -) protocol.GetTransactionsResponse { +func seededTransactionsDifferential(t *testing.T) transactionsDifferential { t.Helper() - wantResp, wantErr := legacyGetTransactionsByLedgerSequence(context.TODO(), h, request) - gotResp, gotErr := h.getTransactionsByLedgerSequence(context.TODO(), request) - - if wantErr != nil { - require.Error(t, gotErr) - require.Equal(t, wantErr.Error(), gotErr.Error()) - return gotResp - } - require.NoError(t, gotErr) - - wantJSON, err := json.Marshal(wantResp) - require.NoError(t, err) - gotJSON, err := json.Marshal(gotResp) - require.NoError(t, err) - // Byte equality, deliberately, not require.JSONEq's semantic equality: the - // point of the differential is that the wire bytes are unchanged, and - // JSONEq would forgive a field that appeared, vanished, or reordered. - require.Equal(t, string(wantJSON), string(gotJSON)) //nolint:testifylint // see above - return gotResp + corpus := transactionsCorpus(t) + require.Len(t, corpus, transactionsCorpusLast-transactionsCorpusFirst+1) + return newTransactionsDifferential(seedDifferentialDB(t, corpus)) } // TestGetTransactions_ViewWalkMatchesParsedPath sweeps start ledgers, page @@ -576,18 +360,15 @@ func assertSameResponse( // responses. The limits are chosen so pages end inside a ledger as well as on // its boundary. func TestGetTransactions_ViewWalkMatchesParsedPath(t *testing.T) { - testDB := setupDifferentialDB(t) - h := differentialHandler(testDB) - - formats := []string{"", protocol.FormatJSON} + diff := seededTransactionsDifferential(t) limits := []uint{1, 2, 3, 4, 5, 7, 11, 100} - for _, format := range formats { - for startLedger := corpusFirstLedger; startLedger <= corpusLastLedger; startLedger++ { + 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) { - assertSameResponse(t, h, protocol.GetTransactionsRequest{ + diff.assertSame(t, protocol.GetTransactionsRequest{ Format: format, StartLedger: uint32(startLedger), Pagination: &protocol.LedgerPaginationOptions{Limit: limit}, @@ -602,13 +383,12 @@ func TestGetTransactions_ViewWalkMatchesParsedPath(t *testing.T) { // requests that carry no pagination block at all, so the handler's default // limit applies. func TestGetTransactions_ViewWalkMatchesParsedPath_DefaultPagination(t *testing.T) { - testDB := setupDifferentialDB(t) - h := differentialHandler(testDB) + diff := seededTransactionsDifferential(t) - for _, format := range []string{"", protocol.FormatJSON} { - for startLedger := corpusFirstLedger; startLedger <= corpusLastLedger; startLedger++ { + for _, format := range diffFormats { + for startLedger := transactionsCorpusFirst; startLedger <= transactionsCorpusLast; startLedger++ { t.Run(fmt.Sprintf("format=%q/start=%d", format, startLedger), func(t *testing.T) { - assertSameResponse(t, h, protocol.GetTransactionsRequest{ + diff.assertSame(t, protocol.GetTransactionsRequest{ Format: format, StartLedger: uint32(startLedger), }) @@ -618,51 +398,27 @@ func TestGetTransactions_ViewWalkMatchesParsedPath_DefaultPagination(t *testing. } // TestGetTransactions_ViewWalkCursorRoundTrip pages through the whole corpus -// with each extraction driving its OWN cursor chain, so a cursor that differed -// by one would send the two paths down diverging pages. Every page must match -// byte for byte, and both must terminate on the same page count. +// 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) { - testDB := setupDifferentialDB(t) - h := differentialHandler(testDB) + diff := seededTransactionsDifferential(t) for _, limit := range []uint{1, 2, 3, 5} { t.Run(fmt.Sprintf("limit=%d", limit), func(t *testing.T) { - request := protocol.GetTransactionsRequest{ - StartLedger: corpusFirstLedger, + first := protocol.GetTransactionsRequest{ + StartLedger: transactionsCorpusFirst, Pagination: &protocol.LedgerPaginationOptions{Limit: limit}, } - legacyReq, viewReq := request, request - - var legacyPages, viewPages int - for { - want, err := legacyGetTransactionsByLedgerSequence(context.TODO(), h, legacyReq) - require.NoError(t, err) - got, err := h.getTransactionsByLedgerSequence(context.TODO(), viewReq) - require.NoError(t, err) - - wantJSON, err := json.Marshal(want) - require.NoError(t, err) - gotJSON, err := json.Marshal(got) - require.NoError(t, err) - //nolint:testifylint // byte equality, not JSONEq's semantic equality - require.Equal(t, string(wantJSON), string(gotJSON), "page %d", legacyPages) - - legacyPages++ - viewPages++ - if len(want.Transactions) == 0 { - break - } - require.Less(t, legacyPages, 200, "paging did not terminate") - - legacyReq = protocol.GetTransactionsRequest{ - Pagination: &protocol.LedgerPaginationOptions{Cursor: want.Cursor, Limit: limit}, - } - viewReq = protocol.GetTransactionsRequest{ - Pagination: &protocol.LedgerPaginationOptions{Cursor: got.Cursor, 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 } - require.Equal(t, legacyPages, viewPages) - require.Greater(t, legacyPages, 1, "the corpus must take more than one page") + pages := diff.assertSameChain(t, first, next) + require.Greater(t, pages, 1, "the corpus must take more than one page") }) } } @@ -672,16 +428,15 @@ func TestGetTransactions_ViewWalkCursorRoundTrip(t *testing.T) { // 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). func TestGetTransactions_ViewWalkMatchesParsedPath_Cursors(t *testing.T) { - testDB := setupDifferentialDB(t) - h := differentialHandler(testDB) + diff := seededTransactionsDifferential(t) - for ledger := corpusFirstLedger; ledger <= corpusLastLedger; ledger++ { + for ledger := transactionsCorpusFirst; ledger <= transactionsCorpusLast; ledger++ { for txOrder := range 7 { for _, limit := range []uint{1, 3, 10} { cursor := toid.New(int32(ledger), int32(txOrder), 1).String() name := fmt.Sprintf("cursor=%d.%d/limit=%d", ledger, txOrder, limit) t.Run(name, func(t *testing.T) { - assertSameResponse(t, h, protocol.GetTransactionsRequest{ + diff.assertSame(t, protocol.GetTransactionsRequest{ Pagination: &protocol.LedgerPaginationOptions{Cursor: cursor, Limit: limit}, }) }) @@ -695,13 +450,12 @@ func TestGetTransactions_ViewWalkMatchesParsedPath_Cursors(t *testing.T) { // driven entirely by the cursor math, which the view walk must not have // shifted. func TestGetTransactions_ViewWalkMatchesParsedPath_EmptyLedgersOnly(t *testing.T) { - testDB := setupDBNoTxs(t, 5) - h := differentialHandler(testDB) + 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) { - assertSameResponse(t, h, protocol.GetTransactionsRequest{ + diff.assertSame(t, protocol.GetTransactionsRequest{ StartLedger: uint32(start), Pagination: &protocol.LedgerPaginationOptions{Limit: limit}, }) @@ -714,11 +468,10 @@ func TestGetTransactions_ViewWalkMatchesParsedPath_EmptyLedgersOnly(t *testing.T // 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) { - testDB := setupDifferentialDB(t) - h := differentialHandler(testDB) + diff := seededTransactionsDifferential(t) - resp, err := h.getTransactionsByLedgerSequence(context.TODO(), protocol.GetTransactionsRequest{ - StartLedger: corpusFirstLedger, + resp, err := diff.got(context.TODO(), protocol.GetTransactionsRequest{ + StartLedger: transactionsCorpusFirst, Pagination: &protocol.LedgerPaginationOptions{Limit: 100}, }) require.NoError(t, err) @@ -766,14 +519,8 @@ func TestTransactionInfo_FieldMapping(t *testing.T) { return raw } symEvent := func(name string) xdr.ContractEvent { - sym := xdr.ScSymbol(name) - val := xdr.ScVal{Type: xdr.ScValTypeScvSymbol, Sym: &sym} id := xdr.ContractId{9} - return xdr.ContractEvent{ - ContractId: &id, - Type: xdr.ContractEventTypeContract, - Body: xdr.ContractEventBody{V: 0, V0: &xdr.ContractEventV0{Topics: []xdr.ScVal{val}, Data: val}}, - } + return diffEvent(xdr.ContractEventTypeContract, &id, diffSym(name), diffSym(name)) } result := xdr.TransactionResult{ @@ -786,10 +533,7 @@ func TestTransactionInfo_FieldMapping(t *testing.T) { meta := diffMetaV4(nil, nil, nil) envelope := diffClassicEnvelope(999) diagnostic := xdr.DiagnosticEvent{InSuccessfulContractCall: true, Event: symEvent("DIAGNOSTIC")} - txEvent := xdr.TransactionEvent{ - Stage: xdr.TransactionEventStageTransactionEventStageAfterAllTxs, - Event: symEvent("TRANSACTION"), - } + txEvent := diffTxEvent(xdr.TransactionEventStageTransactionEventStageAfterAllTxs, symEvent("TRANSACTION")) opEventA, opEventB := symEvent("OPERATION_A"), symEvent("OPERATION_B") tx := store.Transaction{ From 21eb4237fa34607182d4c1edf650f9ac8296d1f8 Mon Sep 17 00:00:00 2001 From: Christian Jonas Date: Wed, 9 Sep 2026 17:44:42 -0400 Subject: [PATCH 03/12] fix positional zipping regression hole --- cmd/stellar-rpc/internal/methods/differential_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd/stellar-rpc/internal/methods/differential_test.go b/cmd/stellar-rpc/internal/methods/differential_test.go index b4fb2330f..bd9b27e9f 100644 --- a/cmd/stellar-rpc/internal/methods/differential_test.go +++ b/cmd/stellar-rpc/internal/methods/differential_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "slices" "testing" "github.com/creachadair/jrpc2" @@ -271,7 +272,9 @@ func diffResultFor(t *testing.T, spec diffTxSpec) xdr.TransactionResultPair { func diffLCM(t *testing.T, version int32, seq uint32, specs ...diffTxSpec) xdr.LedgerCloseMeta { t.Helper() envs := make([]xdr.TransactionEnvelope, 0, len(specs)) - for _, spec := range specs { + // TxSet is agreed-set order, which real ledgers do not keep in apply order, + // reverse order here so a positional/zip pairing regression fails corpus-wide. + for _, spec := range slices.Backward(specs) { envs = append(envs, spec.envelope) } components := []xdr.TxSetComponent{{ From 254533b718aa3edcfff2ca9a0f49370c48e02641 Mon Sep 17 00:00:00 2001 From: Christian Jonas Date: Wed, 9 Sep 2026 17:46:42 -0400 Subject: [PATCH 04/12] fix unique hash invariant hole in test --- .../methods/get_transactions_differential_test.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go b/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go index 795f1a68c..994bf0f64 100644 --- a/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go +++ b/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go @@ -252,10 +252,13 @@ func transactionsCorpus(t *testing.T) []xdr.LedgerCloseMeta { txEvent := diffTxEvent(xdr.TransactionEventStageTransactionEventStageAfterAllTxs, ev) return []xdr.LedgerCloseMeta{ - // 1: the shape the pre-existing tests use — one TxSet envelope, two - // TxProcessing entries sharing its hash, V3 meta with no SorobanMeta - // on a Soroban envelope (the straggler corner). - createTestLedger(101), + // 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}, From f2765abfd3ae580ef744bd90e826039d45a940ad Mon Sep 17 00:00:00 2001 From: Christian Jonas Date: Wed, 9 Sep 2026 17:50:42 -0400 Subject: [PATCH 05/12] split assertSame into self + assertSameError to check error parity --- .../internal/methods/differential_test.go | 33 ++++++++++++------- .../get_transactions_differential_test.go | 14 ++++++++ 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/cmd/stellar-rpc/internal/methods/differential_test.go b/cmd/stellar-rpc/internal/methods/differential_test.go index bd9b27e9f..439c18c40 100644 --- a/cmd/stellar-rpc/internal/methods/differential_test.go +++ b/cmd/stellar-rpc/internal/methods/differential_test.go @@ -37,27 +37,36 @@ type differential[Req, Resp any] struct { } // assertSame runs both sides over req and asserts their responses serialize -// identically, or that they fail with the same error. +// 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) - - if wantErr != nil { - 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) - } - return gotResp - } 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( diff --git a/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go b/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go index 994bf0f64..0490f53bd 100644 --- a/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go +++ b/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go @@ -358,6 +358,20 @@ func seededTransactionsDifferential(t *testing.T) transactionsDifferential { return newTransactionsDifferential(seedDifferentialDB(t, corpus)) } +// TestGetTransactions_ViewWalkErrorParity pins that both paths reject an +// invalid request identically: same message, same jrpc2 code. +func TestGetTransactions_ViewWalkErrorParity(t *testing.T) { + diff := seededTransactionsDifferential(t) + for name, start := range map[string]uint32{ + "below oldest": transactionsCorpusFirst - 1, + "above latest": transactionsCorpusLast + 1, + } { + t.Run(name, func(t *testing.T) { + diff.assertSameError(t, protocol.GetTransactionsRequest{StartLedger: start}) + }) + } +} + // 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 From 648e65b83b11e00240d2ad1d93663dbb46822dfd Mon Sep 17 00:00:00 2001 From: Christian Jonas Date: Fri, 11 Sep 2026 16:28:41 -0400 Subject: [PATCH 06/12] replace deleted ledgerReaderTx.GetLedger usage --- .../internal/methods/get_transactions_differential_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go b/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go index 0490f53bd..b9456b892 100644 --- a/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go +++ b/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go @@ -36,8 +36,8 @@ import ( // Shared machinery lives in differential_test.go. // legacyGetTransactionsByLedgerSequence is the pre-view-walk pagination loop: -// same handler, same cursor math, but reading each ledger through -// LedgerReaderTx.GetLedger's decoded xdr.LedgerCloseMeta. +// 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) { @@ -72,7 +72,8 @@ func legacyGetTransactionsByLedgerSequence( Message: "cursor ledger sequence cannot be negative", } } - ledger, found, gerr := readTx.GetLedger(ctx, uint32(ledgerSeq)) + 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 { From d5de7a2179f2e64203b49cd98b6b2671eef5e818 Mon Sep 17 00:00:00 2001 From: Christian Jonas Date: Fri, 11 Sep 2026 16:34:39 -0400 Subject: [PATCH 07/12] fix test suite header comment --- .../internal/methods/get_transactions_differential_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go b/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go index b9456b892..203670841 100644 --- a/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go +++ b/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go @@ -24,7 +24,9 @@ import ( // 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". +// 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, From b06e6ada4521d4a39a107c02e9c6290a5e150441 Mon Sep 17 00:00:00 2001 From: Christian Jonas Date: Fri, 11 Sep 2026 17:07:11 -0400 Subject: [PATCH 08/12] fix corpus gaps on four remaining versions --- .../internal/methods/differential_test.go | 95 +++++++++---- .../get_transactions_differential_test.go | 134 ++++++++++++++++-- 2 files changed, 191 insertions(+), 38 deletions(-) diff --git a/cmd/stellar-rpc/internal/methods/differential_test.go b/cmd/stellar-rpc/internal/methods/differential_test.go index 439c18c40..827e1d200 100644 --- a/cmd/stellar-rpc/internal/methods/differential_test.go +++ b/cmd/stellar-rpc/internal/methods/differential_test.go @@ -182,6 +182,21 @@ func diffClassicEnvelope(acctSeq uint32) xdr.TransactionEnvelope { 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{ @@ -199,10 +214,20 @@ func diffFeeBumpEnvelope(inner xdr.TransactionEnvelope) xdr.TransactionEnvelope } } +// 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 @@ -274,52 +299,64 @@ func diffResultFor(t *testing.T, spec diffTxSpec) xdr.TransactionResultPair { } } -// diffLCM assembles a LedgerCloseMeta of the given wire version (1 or 2) at -// sequence seq holding specs; no specs is an empty ledger. Both versions -// matter: their TxProcessing arrays are different element types, which the -// view dispatcher walks with different code. +// 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 := make([]xdr.TransactionEnvelope, 0, len(specs)) - // TxSet is agreed-set order, which real ledgers do not keep in apply order, - // reverse order here so a positional/zip pairing regression fails corpus-wide. - for _, spec := range slices.Backward(specs) { - envs = append(envs, spec.envelope) - } - components := []xdr.TxSetComponent{{ - Type: xdr.TxSetComponentTypeTxsetCompTxsMaybeDiscountedFee, - TxsMaybeDiscountedFee: &xdr.TxSetComponentTxsMaybeDiscountedFee{Txs: envs}, - }} + 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{{V: 0, V0Components: &components}}, + Phases: []xdr.TransactionPhase{diffClassicPhase(envs)}, }} - if version == 1 { - proc := make([]xdr.TransactionResultMeta, 0, len(specs)) + if version == 2 { + proc := make([]xdr.TransactionResultMetaV1, 0, len(specs)) for _, spec := range specs { - proc = append(proc, xdr.TransactionResultMeta{ - Result: diffResultFor(t, spec), - TxApplyProcessing: spec.meta, - }) + proc = append(proc, xdr.TransactionResultMetaV1{Result: diffResultFor(t, spec), TxApplyProcessing: spec.meta}) } - return xdr.LedgerCloseMeta{V: 1, V1: &xdr.LedgerCloseMetaV1{ + return xdr.LedgerCloseMeta{V: 2, V2: &xdr.LedgerCloseMetaV2{ LedgerHeader: header, TxSet: txSet, TxProcessing: proc, }} } - - proc := make([]xdr.TransactionResultMetaV1, 0, len(specs)) + // V0 and V1 share the TxProcessing element type. + proc := make([]xdr.TransactionResultMeta, 0, len(specs)) for _, spec := range specs { - proc = append(proc, xdr.TransactionResultMetaV1{ - Result: diffResultFor(t, spec), - TxApplyProcessing: spec.meta, - }) + 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: 2, V2: &xdr.LedgerCloseMetaV2{ + 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_differential_test.go b/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go index 203670841..ac019c737 100644 --- a/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go +++ b/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "slices" "strconv" "testing" @@ -14,9 +15,11 @@ import ( "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" @@ -33,8 +36,9 @@ import ( // 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 meta versions, envelope shapes, -// event shapes, page boundaries, empty ledgers and cursor round-trips. +// 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: @@ -250,6 +254,8 @@ func legacyParseTransaction(lcm xdr.LedgerCloseMeta, ingestTx ingest.LedgerTrans 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) @@ -273,7 +279,7 @@ func transactionsCorpus(t *testing.T) []xdr.LedgerCloseMeta { // events and diagnostic events both present. diffLCM(t, 2, 104, diffTxSpec{txEnvelope(202), diffMetaV3WithEvents( - []xdr.ContractEvent{ev, ev}, []xdr.DiagnosticEvent{diag, failedDiag}), true}, + []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. @@ -310,7 +316,7 @@ func transactionsCorpus(t *testing.T) []xdr.LedgerCloseMeta { diffTxSpec{txEnvelope(210), diffMetaV3NoSoroban(), false}, diffTxSpec{txEnvelope(211), diffMetaV3WithEvents([]xdr.ContractEvent{ev}, nil), true}, diffTxSpec{diffClassicEnvelope(212), diffMetaV4( - []xdr.OperationMetaV2{{Events: []xdr.ContractEvent{ev, ev}}}, nil, + []xdr.OperationMetaV2{{Events: []xdr.ContractEvent{ev, ev2}}}, nil, []xdr.DiagnosticEvent{diag}), true}, diffTxSpec{diffFeeBumpEnvelope(diffClassicEnvelope(213)), diffMetaV1(), false}, ), @@ -321,9 +327,38 @@ func transactionsCorpus(t *testing.T) []xdr.LedgerCloseMeta { []xdr.ContractEvent{ev}, []xdr.DiagnosticEvent{diag}), true}, diffTxSpec{diffClassicEnvelope(215), diffMetaV3NoSoroban(), true}, ), - // 10: another empty ledger, this time at the tip, so a walk that runs + // 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, this time at the tip, so a walk that runs // off the end of the corpus ends on one. - createEmptyTestLedger(110), + createEmptyTestLedger(113), } } @@ -332,9 +367,29 @@ func transactionsCorpus(t *testing.T) []xdr.LedgerCloseMeta { // createEmptyTestLedger) offset their sequences by 100. const ( transactionsCorpusFirst = 101 - transactionsCorpusLast = 110 + 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 @@ -484,6 +539,55 @@ func TestGetTransactions_ViewWalkMatchesParsedPath_EmptyLedgersOnly(t *testing.T } } +// 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. @@ -495,10 +599,20 @@ func TestGetTransactions_ViewWalkCorpusIsNotVacuous(t *testing.T) { Pagination: &protocol.LedgerPaginationOptions{Limit: 100}, }) require.NoError(t, err) - require.GreaterOrEqual(t, len(resp.Transactions), 15, "the corpus must carry real transactions") + require.GreaterOrEqual(t, len(resp.Transactions), 25, "the corpus must carry real transactions") - var feeBumps, failed, withContractEvents, withTxEvents, withDiagnostics int + 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++ } @@ -523,6 +637,8 @@ func TestGetTransactions_ViewWalkCorpusIsNotVacuous(t *testing.T) { 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. From 6618b335383e002d2c5fbceb3f73d7ba1c0294a1 Mon Sep 17 00:00:00 2001 From: Christian Jonas Date: Fri, 11 Sep 2026 17:08:10 -0400 Subject: [PATCH 09/12] match legacy cursor bytes for client-built cursors + sweep operation orders --- .../internal/methods/get_transactions.go | 3 ++- .../get_transactions_differential_test.go | 20 +++++++++++-------- 2 files changed, 14 insertions(+), 9 deletions(-) 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 index ac019c737..173027cab 100644 --- a/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go +++ b/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go @@ -501,20 +501,24 @@ func TestGetTransactions_ViewWalkCursorRoundTrip(t *testing.T) { // 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). +// 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 _, limit := range []uint{1, 3, 10} { - cursor := toid.New(int32(ledger), int32(txOrder), 1).String() - name := fmt.Sprintf("cursor=%d.%d/limit=%d", ledger, txOrder, limit) - t.Run(name, func(t *testing.T) { - diff.assertSame(t, protocol.GetTransactionsRequest{ - Pagination: &protocol.LedgerPaginationOptions{Cursor: cursor, Limit: limit}, + 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}, + }) }) - }) + } } } } From 66feea2a2fddc6fb6b543c349591690aebf193e3 Mon Sep 17 00:00:00 2001 From: Christian Jonas Date: Fri, 11 Sep 2026 17:13:00 -0400 Subject: [PATCH 10/12] add cursor and request-shape error parity cells --- .../get_transactions_differential_test.go | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go b/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go index 173027cab..0c801fa4d 100644 --- a/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go +++ b/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go @@ -417,17 +417,37 @@ func seededTransactionsDifferential(t *testing.T) transactionsDifferential { } // TestGetTransactions_ViewWalkErrorParity pins that both paths reject an -// invalid request identically: same message, same jrpc2 code. +// 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) - for name, start := range map[string]uint32{ - "below oldest": transactionsCorpusFirst - 1, - "above latest": transactionsCorpusLast + 1, + 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, protocol.GetTransactionsRequest{StartLedger: start}) + 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 From 8de90a6d3ae8afdf59d5d806fe55d6c8669a80e9 Mon Sep 17 00:00:00 2001 From: Christian Jonas Date: Mon, 14 Sep 2026 13:02:28 -0400 Subject: [PATCH 11/12] add 3 extra cases to getTx cursor test --- cmd/stellar-rpc/internal/methods/get_transactions_test.go | 4 ++++ 1 file changed, 4 insertions(+) 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) { From 1c3fe2aae5595d77668a54de67909e11407ea4d0 Mon Sep 17 00:00:00 2001 From: Christian Jonas Date: Mon, 14 Sep 2026 13:02:44 -0400 Subject: [PATCH 12/12] minor comment fixes in differential test --- .../methods/get_transactions_differential_test.go | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go b/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go index 0c801fa4d..b0ec747b7 100644 --- a/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go +++ b/cmd/stellar-rpc/internal/methods/get_transactions_differential_test.go @@ -274,7 +274,7 @@ func transactionsCorpus(t *testing.T) []xdr.LedgerCloseMeta { diffTxSpec{diffClassicEnvelope(201), diffMetaV1(), false}, ), // 3: an empty ledger mid-corpus — the page must walk straight past it. - createEmptyTestLedger(103), + diffLCM(t, 1, 103), // 4: LCM V2, Soroban envelope, V3 meta WITH SorobanMeta: contract // events and diagnostic events both present. diffLCM(t, 2, 104, @@ -296,7 +296,7 @@ func transactionsCorpus(t *testing.T) []xdr.LedgerCloseMeta { ), true}, diffTxSpec{diffClassicEnvelope(205), diffMetaV4(nil, nil, nil), false}, ), - // 7: fee bumps — over a classic inner and over a Soroban inner, one + // 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{ @@ -356,15 +356,13 @@ func transactionsCorpus(t *testing.T) []xdr.LedgerCloseMeta { 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, this time at the tip, so a walk that runs - // off the end of the corpus ends on one. - createEmptyTestLedger(113), + // 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. -// The run starts at 101 because the shared fixture helpers (createTestLedger, -// createEmptyTestLedger) offset their sequences by 100. const ( transactionsCorpusFirst = 101 transactionsCorpusLast = 113