From fd5f095fe5f6cdd11a2caa2cd0a05b05f8808d8c Mon Sep 17 00:00:00 2001 From: Urvi Date: Wed, 16 Sep 2026 08:26:25 -0700 Subject: [PATCH 01/13] rpcv2: getEventsV2 integration tests and a tampered-cursor wire test Five tests against a real Core in rpcv2/integrationtest: ascending drain to the tip, closed range in both orders, filters over the wire, parity with getEvents on the same node, and paging while the tip moves. The package's TestMain defaults the daemon selector to rpcv2. The tampered-cursor cases run in process in jsonrpc_test.go; the cursor decoder itself is already fuzzed in the query package. Needs the SDK client's GetEventsV2 method; the SDK pin bumps separately. Co-Authored-By: Claude Fable 5.1 --- .../integrationtest/get_events_v2_test.go | 364 ++++++++++++++++++ .../rpcv2/integrationtest/main_test.go | 25 ++ .../internal/rpcv2/jsonrpc_test.go | 57 +++ 3 files changed, 446 insertions(+) create mode 100644 cmd/stellar-rpc/internal/rpcv2/integrationtest/get_events_v2_test.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/integrationtest/main_test.go diff --git a/cmd/stellar-rpc/internal/rpcv2/integrationtest/get_events_v2_test.go b/cmd/stellar-rpc/internal/rpcv2/integrationtest/get_events_v2_test.go new file mode 100644 index 000000000..ac2bf1462 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/integrationtest/get_events_v2_test.go @@ -0,0 +1,364 @@ +package integrationtest + +import ( + "encoding/json" + "testing" + + "github.com/creachadair/jrpc2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + client "github.com/stellar/go-stellar-sdk/clients/rpcclient" + protocol "github.com/stellar/go-stellar-sdk/protocols/rpc" + "github.com/stellar/go-stellar-sdk/strkey" + "github.com/stellar/go-stellar-sdk/txnbuild" + "github.com/stellar/go-stellar-sdk/xdr" + + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/integrationtest/infrastructure" +) + +// One increment leaves three events: fee, the contract's own, fee refund. +const eventsPerInvoke = 3 + +type eventsFixture struct { + contractID string // strkey, as the API reports it + contractRaw xdr.ContractId // the same id, as invocations need it + ledgers []uint32 // one per increment, ascending + txHashes []string // one per increment, same order +} + +func (f *eventsFixture) first() uint32 { return f.ledgers[0] } + +func (f *eventsFixture) increment(test *infrastructure.Test) { + resp := test.InvokeHostFunc(f.contractRaw, "increment") + f.ledgers = append(f.ledgers, resp.Ledger) + f.txHashes = append(f.txHashes, resp.TransactionHash) +} +func (f *eventsFixture) last() uint32 { return f.ledgers[len(f.ledgers)-1] } + +// Each call waits for inclusion, so the increments land in distinct ledgers. +func deployAndIncrement(t *testing.T, test *infrastructure.Test, n int) *eventsFixture { + rpc := test.GetRPCLient() + test.UploadEventsContract() + + creationOp := infrastructure.CreateCreateEventsContractOperation(test.MasterAccount().GetAccountID()) + params := infrastructure.PreflightTransactionParams(t, rpc, + infrastructure.CreateTransactionParams(test.MasterAccount(), creationOp), + ) + tx, err := txnbuild.NewTransaction(params) + require.NoError(t, err) + infrastructure.SendSuccessfulTransaction(t, rpc, test.MasterKey(), tx) + + preimage := creationOp.HostFunction.MustCreateContractV2().ContractIdPreimage + rawID := infrastructure.GetContractID( + t, + test.MasterAccount().GetAccountID(), + preimage.MustFromAddress().Salt, + infrastructure.StandaloneNetworkPassphrase, + ) + fx := &eventsFixture{ + contractID: strkey.MustEncode(strkey.VersionByteContract, rawID[:]), + contractRaw: xdr.ContractId(rawID), + } + + for range n { + fx.increment(test) + } + for i := 1; i < n; i++ { + require.Greater(t, fx.ledgers[i], fx.ledgers[i-1], "increments must land in distinct ledgers") + } + return fx +} + +// drain follows the cursor until the status is not HAS_MORE. +func drain(t *testing.T, rpc *client.Client, req protocol.GetEventsV2Request, +) ([]protocol.EventInfoV2, protocol.GetEventsV2Response) { + const maxPages = 200 + descending := req.Order == protocol.OrderDescending + var all []protocol.EventInfoV2 + for page := range maxPages { + resp, err := rpc.GetEventsV2(t.Context(), req) + require.NoError(t, err, "page %d", page) + if req.Limit != nil { + require.LessOrEqual(t, len(resp.Events), int(*req.Limit), "page %d", page) + } + if page > 0 { + prev := all[len(all)-1].Ledger + for _, e := range resp.Events { + if descending { + require.LessOrEqual(t, e.Ledger, prev, "page %d: descending order broken", page) + } else { + require.GreaterOrEqual(t, e.Ledger, prev, "page %d: ascending order broken", page) + } + } + } + all = append(all, resp.Events...) + if resp.ScanStatus != protocol.ScanStatusHasMore { + return all, resp + } + require.NotEmpty(t, resp.Cursor, "page %d: HAS_MORE without a cursor", page) + req = protocol.GetEventsV2Request{Cursor: resp.Cursor, Limit: req.Limit, Format: req.Format} + } + t.Fatalf("still HAS_MORE after %d pages", maxPages) + return nil, protocol.GetEventsV2Response{} +} + +// The fee and refund events belong to the native asset contract, so only the +// contract id tells the fixture's own event apart. +func requireFixtureEvents(t *testing.T, fx *eventsFixture, events []protocol.EventInfoV2) { + require.Len(t, events, eventsPerInvoke*len(fx.ledgers)) + ids := make(map[string]struct{}, len(events)) + fromContract := 0 + for _, e := range events { + _, dup := ids[e.ID] + require.False(t, dup, "duplicate event id %s", e.ID) + ids[e.ID] = struct{}{} + assert.Contains(t, fx.ledgers, uint32(e.Ledger)) + assert.Contains(t, fx.txHashes, e.TransactionHash) + assert.Equal(t, protocol.EventTypeContract, e.EventType) + assert.NotEmpty(t, e.ContractID) + if e.ContractID == fx.contractID { + fromContract++ + } + } + require.Equal(t, len(fx.ledgers), fromContract, "one event per increment from the fixture's contract") +} + +func eventIDs(events []protocol.EventInfoV2) []string { + ids := make([]string, 0, len(events)) + for _, e := range events { + ids = append(ids, e.ID) + } + return ids +} + +func reversed(s []string) []string { + out := make([]string, len(s)) + for i, v := range s { + out[len(s)-1-i] = v + } + return out +} + +func TestGetEventsV2AscendingDrainToTheTip(t *testing.T) { + test := infrastructure.NewTest(t, nil) + rpc := test.GetRPCLient() + fx := deployAndIncrement(t, test, 3) + + events, last := drain(t, rpc, protocol.GetEventsV2Request{ + MinLedger: fx.first(), + Limit: new(uint(1)), + }) + requireFixtureEvents(t, fx, events) + for i := 1; i < len(events); i++ { + assert.LessOrEqual(t, events[i-1].Ledger, events[i].Ledger, "ascending order") + } + + assert.Equal(t, protocol.ScanStatusWaitingForLedgers, last.ScanStatus) + assert.NotEmpty(t, last.Cursor) + assert.GreaterOrEqual(t, last.LatestLedger, fx.last()) + assert.LessOrEqual(t, last.OldestLedger, fx.first()) + + again, err := rpc.GetEventsV2(t.Context(), protocol.GetEventsV2Request{Cursor: last.Cursor}) + require.NoError(t, err) + assert.Empty(t, again.Events) +} + +func TestGetEventsV2ClosedRangeBothOrders(t *testing.T) { + test := infrastructure.NewTest(t, nil) + rpc := test.GetRPCLient() + fx := deployAndIncrement(t, test, 3) + + asc, err := rpc.GetEventsV2(t.Context(), protocol.GetEventsV2Request{ + MinLedger: fx.first(), + MaxLedger: fx.last(), + }) + require.NoError(t, err) + requireFixtureEvents(t, fx, asc.Events) + assert.Equal(t, protocol.ScanStatusComplete, asc.ScanStatus) + assert.Empty(t, asc.Cursor, "a complete query carries no cursor") + + desc, err := rpc.GetEventsV2(t.Context(), protocol.GetEventsV2Request{ + MinLedger: fx.first(), + MaxLedger: fx.last(), + Order: protocol.OrderDescending, + }) + require.NoError(t, err) + assert.Equal(t, protocol.ScanStatusComplete, desc.ScanStatus) + assert.Empty(t, desc.Cursor) + assert.Equal(t, reversed(eventIDs(asc.Events)), eventIDs(desc.Events), + "descending is the exact reverse of ascending") + + paged, last := drain(t, rpc, protocol.GetEventsV2Request{ + MinLedger: fx.first(), + MaxLedger: fx.last(), + Order: protocol.OrderDescending, + Limit: new(uint(2)), + }) + assert.Equal(t, protocol.ScanStatusComplete, last.ScanStatus) + assert.Equal(t, eventIDs(desc.Events), eventIDs(paged)) +} + +func TestGetEventsV2FiltersOverTheWire(t *testing.T) { + test := infrastructure.NewTest(t, nil) + rpc := test.GetRPCLient() + fx := deployAndIncrement(t, test, 2) + + rng := protocol.GetEventsV2Request{MinLedger: fx.first(), MaxLedger: fx.last()} + call := func(t *testing.T, req protocol.GetEventsV2Request) protocol.GetEventsV2Response { + resp, err := rpc.GetEventsV2(t.Context(), req) + require.NoError(t, err) + assert.Equal(t, protocol.ScanStatusComplete, resp.ScanStatus) + return resp + } + contractOnly := func(resp protocol.GetEventsV2Response) []protocol.EventInfoV2 { + var out []protocol.EventInfoV2 + for _, e := range resp.Events { + if e.ContractID == fx.contractID { + out = append(out, e) + } + } + return out + } + + // Filter with the topic the node itself emitted, in both encodings. + unfiltered := call(t, rng) + requireFixtureEvents(t, fx, unfiltered.Events) + sample := contractOnly(unfiltered)[0] + require.NotEmpty(t, sample.TopicXDR) + require.Empty(t, sample.TopicJSON, "base64 output carries no JSON topics") + topic0XDR := sample.TopicXDR[0] + + rngJSON := rng + rngJSON.Format = protocol.FormatJSON + asJSON := call(t, rngJSON) + sampleJSON := contractOnly(asJSON)[0] + require.Equal(t, sample.ID, sampleJSON.ID) + require.NotEmpty(t, sampleJSON.TopicJSON) + require.Empty(t, sampleJSON.TopicXDR, "JSON output carries no base64 topics") + topic0JSON := sampleJSON.TopicJSON[0] + + wantIDs := eventIDs(contractOnly(unfiltered)) + require.Len(t, wantIDs, len(fx.ledgers)) + + t.Run("contract id", func(t *testing.T) { + req := rng + req.Filters = []protocol.EventFilterV2{{ContractID: fx.contractID}} + assert.Equal(t, wantIDs, eventIDs(call(t, req).Events)) + }) + + t.Run("contract id and type", func(t *testing.T) { + req := rng + req.Filters = []protocol.EventFilterV2{{ContractID: fx.contractID, EventType: protocol.EventTypeContract}} + assert.Equal(t, wantIDs, eventIDs(call(t, req).Events)) + }) + + t.Run("topic0 as base64", func(t *testing.T) { + req := rng + req.Filters = []protocol.EventFilterV2{{ + ContractID: fx.contractID, + Topic0: json.RawMessage(`"` + topic0XDR + `"`), + }} + assert.Equal(t, wantIDs, eventIDs(call(t, req).Events)) + }) + + // Rejected until #940 lands; then flip this to the positive check. + t.Run("topic0 as JSON is rejected until #940", func(t *testing.T) { + req := rng + req.XDRInputFormat = protocol.FormatJSON + req.Filters = []protocol.EventFilterV2{{ContractID: fx.contractID, Topic0: topic0JSON}} + _, err := rpc.GetEventsV2(t.Context(), req) + var rpcErr *jrpc2.Error + require.ErrorAs(t, err, &rpcErr) + assert.Equal(t, jrpc2.InvalidParams, rpcErr.Code) + var data protocol.InvalidParamsErrorData + require.NoError(t, json.Unmarshal(rpcErr.Data, &data)) + assert.Equal(t, protocol.ErrorReasonInvalidParams, data.Reason) + }) + + t.Run("topic0 that matches nothing", func(t *testing.T) { + nothing, err := xdr.MarshalBase64(xdr.ScVal{Type: xdr.ScValTypeScvSymbol, Sym: new(xdr.ScSymbol)}) + require.NoError(t, err) + req := rng + req.Filters = []protocol.EventFilterV2{{ + ContractID: fx.contractID, + Topic0: json.RawMessage(`"` + nothing + `"`), + }} + assert.Empty(t, call(t, req).Events) + }) + + t.Run("filters are OR-ed", func(t *testing.T) { + req := rng + req.Filters = []protocol.EventFilterV2{ + {ContractID: fx.contractID}, + {EventType: protocol.EventTypeContract}, + } + assert.Equal(t, eventIDs(unfiltered.Events), eventIDs(call(t, req).Events)) + }) +} + +// Same node, real Core output. The unit parity harness uses synthetic data. +func TestGetEventsV2MatchesV1(t *testing.T) { + test := infrastructure.NewTest(t, nil) + rpc := test.GetRPCLient() + fx := deployAndIncrement(t, test, 3) + + for _, format := range []string{"", protocol.FormatJSON} { + t.Run("format="+format, func(t *testing.T) { + v1, err := rpc.GetEvents(t.Context(), protocol.GetEventsRequest{ + StartLedger: fx.first(), + EndLedger: fx.last() + 1, // v1 endLedger is exclusive + Pagination: &protocol.PaginationOptions{Limit: 1000}, + Format: format, + }) + require.NoError(t, err) + v2, err := rpc.GetEventsV2(t.Context(), protocol.GetEventsV2Request{ + MinLedger: fx.first(), + MaxLedger: fx.last(), + Format: format, + }) + require.NoError(t, err) + requireFixtureEvents(t, fx, v2.Events) + + require.Len(t, v2.Events, len(v1.Events)) + for i := range v1.Events { + assert.Equal(t, protocol.EventInfoV2(v1.Events[i]), v2.Events[i], "event %d", i) + } + assert.Equal(t, v1.LatestLedger, v2.LatestLedger, "latestLedger") + assert.Equal(t, v1.OldestLedger, v2.OldestLedger, "oldestLedger") + }) + } +} + +// Page to the tip, submit, resume from the tip cursor. The union of the pages +// must equal a closed range read: nothing lost at the tip, nothing twice. +func TestGetEventsV2PagingWhileTipMoves(t *testing.T) { + test := infrastructure.NewTest(t, nil) + rpc := test.GetRPCLient() + fx := deployAndIncrement(t, test, 1) + + seen := make([]protocol.EventInfoV2, 0, 16) + req := protocol.GetEventsV2Request{MinLedger: fx.first(), Limit: new(uint(1))} + const rounds = 3 + for round := range rounds { + got, last := drain(t, rpc, req) + seen = append(seen, got...) + require.Equal(t, protocol.ScanStatusWaitingForLedgers, last.ScanStatus, "round %d", round) + require.NotEmpty(t, last.Cursor, "round %d", round) + + idle, err := rpc.GetEventsV2(t.Context(), protocol.GetEventsV2Request{Cursor: last.Cursor}) + require.NoError(t, err) + assert.Empty(t, idle.Events, "round %d: idle tip cursor served events", round) + assert.NotEmpty(t, idle.Cursor, "round %d: idle tip cursor lost the cursor", round) + + if round < rounds-1 { + fx.increment(test) + } + req = protocol.GetEventsV2Request{Cursor: idle.Cursor, Limit: new(uint(1))} + } + + requireFixtureEvents(t, fx, seen) + final, err := rpc.GetEventsV2(t.Context(), protocol.GetEventsV2Request{MinLedger: fx.first(), MaxLedger: fx.last()}) + require.NoError(t, err) + assert.Equal(t, eventIDs(final.Events), eventIDs(seen), "paging to the tip disagrees with a closed range read") +} diff --git a/cmd/stellar-rpc/internal/rpcv2/integrationtest/main_test.go b/cmd/stellar-rpc/internal/rpcv2/integrationtest/main_test.go new file mode 100644 index 000000000..eafae0b29 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/integrationtest/main_test.go @@ -0,0 +1,25 @@ +package integrationtest + +import ( + "fmt" + "os" + "testing" +) + +// Every test in this package needs the rpcv2 daemon. Default the harness +// selector to it, and refuse to run against anything else. +func TestMain(m *testing.M) { + const envVar = "STELLAR_RPC_INTEGRATION_TESTS_DAEMON" + switch os.Getenv(envVar) { + case "": + if err := os.Setenv(envVar, "rpcv2"); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + case "rpcv2": + default: + fmt.Fprintf(os.Stderr, "%s=%q: this package only runs against rpcv2\n", envVar, os.Getenv(envVar)) + os.Exit(1) + } + os.Exit(m.Run()) +} diff --git a/cmd/stellar-rpc/internal/rpcv2/jsonrpc_test.go b/cmd/stellar-rpc/internal/rpcv2/jsonrpc_test.go index 03f476c9c..390d90e46 100644 --- a/cmd/stellar-rpc/internal/rpcv2/jsonrpc_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/jsonrpc_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "net/http/httptest" + "strings" "testing" "time" @@ -132,6 +133,62 @@ func TestJSONRPCHandler_GetEventsV2ReportsTypedErrorData(t *testing.T) { assert.Equal(t, protocol.ErrorReasonInvalidParams, data.Reason) } +// A cursor the client did not get from this node must come back as a typed +// cursor_malformed or invalid_params error, never as an internal error, and +// the server must keep serving afterwards. The decoder itself is fuzzed in +// the query package; this pins the wire shape. +func TestJSONRPCHandler_GetEventsV2RejectsTamperedCursors(t *testing.T) { + url := newTestRPCServer(t, seedServingRegistry(t)) + + first := rpcv2test.PostRPC(t, url, protocol.GetEventsV2MethodName, `{"minLedger":2,"limit":1}`) + require.Nil(t, first.Error) + var page struct { + Cursor string `json:"cursor"` + } + require.NoError(t, json.Unmarshal(first.Result, &page)) + require.NotEmpty(t, page.Cursor, "an open range at the tip hands back a cursor") + c := page.Cursor + prefixEnd := strings.IndexByte(c, '_') + 1 + require.Positive(t, prefixEnd, "cursor %q has no prefix separator", c) + prefix, body := c[:prefixEnd], c[prefixEnd:] + + flipped := "A" + if strings.HasSuffix(body, "A") { + flipped = "B" + } + for name, cursor := range map[string]string{ + "empty": "", + "prefix only": prefix, + "body only": body, + "truncated body": prefix + body[:len(body)/2], + "flipped last char": prefix + body[:len(body)-1] + flipped, + "doubled body": prefix + body + body, + "v1 shaped cursor": "0000000137438953472-0000000000", + "wrong prefix": "gec0_" + body, + "garbage": prefix + "!!!not-base64!!!", + "extra trailing char": c + "A", + "whitespace inside": prefix + " " + body, + "very long": prefix + strings.Repeat("A", 4096), + } { + t.Run(name, func(t *testing.T) { + params, err := json.Marshal(map[string]string{"cursor": cursor}) + require.NoError(t, err) + out := rpcv2test.PostRPC(t, url, protocol.GetEventsV2MethodName, string(params)) + require.NotNil(t, out.Error) + assert.EqualValues(t, jrpc2.InvalidParams, out.Error.Code, "message: %s", out.Error.Message) + var data struct { + Reason string `json:"reason"` + } + require.NoError(t, json.Unmarshal(out.Error.Data, &data)) + assert.Contains(t, []string{protocol.ErrorReasonCursorMalformed, protocol.ErrorReasonInvalidParams}, data.Reason, + "message: %s", out.Error.Message) + }) + } + + again := rpcv2test.PostRPC(t, url, protocol.GetEventsV2MethodName, `{"minLedger":2,"limit":1}`) + require.Nil(t, again.Error, "the server must still serve after a burst of bad cursors") +} + // Every field is optional, so a typo would widen the query rather than fail. func TestJSONRPCHandler_GetEventsV2RejectsUnknownFields(t *testing.T) { url := newTestRPCServer(t, seedServingRegistry(t)) From d5dd7d4062fa370f540371418fd7ec41ad10e36f Mon Sep 17 00:00:00 2001 From: Urvi Date: Wed, 16 Sep 2026 09:08:11 -0700 Subject: [PATCH 02/13] go.mod: pin go-stellar-sdk to the GetEventsV2 client commit Points at stellar/go-stellar-sdk#6005's head until it merges; the pin then moves to the merge commit. Co-Authored-By: Claude Fable 5.1 --- go.mod | 4 ++-- go.sum | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index b1cd757dc..3c6422773 100644 --- a/go.mod +++ b/go.mod @@ -26,7 +26,7 @@ require ( github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.7.0 github.com/spf13/pflag v1.0.5 - github.com/stellar/go-stellar-sdk v0.7.4-0.20260903222904-d8c8acf5f56c + github.com/stellar/go-stellar-sdk v0.7.4-0.20260916152636-c033eaa5e2ff github.com/stellar/streamhash v0.1.1-0.20260826155856-77556799545b github.com/stretchr/testify v1.12.1 ) @@ -125,7 +125,7 @@ require ( github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pelletier/go-toml/v2 v2.1.0 // indirect github.com/pkg/errors v0.9.1 - github.com/prometheus/client_model v0.6.2 + github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.2 // indirect github.com/prometheus/procfs v0.19.2 github.com/sagikazarmark/locafero v0.3.0 // indirect diff --git a/go.sum b/go.sum index 2828a53be..aacb87dd1 100644 --- a/go.sum +++ b/go.sum @@ -347,8 +347,8 @@ github.com/spf13/viper v1.17.0 h1:I5txKw7MJasPL/BrfkbA0Jyo/oELqVmux4pR/UxOMfI= github.com/spf13/viper v1.17.0/go.mod h1:BmMMMLQXSbcHK6KAOiFLz0l5JHrU89OdIRHvsk0+yVI= github.com/spiffe/go-spiffe/v2 v2.7.0 h1:uXe1MflJoHw58wAUvxVlcM7WpKtijWG7I1UidcGh6g4= github.com/spiffe/go-spiffe/v2 v2.7.0/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= -github.com/stellar/go-stellar-sdk v0.7.4-0.20260903222904-d8c8acf5f56c h1:NxUyq0Xl4ASNoYllZFdUKIgSpWaNcnS0/iqzL1fo0JY= -github.com/stellar/go-stellar-sdk v0.7.4-0.20260903222904-d8c8acf5f56c/go.mod h1:bcErD+KvYtyrGqjaSjQeT7raOmlENi0WaORRwIJmfFE= +github.com/stellar/go-stellar-sdk v0.7.4-0.20260916152636-c033eaa5e2ff h1:6KnKLvrAcmShv+/O1N5w9KvJbfNeRPKqT/5nkBhjC8c= +github.com/stellar/go-stellar-sdk v0.7.4-0.20260916152636-c033eaa5e2ff/go.mod h1:bcErD+KvYtyrGqjaSjQeT7raOmlENi0WaORRwIJmfFE= github.com/stellar/go-xdr v0.0.0-20260806060815-dc590f17552a h1:40YIhQusSioBKDW5Tr+YdjoXphcVeu7eXUS2ibMQBTs= github.com/stellar/go-xdr v0.0.0-20260806060815-dc590f17552a/go.mod h1:If+U9Z1W5xU97VrOgJandQT+2dN7/iOpkCrxBJEyF80= github.com/stellar/streamhash v0.1.1-0.20260826155856-77556799545b h1:+qwFEBRESdIqVbLLMUtKf6RTnh7MDbNbyoP6T2WPfJc= From d4c650450f25ecc0783a4c68958ff95b8ce5e193 Mon Sep 17 00:00:00 2001 From: Urvi Date: Wed, 16 Sep 2026 09:38:36 -0700 Subject: [PATCH 03/13] rpcv2: state the JSON topic-input rejection as the contract, not a deferral #940 shipped the converter and is closed; the handler stays unwired on purpose and json remains a spec option. Stop citing the issue as pending. Co-Authored-By: Claude Fable 5.1 --- .../internal/rpcv2/eventsapi/conversions_test.go | 2 +- cmd/stellar-rpc/internal/rpcv2/eventsapi/get_events_v2.go | 6 +++--- .../internal/rpcv2/integrationtest/get_events_v2_test.go | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cmd/stellar-rpc/internal/rpcv2/eventsapi/conversions_test.go b/cmd/stellar-rpc/internal/rpcv2/eventsapi/conversions_test.go index 240744738..b089491eb 100644 --- a/cmd/stellar-rpc/internal/rpcv2/eventsapi/conversions_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/eventsapi/conversions_test.go @@ -283,7 +283,7 @@ func TestEventFilterFields(t *testing.T) { func TestEventFilterJSONFormat(t *testing.T) { _, transfer := symbolScVal(t, "transfer") - t.Run("with a topic is rejected until #940", func(t *testing.T) { + t.Run("with a topic is rejected", func(t *testing.T) { in := protocol.EventFilterV2{Topic0: requestTopic(t, transfer)} _, err := eventFilter(&in, protocol.FormatJSON) require.ErrorIs(t, err, errJSONInputFormatUnsupported) diff --git a/cmd/stellar-rpc/internal/rpcv2/eventsapi/get_events_v2.go b/cmd/stellar-rpc/internal/rpcv2/eventsapi/get_events_v2.go index ac6c7b188..83c3d72a3 100644 --- a/cmd/stellar-rpc/internal/rpcv2/eventsapi/get_events_v2.go +++ b/cmd/stellar-rpc/internal/rpcv2/eventsapi/get_events_v2.go @@ -351,9 +351,9 @@ func jrpcError(message string, data any) error { return &jrpc2.Error{Code: jrpc2.InvalidParams, Message: message, Data: encoded} } -// errJSONInputFormatUnsupported: xdrInputFormat "json" needs a JSON-to-XDR -// converter, because the term index matches on a topic's canonical bytes. -// Deferred to #940. +// errJSONInputFormatUnsupported: xdrInputFormat "json" is a spec option this +// server does not serve. The term index matches on a topic's canonical XDR +// bytes; xdr2json can produce them, but the handler is not wired to it. var errJSONInputFormatUnsupported = errors.New( "xdrInputFormat \"json\" is not supported yet") diff --git a/cmd/stellar-rpc/internal/rpcv2/integrationtest/get_events_v2_test.go b/cmd/stellar-rpc/internal/rpcv2/integrationtest/get_events_v2_test.go index ac2bf1462..c4e1ebc47 100644 --- a/cmd/stellar-rpc/internal/rpcv2/integrationtest/get_events_v2_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/integrationtest/get_events_v2_test.go @@ -262,8 +262,8 @@ func TestGetEventsV2FiltersOverTheWire(t *testing.T) { assert.Equal(t, wantIDs, eventIDs(call(t, req).Events)) }) - // Rejected until #940 lands; then flip this to the positive check. - t.Run("topic0 as JSON is rejected until #940", func(t *testing.T) { + // The spec lists json as an xdrInputFormat; this server does not serve it. + t.Run("topic0 as JSON is rejected", func(t *testing.T) { req := rng req.XDRInputFormat = protocol.FormatJSON req.Filters = []protocol.EventFilterV2{{ContractID: fx.contractID, Topic0: topic0JSON}} From 40a3883be1cd8e05cc3569396f64e2e493967471 Mon Sep 17 00:00:00 2001 From: Urvi Date: Thu, 17 Sep 2026 08:55:22 -0700 Subject: [PATCH 04/13] integrationtest: wait for one consensus ledger after the daemon catches up Core creates its Soroban transaction queue only when a ledger closes through consensus (HerderImpl::lastClosedLedgerIncreased), never during catch-up. A captive core that has just caught up answers every Soroban transaction with txNOT_SUPPORTED until its first consensus ledger closes. The harness submitted the limits upgrade inside that window, about one second wide, which is the setup flake seen as ERROR/txNOT_SUPPORTED at test.go:1071. Waiting for one more committed ledger after the daemon is caught up closes the window. Co-Authored-By: Claude Fable 5.1 --- .../integrationtest/infrastructure/test.go | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/cmd/stellar-rpc/internal/integrationtest/infrastructure/test.go b/cmd/stellar-rpc/internal/integrationtest/infrastructure/test.go index 36fdb186f..add431a65 100644 --- a/cmd/stellar-rpc/internal/integrationtest/infrastructure/test.go +++ b/cmd/stellar-rpc/internal/integrationtest/infrastructure/test.go @@ -604,6 +604,7 @@ func (i *Test) waitForRPC() { result, err := i.GetRPCLient().GetHealth(i.t.Context()) i.t.Logf("getHealth: %+v; err: %v", result, err) if err == nil && result.Status == "healthy" && i.caughtUpWithCore(result.LatestLedger) { + i.waitForOneMoreLedger(result.LatestLedger, deadline) return } require.False(i.t, time.Now().After(deadline), "RPC never got healthy: %+v", err) @@ -611,6 +612,25 @@ func (i *Test) waitForRPC() { } } +// waitForOneMoreLedger returns once the daemon has committed a ledger past +// caughtUp. Core creates its Soroban transaction queue only when a ledger +// closes through consensus, not during catch-up. A captive core that has just +// caught up rejects every Soroban transaction with txNOT_SUPPORTED until its +// first consensus ledger closes, so a test that submits one right after +// getHealth turns healthy fails in that window. One more committed ledger +// after catch-up means that close has happened. +func (i *Test) waitForOneMoreLedger(caughtUp uint32, deadline time.Time) { + for { + result, err := i.GetRPCLient().GetHealth(i.t.Context()) + if err == nil && result.LatestLedger > caughtUp { + return + } + require.False(i.t, time.Now().After(deadline), + "RPC never committed a ledger past %d: %+v", caughtUp, err) + time.Sleep(200 * time.Millisecond) + } +} + // caughtUpWithCore reports whether the daemon has ingested up to the ledger the // Core container is at. "healthy" alone is not enough here: a daemon that // starts at genesis reports healthy as soon as its first ledger commits, because From e0eea76f956b7d0026f8352a0eb974fb64b453d1 Mon Sep 17 00:00:00 2001 From: Urvi Date: Thu, 17 Sep 2026 10:41:38 -0700 Subject: [PATCH 05/13] rpcv2: the cursor wire test checks decoding, not provenance Cursors are unsigned, self-contained state; decoding checks well-formedness only. Rename the test and its comment to say so, and drop the empty-cursor case, which the handler reads as an omitted cursor and rejects for the missing minLedger, not as a malformed token. Co-Authored-By: Claude Fable 5.1 --- cmd/stellar-rpc/internal/rpcv2/jsonrpc_test.go | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/cmd/stellar-rpc/internal/rpcv2/jsonrpc_test.go b/cmd/stellar-rpc/internal/rpcv2/jsonrpc_test.go index 390d90e46..66684b3fe 100644 --- a/cmd/stellar-rpc/internal/rpcv2/jsonrpc_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/jsonrpc_test.go @@ -133,11 +133,11 @@ func TestJSONRPCHandler_GetEventsV2ReportsTypedErrorData(t *testing.T) { assert.Equal(t, protocol.ErrorReasonInvalidParams, data.Reason) } -// A cursor the client did not get from this node must come back as a typed -// cursor_malformed or invalid_params error, never as an internal error, and -// the server must keep serving afterwards. The decoder itself is fuzzed in -// the query package; this pins the wire shape. -func TestJSONRPCHandler_GetEventsV2RejectsTamperedCursors(t *testing.T) { +// A cursor that does not decode must come back as a typed cursor_malformed +// or invalid_params error, never an internal error, and the server keeps +// serving. The decoder is fuzzed in the query package; this pins the wire +// shape. +func TestJSONRPCHandler_GetEventsV2RejectsMalformedCursors(t *testing.T) { url := newTestRPCServer(t, seedServingRegistry(t)) first := rpcv2test.PostRPC(t, url, protocol.GetEventsV2MethodName, `{"minLedger":2,"limit":1}`) @@ -157,7 +157,6 @@ func TestJSONRPCHandler_GetEventsV2RejectsTamperedCursors(t *testing.T) { flipped = "B" } for name, cursor := range map[string]string{ - "empty": "", "prefix only": prefix, "body only": body, "truncated body": prefix + body[:len(body)/2], @@ -186,7 +185,7 @@ func TestJSONRPCHandler_GetEventsV2RejectsTamperedCursors(t *testing.T) { } again := rpcv2test.PostRPC(t, url, protocol.GetEventsV2MethodName, `{"minLedger":2,"limit":1}`) - require.Nil(t, again.Error, "the server must still serve after a burst of bad cursors") + require.Nil(t, again.Error, "the server must still serve after a burst of malformed cursors") } // Every field is optional, so a typo would widen the query rather than fail. From 3cd5ac9cf504a1483fa9914d82e289677673eb5d Mon Sep 17 00:00:00 2001 From: Urvi Date: Thu, 17 Sep 2026 11:10:42 -0700 Subject: [PATCH 06/13] integrationtest: only wait for the extra ledger in the live-Core modes The synthetic load test has no Core and a finite stream that may already be done when health first passes; the delayed-daemon mode is behind on purpose. Both would wait out the deadline for a ledger that never comes. Co-Authored-By: Claude Fable 5.1 --- .../internal/integrationtest/infrastructure/test.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cmd/stellar-rpc/internal/integrationtest/infrastructure/test.go b/cmd/stellar-rpc/internal/integrationtest/infrastructure/test.go index add431a65..fbc8912d2 100644 --- a/cmd/stellar-rpc/internal/integrationtest/infrastructure/test.go +++ b/cmd/stellar-rpc/internal/integrationtest/infrastructure/test.go @@ -604,7 +604,12 @@ func (i *Test) waitForRPC() { result, err := i.GetRPCLient().GetHealth(i.t.Context()) i.t.Logf("getHealth: %+v; err: %v", result, err) if err == nil && result.Status == "healthy" && i.caughtUpWithCore(result.LatestLedger) { - i.waitForOneMoreLedger(result.LatestLedger, deadline) + // Only the live-Core modes need the extra ledger. The synthetic + // load test has no Core and a finite stream that may already be + // done; the delayed-daemon mode is behind on purpose. + if i.coreClient != nil && i.delayDaemonForLedgerN == 0 { + i.waitForOneMoreLedger(result.LatestLedger, deadline) + } return } require.False(i.t, time.Now().After(deadline), "RPC never got healthy: %+v", err) From 90070583b91d5446638231290378103763936f27 Mon Sep 17 00:00:00 2001 From: Urvi Date: Fri, 18 Sep 2026 11:18:08 -0700 Subject: [PATCH 07/13] rpcv2: address review on the getEventsV2 tests and the harness wait - MatchesV1 reads the tip twice across a moving Core, so compare the two latestLedger readings by order, not equality. - Fold the extra-ledger wait into the waitForRPC loop so it keeps the daemon exit watch and the per-poll log. - The malformed-cursor cases all decode through DecodeEventCursor, so pin cursor_malformed exactly. - Add Test.CreateEventsContract beside the other create helpers and use it instead of a copied deploy block. - require, not assert, on the idle tip cursor so a failure stops at its cause instead of the next round. Co-Authored-By: Claude Fable 5.1 --- .../integrationtest/infrastructure/test.go | 56 ++++++++++--------- .../integrationtest/get_events_v2_test.go | 28 ++-------- .../internal/rpcv2/jsonrpc_test.go | 7 +-- 3 files changed, 39 insertions(+), 52 deletions(-) diff --git a/cmd/stellar-rpc/internal/integrationtest/infrastructure/test.go b/cmd/stellar-rpc/internal/integrationtest/infrastructure/test.go index fbc8912d2..7ad54fbfc 100644 --- a/cmd/stellar-rpc/internal/integrationtest/infrastructure/test.go +++ b/cmd/stellar-rpc/internal/integrationtest/infrastructure/test.go @@ -595,6 +595,16 @@ func (i *Test) waitForRPC() { if i.daemon != nil { exited = i.daemon.exited() } + // caughtUp is the ledger seen on the first poll that was healthy and caught + // up with Core, or 0 until then. Core creates its Soroban transaction queue + // only when a ledger closes through consensus, never during catch-up, so a + // captive core that has just caught up rejects every Soroban transaction + // with txNOT_SUPPORTED until its first consensus ledger closes. One more + // committed ledger after catch-up means that close has happened. Only the + // live-Core modes need it: the synthetic load test has no Core and a finite + // stream, and the delayed-daemon mode is behind on purpose. + var caughtUp uint32 + needOneMore := i.coreClient != nil && i.delayDaemonForLedgerN == 0 for { select { case err := <-exited: @@ -603,39 +613,24 @@ func (i *Test) waitForRPC() { } result, err := i.GetRPCLient().GetHealth(i.t.Context()) i.t.Logf("getHealth: %+v; err: %v", result, err) - if err == nil && result.Status == "healthy" && i.caughtUpWithCore(result.LatestLedger) { - // Only the live-Core modes need the extra ledger. The synthetic - // load test has no Core and a finite stream that may already be - // done; the delayed-daemon mode is behind on purpose. - if i.coreClient != nil && i.delayDaemonForLedgerN == 0 { - i.waitForOneMoreLedger(result.LatestLedger, deadline) + if err == nil && result.Status == "healthy" { + switch { + case caughtUp != 0: + if result.LatestLedger > caughtUp { + return + } + case i.caughtUpWithCore(result.LatestLedger): + if !needOneMore { + return + } + caughtUp = result.LatestLedger } - return } require.False(i.t, time.Now().After(deadline), "RPC never got healthy: %+v", err) time.Sleep(time.Second) } } -// waitForOneMoreLedger returns once the daemon has committed a ledger past -// caughtUp. Core creates its Soroban transaction queue only when a ledger -// closes through consensus, not during catch-up. A captive core that has just -// caught up rejects every Soroban transaction with txNOT_SUPPORTED until its -// first consensus ledger closes, so a test that submits one right after -// getHealth turns healthy fails in that window. One more committed ledger -// after catch-up means that close has happened. -func (i *Test) waitForOneMoreLedger(caughtUp uint32, deadline time.Time) { - for { - result, err := i.GetRPCLient().GetHealth(i.t.Context()) - if err == nil && result.LatestLedger > caughtUp { - return - } - require.False(i.t, time.Now().After(deadline), - "RPC never committed a ledger past %d: %+v", caughtUp, err) - time.Sleep(200 * time.Millisecond) - } -} - // caughtUpWithCore reports whether the daemon has ingested up to the ledger the // Core container is at. "healthy" alone is not enough here: a daemon that // starts at genesis reports healthy as soon as its first ledger commits, because @@ -998,6 +993,15 @@ func (i *Test) CreateHelloWorldContract() (protocol.GetTransactionResponse, [32] return i.PreflightAndSendMasterOperation(op), contractID, contractHash } +func (i *Test) CreateEventsContract() (protocol.GetTransactionResponse, [32]byte, xdr.Hash) { + _, contractHash := i.uploadContract(GetEventsContract()) + salt := xdr.Uint256(testSalt) + account := i.MasterAccount().GetAccountID() + op := createCreateContractV2Operation(account, salt, contractHash) + contractID := GetContractID(i.t, account, salt, StandaloneNetworkPassphrase) + return i.PreflightAndSendMasterOperation(op), contractID, contractHash +} + func (i *Test) CreateAutorestoreContract(items int) (protocol.GetTransactionResponse, [32]byte, xdr.Hash) { contractBinary := GetAutorestoreContract() _, contractHash := i.uploadContract(contractBinary) diff --git a/cmd/stellar-rpc/internal/rpcv2/integrationtest/get_events_v2_test.go b/cmd/stellar-rpc/internal/rpcv2/integrationtest/get_events_v2_test.go index c4e1ebc47..074d6e3c3 100644 --- a/cmd/stellar-rpc/internal/rpcv2/integrationtest/get_events_v2_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/integrationtest/get_events_v2_test.go @@ -11,7 +11,6 @@ import ( client "github.com/stellar/go-stellar-sdk/clients/rpcclient" protocol "github.com/stellar/go-stellar-sdk/protocols/rpc" "github.com/stellar/go-stellar-sdk/strkey" - "github.com/stellar/go-stellar-sdk/txnbuild" "github.com/stellar/go-stellar-sdk/xdr" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/integrationtest/infrastructure" @@ -38,24 +37,7 @@ func (f *eventsFixture) last() uint32 { return f.ledgers[len(f.ledgers)-1] } // Each call waits for inclusion, so the increments land in distinct ledgers. func deployAndIncrement(t *testing.T, test *infrastructure.Test, n int) *eventsFixture { - rpc := test.GetRPCLient() - test.UploadEventsContract() - - creationOp := infrastructure.CreateCreateEventsContractOperation(test.MasterAccount().GetAccountID()) - params := infrastructure.PreflightTransactionParams(t, rpc, - infrastructure.CreateTransactionParams(test.MasterAccount(), creationOp), - ) - tx, err := txnbuild.NewTransaction(params) - require.NoError(t, err) - infrastructure.SendSuccessfulTransaction(t, rpc, test.MasterKey(), tx) - - preimage := creationOp.HostFunction.MustCreateContractV2().ContractIdPreimage - rawID := infrastructure.GetContractID( - t, - test.MasterAccount().GetAccountID(), - preimage.MustFromAddress().Salt, - infrastructure.StandaloneNetworkPassphrase, - ) + _, rawID, _ := test.CreateEventsContract() fx := &eventsFixture{ contractID: strkey.MustEncode(strkey.VersionByteContract, rawID[:]), contractRaw: xdr.ContractId(rawID), @@ -324,7 +306,9 @@ func TestGetEventsV2MatchesV1(t *testing.T) { for i := range v1.Events { assert.Equal(t, protocol.EventInfoV2(v1.Events[i]), v2.Events[i], "event %d", i) } - assert.Equal(t, v1.LatestLedger, v2.LatestLedger, "latestLedger") + // The tip moves between the two reads; v2 was read second. + assert.GreaterOrEqual(t, v2.LatestLedger, v1.LatestLedger, "latestLedger") + assert.GreaterOrEqual(t, v1.LatestLedger, fx.last(), "latestLedger") assert.Equal(t, v1.OldestLedger, v2.OldestLedger, "oldestLedger") }) } @@ -348,8 +332,8 @@ func TestGetEventsV2PagingWhileTipMoves(t *testing.T) { idle, err := rpc.GetEventsV2(t.Context(), protocol.GetEventsV2Request{Cursor: last.Cursor}) require.NoError(t, err) - assert.Empty(t, idle.Events, "round %d: idle tip cursor served events", round) - assert.NotEmpty(t, idle.Cursor, "round %d: idle tip cursor lost the cursor", round) + require.Empty(t, idle.Events, "round %d: idle tip cursor served events", round) + require.NotEmpty(t, idle.Cursor, "round %d: idle tip cursor lost the cursor", round) if round < rounds-1 { fx.increment(test) diff --git a/cmd/stellar-rpc/internal/rpcv2/jsonrpc_test.go b/cmd/stellar-rpc/internal/rpcv2/jsonrpc_test.go index 66684b3fe..b55a9bd78 100644 --- a/cmd/stellar-rpc/internal/rpcv2/jsonrpc_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/jsonrpc_test.go @@ -134,9 +134,8 @@ func TestJSONRPCHandler_GetEventsV2ReportsTypedErrorData(t *testing.T) { } // A cursor that does not decode must come back as a typed cursor_malformed -// or invalid_params error, never an internal error, and the server keeps -// serving. The decoder is fuzzed in the query package; this pins the wire -// shape. +// error, never an internal error, and the server keeps serving. The decoder +// is fuzzed in the query package; this pins the wire shape. func TestJSONRPCHandler_GetEventsV2RejectsMalformedCursors(t *testing.T) { url := newTestRPCServer(t, seedServingRegistry(t)) @@ -179,7 +178,7 @@ func TestJSONRPCHandler_GetEventsV2RejectsMalformedCursors(t *testing.T) { Reason string `json:"reason"` } require.NoError(t, json.Unmarshal(out.Error.Data, &data)) - assert.Contains(t, []string{protocol.ErrorReasonCursorMalformed, protocol.ErrorReasonInvalidParams}, data.Reason, + assert.Equal(t, protocol.ErrorReasonCursorMalformed, data.Reason, "message: %s", out.Error.Message) }) } From 917b5c86e7018ac5da06c34d6072184ce89d5e41 Mon Sep 17 00:00:00 2001 From: Urvi Date: Fri, 18 Sep 2026 11:38:21 -0700 Subject: [PATCH 08/13] rpcv2: trim test comments to the facts the code does not show Co-Authored-By: Claude Fable 5.1 --- .../internal/integrationtest/infrastructure/test.go | 12 ++++-------- .../rpcv2/integrationtest/get_events_v2_test.go | 6 ++---- cmd/stellar-rpc/internal/rpcv2/jsonrpc_test.go | 5 ++--- 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/cmd/stellar-rpc/internal/integrationtest/infrastructure/test.go b/cmd/stellar-rpc/internal/integrationtest/infrastructure/test.go index 7ad54fbfc..755378ca4 100644 --- a/cmd/stellar-rpc/internal/integrationtest/infrastructure/test.go +++ b/cmd/stellar-rpc/internal/integrationtest/infrastructure/test.go @@ -595,14 +595,10 @@ func (i *Test) waitForRPC() { if i.daemon != nil { exited = i.daemon.exited() } - // caughtUp is the ledger seen on the first poll that was healthy and caught - // up with Core, or 0 until then. Core creates its Soroban transaction queue - // only when a ledger closes through consensus, never during catch-up, so a - // captive core that has just caught up rejects every Soroban transaction - // with txNOT_SUPPORTED until its first consensus ledger closes. One more - // committed ledger after catch-up means that close has happened. Only the - // live-Core modes need it: the synthetic load test has no Core and a finite - // stream, and the delayed-daemon mode is behind on purpose. + // Core builds its Soroban transaction queue only when a ledger closes + // through consensus, not during catch-up. The limits upgrade is a Soroban + // transaction, so wait for one consensus ledger before setup sends it. + // The load test has no Core; the delayed-daemon mode is behind on purpose. var caughtUp uint32 needOneMore := i.coreClient != nil && i.delayDaemonForLedgerN == 0 for { diff --git a/cmd/stellar-rpc/internal/rpcv2/integrationtest/get_events_v2_test.go b/cmd/stellar-rpc/internal/rpcv2/integrationtest/get_events_v2_test.go index 074d6e3c3..288cc84b3 100644 --- a/cmd/stellar-rpc/internal/rpcv2/integrationtest/get_events_v2_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/integrationtest/get_events_v2_test.go @@ -52,7 +52,6 @@ func deployAndIncrement(t *testing.T, test *infrastructure.Test, n int) *eventsF return fx } -// drain follows the cursor until the status is not HAS_MORE. func drain(t *testing.T, rpc *client.Client, req protocol.GetEventsV2Request, ) ([]protocol.EventInfoV2, protocol.GetEventsV2Response) { const maxPages = 200 @@ -203,7 +202,6 @@ func TestGetEventsV2FiltersOverTheWire(t *testing.T) { return out } - // Filter with the topic the node itself emitted, in both encodings. unfiltered := call(t, rng) requireFixtureEvents(t, fx, unfiltered.Events) sample := contractOnly(unfiltered)[0] @@ -314,8 +312,8 @@ func TestGetEventsV2MatchesV1(t *testing.T) { } } -// Page to the tip, submit, resume from the tip cursor. The union of the pages -// must equal a closed range read: nothing lost at the tip, nothing twice. +// The union of the pages must equal a closed range read: nothing lost at the +// tip, nothing twice. func TestGetEventsV2PagingWhileTipMoves(t *testing.T) { test := infrastructure.NewTest(t, nil) rpc := test.GetRPCLient() diff --git a/cmd/stellar-rpc/internal/rpcv2/jsonrpc_test.go b/cmd/stellar-rpc/internal/rpcv2/jsonrpc_test.go index b55a9bd78..6b8bdb723 100644 --- a/cmd/stellar-rpc/internal/rpcv2/jsonrpc_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/jsonrpc_test.go @@ -133,9 +133,8 @@ func TestJSONRPCHandler_GetEventsV2ReportsTypedErrorData(t *testing.T) { assert.Equal(t, protocol.ErrorReasonInvalidParams, data.Reason) } -// A cursor that does not decode must come back as a typed cursor_malformed -// error, never an internal error, and the server keeps serving. The decoder -// is fuzzed in the query package; this pins the wire shape. +// A cursor that does not decode gets a typed cursor_malformed error and the +// server keeps serving. The decoder itself is fuzzed in the query package. func TestJSONRPCHandler_GetEventsV2RejectsMalformedCursors(t *testing.T) { url := newTestRPCServer(t, seedServingRegistry(t)) From 406e8174bded2c51ff47dd9832b8cdb2efe82344 Mon Sep 17 00:00:00 2001 From: Urvi Date: Fri, 18 Sep 2026 11:45:54 -0700 Subject: [PATCH 09/13] rpcv2: drain tolerates an empty HAS_MORE page and checks order within a page Co-Authored-By: Claude Fable 5.1 --- .../internal/rpcv2/integrationtest/get_events_v2_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/stellar-rpc/internal/rpcv2/integrationtest/get_events_v2_test.go b/cmd/stellar-rpc/internal/rpcv2/integrationtest/get_events_v2_test.go index 288cc84b3..11174af90 100644 --- a/cmd/stellar-rpc/internal/rpcv2/integrationtest/get_events_v2_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/integrationtest/get_events_v2_test.go @@ -63,17 +63,17 @@ func drain(t *testing.T, rpc *client.Client, req protocol.GetEventsV2Request, if req.Limit != nil { require.LessOrEqual(t, len(resp.Events), int(*req.Limit), "page %d", page) } - if page > 0 { - prev := all[len(all)-1].Ledger - for _, e := range resp.Events { + for _, e := range resp.Events { + if len(all) > 0 { + prev := all[len(all)-1].Ledger if descending { require.LessOrEqual(t, e.Ledger, prev, "page %d: descending order broken", page) } else { require.GreaterOrEqual(t, e.Ledger, prev, "page %d: ascending order broken", page) } } + all = append(all, e) } - all = append(all, resp.Events...) if resp.ScanStatus != protocol.ScanStatusHasMore { return all, resp } From 96a62e110009cfb84624b4dfe54fb52032848b8c Mon Sep 17 00:00:00 2001 From: Urvi Date: Fri, 18 Sep 2026 12:02:58 -0700 Subject: [PATCH 10/13] go.mod: move the go-stellar-sdk pin to stellar/go-stellar-sdk#6005's current head Picks up the Filters omitzero tag; moves again to the merge commit when the PR lands. Co-Authored-By: Claude Fable 5.1 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 3c6422773..efb877d80 100644 --- a/go.mod +++ b/go.mod @@ -26,7 +26,7 @@ require ( github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.7.0 github.com/spf13/pflag v1.0.5 - github.com/stellar/go-stellar-sdk v0.7.4-0.20260916152636-c033eaa5e2ff + github.com/stellar/go-stellar-sdk v0.7.4-0.20260918184559-6c2b52080b31 github.com/stellar/streamhash v0.1.1-0.20260826155856-77556799545b github.com/stretchr/testify v1.12.1 ) diff --git a/go.sum b/go.sum index aacb87dd1..ce74b4c1d 100644 --- a/go.sum +++ b/go.sum @@ -347,8 +347,8 @@ github.com/spf13/viper v1.17.0 h1:I5txKw7MJasPL/BrfkbA0Jyo/oELqVmux4pR/UxOMfI= github.com/spf13/viper v1.17.0/go.mod h1:BmMMMLQXSbcHK6KAOiFLz0l5JHrU89OdIRHvsk0+yVI= github.com/spiffe/go-spiffe/v2 v2.7.0 h1:uXe1MflJoHw58wAUvxVlcM7WpKtijWG7I1UidcGh6g4= github.com/spiffe/go-spiffe/v2 v2.7.0/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= -github.com/stellar/go-stellar-sdk v0.7.4-0.20260916152636-c033eaa5e2ff h1:6KnKLvrAcmShv+/O1N5w9KvJbfNeRPKqT/5nkBhjC8c= -github.com/stellar/go-stellar-sdk v0.7.4-0.20260916152636-c033eaa5e2ff/go.mod h1:bcErD+KvYtyrGqjaSjQeT7raOmlENi0WaORRwIJmfFE= +github.com/stellar/go-stellar-sdk v0.7.4-0.20260918184559-6c2b52080b31 h1:PbSm/7QEC0V5j9UtJ8cyO6e+vfBVQZxc0IBkyehupGU= +github.com/stellar/go-stellar-sdk v0.7.4-0.20260918184559-6c2b52080b31/go.mod h1:bcErD+KvYtyrGqjaSjQeT7raOmlENi0WaORRwIJmfFE= github.com/stellar/go-xdr v0.0.0-20260806060815-dc590f17552a h1:40YIhQusSioBKDW5Tr+YdjoXphcVeu7eXUS2ibMQBTs= github.com/stellar/go-xdr v0.0.0-20260806060815-dc590f17552a/go.mod h1:If+U9Z1W5xU97VrOgJandQT+2dN7/iOpkCrxBJEyF80= github.com/stellar/streamhash v0.1.1-0.20260826155856-77556799545b h1:+qwFEBRESdIqVbLLMUtKf6RTnh7MDbNbyoP6T2WPfJc= From e9d971612b3fb7eecf6c2d7d1897701f4c7ae44e Mon Sep 17 00:00:00 2001 From: Urvi Date: Fri, 18 Sep 2026 12:06:26 -0700 Subject: [PATCH 11/13] go.mod: pin go-stellar-sdk to main with GetEventsV2 (stellar/go-stellar-sdk#6005 merged) Co-Authored-By: Claude Fable 5.1 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index efb877d80..aa066f5ba 100644 --- a/go.mod +++ b/go.mod @@ -26,7 +26,7 @@ require ( github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.7.0 github.com/spf13/pflag v1.0.5 - github.com/stellar/go-stellar-sdk v0.7.4-0.20260918184559-6c2b52080b31 + github.com/stellar/go-stellar-sdk v0.7.4-0.20260918190431-7092f4b101ef github.com/stellar/streamhash v0.1.1-0.20260826155856-77556799545b github.com/stretchr/testify v1.12.1 ) diff --git a/go.sum b/go.sum index ce74b4c1d..76139f86e 100644 --- a/go.sum +++ b/go.sum @@ -347,8 +347,8 @@ github.com/spf13/viper v1.17.0 h1:I5txKw7MJasPL/BrfkbA0Jyo/oELqVmux4pR/UxOMfI= github.com/spf13/viper v1.17.0/go.mod h1:BmMMMLQXSbcHK6KAOiFLz0l5JHrU89OdIRHvsk0+yVI= github.com/spiffe/go-spiffe/v2 v2.7.0 h1:uXe1MflJoHw58wAUvxVlcM7WpKtijWG7I1UidcGh6g4= github.com/spiffe/go-spiffe/v2 v2.7.0/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= -github.com/stellar/go-stellar-sdk v0.7.4-0.20260918184559-6c2b52080b31 h1:PbSm/7QEC0V5j9UtJ8cyO6e+vfBVQZxc0IBkyehupGU= -github.com/stellar/go-stellar-sdk v0.7.4-0.20260918184559-6c2b52080b31/go.mod h1:bcErD+KvYtyrGqjaSjQeT7raOmlENi0WaORRwIJmfFE= +github.com/stellar/go-stellar-sdk v0.7.4-0.20260918190431-7092f4b101ef h1:vR1EoiV0gUXSAhi1Ouv//m3qR4Mg/WJvGLyjXT3VpoA= +github.com/stellar/go-stellar-sdk v0.7.4-0.20260918190431-7092f4b101ef/go.mod h1:bcErD+KvYtyrGqjaSjQeT7raOmlENi0WaORRwIJmfFE= github.com/stellar/go-xdr v0.0.0-20260806060815-dc590f17552a h1:40YIhQusSioBKDW5Tr+YdjoXphcVeu7eXUS2ibMQBTs= github.com/stellar/go-xdr v0.0.0-20260806060815-dc590f17552a/go.mod h1:If+U9Z1W5xU97VrOgJandQT+2dN7/iOpkCrxBJEyF80= github.com/stellar/streamhash v0.1.1-0.20260826155856-77556799545b h1:+qwFEBRESdIqVbLLMUtKf6RTnh7MDbNbyoP6T2WPfJc= From 5ffc88f0576705fd8aa4c01943a8ec20a2b2981f Mon Sep 17 00:00:00 2001 From: Urvi Date: Fri, 18 Sep 2026 13:12:40 -0700 Subject: [PATCH 12/13] integrationtest: let the Core container reach the upgrade ledger before setting the key The daemon reports inclusion from its captive core. The upgrade key is then resolved by the Core container against its own last closed ledger, which can trail the captive core under CI load, and Core answers "Error setting configUpgradeSet". Seen twice on the P27 rpcv2 leg. Co-Authored-By: Claude Fable 5.1 --- .../internal/integrationtest/infrastructure/test.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cmd/stellar-rpc/internal/integrationtest/infrastructure/test.go b/cmd/stellar-rpc/internal/integrationtest/infrastructure/test.go index 755378ca4..91161a8af 100644 --- a/cmd/stellar-rpc/internal/integrationtest/infrastructure/test.go +++ b/cmd/stellar-rpc/internal/integrationtest/infrastructure/test.go @@ -1083,6 +1083,7 @@ func (i *Test) upgradeLimitsWithFile(limitFile, expectInSorobanInfo string) { txnCount := len(lines) / 2 // each upgrade command outputs txnB64 \n hash assert.Len(i.t, lines, 9) + var lastLedger uint32 for j := 0; j+1 < len(lines); j += 2 { b64 := lines[j] i.t.Logf("Upgrade transaction: %s (hash: %s)", b64, lines[j+1]) @@ -1093,9 +1094,14 @@ func (i *Test) upgradeLimitsWithFile(limitFile, expectInSorobanInfo string) { txn, t := gtxn.Transaction() require.True(i.t, t) - SendSuccessfulTransaction(i.t, i.rpcClient, nil /* signed @ L791 */, txn) + lastLedger = SendSuccessfulTransaction(i.t, i.rpcClient, nil /* signed @ L791 */, txn).Ledger } + // The daemon reported the ledger from its captive core. The upgrade key + // below is resolved by the Core container against its own last closed + // ledger, which can still be behind under load. + i.waitForCoreAtLedger(int(lastLedger)) + upgradeKey := strings.TrimSpace(lines[len(lines)-1]) i.t.Logf("Upgrading Core config with key: %s", upgradeKey) From 93b5f268f4a634c523a2e189ceca9f9723627f11 Mon Sep 17 00:00:00 2001 From: Urvi Date: Fri, 18 Sep 2026 13:23:05 -0700 Subject: [PATCH 13/13] rpcv2: skip the getEventsV2 integration tests below protocol 23 Same guard the shared contract-events test uses, applied before the harness starts a Core. Co-Authored-By: Claude Fable 5.1 --- .../integrationtest/get_events_v2_test.go | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/cmd/stellar-rpc/internal/rpcv2/integrationtest/get_events_v2_test.go b/cmd/stellar-rpc/internal/rpcv2/integrationtest/get_events_v2_test.go index 11174af90..bc9bc8210 100644 --- a/cmd/stellar-rpc/internal/rpcv2/integrationtest/get_events_v2_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/integrationtest/get_events_v2_test.go @@ -35,6 +35,15 @@ func (f *eventsFixture) increment(test *infrastructure.Test) { } func (f *eventsFixture) last() uint32 { return f.ledgers[len(f.ledgers)-1] } +// The fixture deploys with CreateContractV2 and counts unified transaction +// events, both protocol 23 and later. +func newEventsTest(t *testing.T) *infrastructure.Test { + if infrastructure.GetCoreMaxSupportedProtocol() < 23 { + t.Skip("Only test this for protocol >= 23") + } + return infrastructure.NewTest(t, nil) +} + // Each call waits for inclusion, so the increments land in distinct ledgers. func deployAndIncrement(t *testing.T, test *infrastructure.Test, n int) *eventsFixture { _, rawID, _ := test.CreateEventsContract() @@ -122,7 +131,7 @@ func reversed(s []string) []string { } func TestGetEventsV2AscendingDrainToTheTip(t *testing.T) { - test := infrastructure.NewTest(t, nil) + test := newEventsTest(t) rpc := test.GetRPCLient() fx := deployAndIncrement(t, test, 3) @@ -146,7 +155,7 @@ func TestGetEventsV2AscendingDrainToTheTip(t *testing.T) { } func TestGetEventsV2ClosedRangeBothOrders(t *testing.T) { - test := infrastructure.NewTest(t, nil) + test := newEventsTest(t) rpc := test.GetRPCLient() fx := deployAndIncrement(t, test, 3) @@ -181,7 +190,7 @@ func TestGetEventsV2ClosedRangeBothOrders(t *testing.T) { } func TestGetEventsV2FiltersOverTheWire(t *testing.T) { - test := infrastructure.NewTest(t, nil) + test := newEventsTest(t) rpc := test.GetRPCLient() fx := deployAndIncrement(t, test, 2) @@ -279,7 +288,7 @@ func TestGetEventsV2FiltersOverTheWire(t *testing.T) { // Same node, real Core output. The unit parity harness uses synthetic data. func TestGetEventsV2MatchesV1(t *testing.T) { - test := infrastructure.NewTest(t, nil) + test := newEventsTest(t) rpc := test.GetRPCLient() fx := deployAndIncrement(t, test, 3) @@ -315,7 +324,7 @@ func TestGetEventsV2MatchesV1(t *testing.T) { // The union of the pages must equal a closed range read: nothing lost at the // tip, nothing twice. func TestGetEventsV2PagingWhileTipMoves(t *testing.T) { - test := infrastructure.NewTest(t, nil) + test := newEventsTest(t) rpc := test.GetRPCLient() fx := deployAndIncrement(t, test, 1)