From 0a4405ac2459217e2bd21377d502c16aa65cc700 Mon Sep 17 00:00:00 2001 From: Christian Jonas Date: Fri, 4 Sep 2026 17:59:36 -0400 Subject: [PATCH 01/22] migrate initial signatures for views + use view walk in eventInfoForEvents --- .../internal/methods/get_events.go | 89 ++++++++++--------- .../internal/rpcv1/sqlitedb/event.go | 9 +- .../internal/rpcv1/sqlitedb/event_test.go | 2 +- cmd/stellar-rpc/internal/store/event.go | 6 +- 4 files changed, 53 insertions(+), 53 deletions(-) diff --git a/cmd/stellar-rpc/internal/methods/get_events.go b/cmd/stellar-rpc/internal/methods/get_events.go index 6ff25135e..5f43d986d 100644 --- a/cmd/stellar-rpc/internal/methods/get_events.go +++ b/cmd/stellar-rpc/internal/methods/get_events.go @@ -2,9 +2,10 @@ package methods import ( "context" + "encoding/base64" "encoding/json" "fmt" - "strconv" + "math" "time" "github.com/creachadair/jrpc2" @@ -120,7 +121,7 @@ func combineTopics(filters []protocol.EventFilter) (store.TopicFilters, error) { type entry struct { cursor protocol.Cursor ledgerCloseTimestamp int64 - event xdr.DiagnosticEvent + eventView xdr.DiagnosticEventView txHash *xdr.Hash } @@ -195,16 +196,16 @@ func (h eventsRPCHandler) getEvents(ctx context.Context, request protocol.GetEve eventTypes := combineEventTypes(request.Filters) // Scan function to apply filters - var eventScanFunction store.ScanFunction = func( - event xdr.DiagnosticEvent, cursor protocol.Cursor, ledgerCloseTimestamp int64, txHash *xdr.Hash, + var eventViewScanFunction store.ViewScanFunction = func( + eventView xdr.DiagnosticEventView, cursor protocol.Cursor, ledgerCloseTimestamp int64, txHash *xdr.Hash, ) bool { - if request.Matches(event) { - found = append(found, entry{cursor, ledgerCloseTimestamp, event, txHash}) + if request.Matches(eventView) { + found = append(found, entry{cursor, ledgerCloseTimestamp, eventView, txHash}) } return uint(len(found)) < limit } - err = h.dbReader.GetEvents(ctx, cursorRange, contractIDs, topics, eventTypes, eventScanFunction) + err = h.dbReader.GetEvents(ctx, cursorRange, contractIDs, topics, eventTypes, eventViewScanFunction) if err != nil { return protocol.GetEventsResponse{}, &jrpc2.Error{ Code: jrpc2.InvalidRequest, Message: err.Error(), @@ -214,7 +215,7 @@ func (h eventsRPCHandler) getEvents(ctx context.Context, request protocol.GetEve results := make([]protocol.EventInfo, 0, len(found)) for _, entry := range found { info, err := eventInfoForEvent( - entry.event, + entry.eventView, entry.cursor, time.Unix(entry.ledgerCloseTimestamp, 0).UTC().Format(time.RFC3339), entry.txHash.HexString(), @@ -251,28 +252,39 @@ func (h eventsRPCHandler) getEvents(ctx context.Context, request protocol.GetEve } func eventInfoForEvent( - event xdr.DiagnosticEvent, + eventView xdr.DiagnosticEventView, cursor protocol.Cursor, ledgerClosedAt, txHash, format string, ) (protocol.EventInfo, error) { - v0, ok := event.Event.Body.GetV0() - if !ok { - return protocol.EventInfo{}, errors.New("unknown event version") - } - - eventType, ok := protocol.GetEventTypeFromEventTypeXDR()[event.Event.Type] - if !ok { - return protocol.EventInfo{}, fmt.Errorf("unknown XDR ContractEventType type: %d", event.Event.Type) + var ( + xdrType xdr.ContractEventType + topics [][]byte + dataRaw []byte + cidRaw []byte + ) + err := xdr.TryVoid(func() { + ev := eventView.MustEvent() + xdrType = ev.MustType().MustValue() + v0 := ev.MustBody().MustV0() // panics on a non-V0 body, replacing "unknown event version" + for t := range v0.MustTopics().MustIter() { + topics = append(topics, t.MustRaw()) + } + dataRaw = v0.MustData().MustRaw() + if cid, ok := ev.MustContractId().MustUnwrap(); ok { + cidRaw = cid.MustRaw() + } + }) + if err != nil { + return protocol.EventInfo{}, errors.Wrap(err, "malformed event") } - ledger, err := strconv.ParseInt(strconv.FormatUint(uint64(cursor.Ledger), 10), 10, 32) - if err != nil { + if cursor.Ledger > math.MaxInt32 { return protocol.EventInfo{}, fmt.Errorf("ledger sequence %d exceeds supported range", cursor.Ledger) } info := protocol.EventInfo{ - EventType: eventType, - Ledger: int32(ledger), + EventType: protocol.GetEventTypeFromEventTypeXDR()[xdrType], + Ledger: int32(cursor.Ledger), LedgerClosedAt: ledgerClosedAt, ID: cursor.String(), TransactionHash: txHash, @@ -280,12 +292,19 @@ func eventInfoForEvent( TxIndex: cursor.Tx, } + if cidRaw != nil { + info.ContractID = strkey.MustEncode( + strkey.VersionByteContract, + cidRaw, + ) + } + switch format { case protocol.FormatJSON: // json encode the topic info.TopicJSON = make([]json.RawMessage, 0, protocol.MaxTopicCount) - for _, topic := range v0.Topics { - topic, err := xdr2json.ConvertInterface(topic) + for _, topicView := range topics { + topic, err := xdr2json.ConvertBytes(xdr.ScVal{}, topicView) if err != nil { return protocol.EventInfo{}, err } @@ -293,7 +312,7 @@ func eventInfoForEvent( } var convErr error - info.ValueJSON, convErr = xdr2json.ConvertInterface(v0.Data) + info.ValueJSON, convErr = xdr2json.ConvertBytes(xdr.ScVal{}, dataRaw) if convErr != nil { return protocol.EventInfo{}, convErr } @@ -301,29 +320,13 @@ func eventInfoForEvent( default: // base64-xdr encode the topic topic := make([]string, 0, protocol.MaxTopicCount) - for _, segment := range v0.Topics { - seg, err := xdr.MarshalBase64(segment) - if err != nil { - return protocol.EventInfo{}, err - } - topic = append(topic, seg) - } - - // base64-xdr encode the data - data, err := xdr.MarshalBase64(v0.Data) - if err != nil { - return protocol.EventInfo{}, err + for _, segment := range topics { + topic = append(topic, base64.StdEncoding.EncodeToString(segment)) } - info.TopicXDR = topic - info.ValueXDR = data + info.ValueXDR = base64.StdEncoding.EncodeToString(dataRaw) // base64-xdr encode the data } - if event.Event.ContractId != nil { - info.ContractID = strkey.MustEncode( - strkey.VersionByteContract, - (*event.Event.ContractId)[:]) - } return info, nil } diff --git a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/event.go b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/event.go index 06c8d8267..cee64af56 100644 --- a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/event.go +++ b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/event.go @@ -300,7 +300,7 @@ func (eventHandler *eventHandler) GetEvents( contractIDs [][]byte, topics store.TopicFilters, eventTypes []int, - scanner store.ScanFunction, + scanner store.ViewScanFunction, ) error { start := time.Now() @@ -398,13 +398,10 @@ func (eventHandler *eventHandler) GetEvents( return errors.Join(err, errors.New("failed to parse cursor")) } - var event xdr.DiagnosticEvent - if err := event.UnmarshalBinary(row.eventData); err != nil { - return errors.Join(err, errors.New("failed to decode event")) - } + eventView := xdr.DiagnosticEventView(row.eventData) txHash := xdr.Hash(transactionHash) - if !scanner(event, cur, ledgerCloseTime, &txHash) { + if !scanner(eventView, cur, ledgerCloseTime, &txHash) { return nil } } diff --git a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/event_test.go b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/event_test.go index 20ced00ae..bb45d7ab7 100644 --- a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/event_test.go +++ b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/event_test.go @@ -276,7 +276,7 @@ func TestInsertEventsBatchingExceedsLimit(t *testing.T) { var count int err = eventReader.GetEvents(ctx, cursorRange, nil, nil, nil, - func(_ xdr.DiagnosticEvent, _ protocol.Cursor, _ int64, _ *xdr.Hash) bool { + func(_ xdr.DiagnosticEventView, _ protocol.Cursor, _ int64, _ *xdr.Hash) bool { count++ return true }) diff --git a/cmd/stellar-rpc/internal/store/event.go b/cmd/stellar-rpc/internal/store/event.go index e43c80705..93eff9c4c 100644 --- a/cmd/stellar-rpc/internal/store/event.go +++ b/cmd/stellar-rpc/internal/store/event.go @@ -24,8 +24,8 @@ type TopicFilter []TopicCondition // matches a row, the DB event is considered a candidate for further filtering. type TopicFilters []TopicFilter -type ScanFunction func( - event xdr.DiagnosticEvent, +type ViewScanFunction func( + eventView xdr.DiagnosticEventView, cursor protocol.Cursor, ledgerCloseTimestamp int64, txHash *xdr.Hash, @@ -39,7 +39,7 @@ type EventReader interface { contractIDs [][]byte, topics TopicFilters, eventTypes []int, - f ScanFunction, + f ViewScanFunction, ) error } From 6715f381df8628d6a0d1979a267d22991f459d68 Mon Sep 17 00:00:00 2001 From: Christian Jonas Date: Fri, 4 Sep 2026 18:53:59 -0400 Subject: [PATCH 02/22] move events view matcher + v1 filter compiler from rpcv2 into shared/store --- .../internal/rpcv2/eventsapi/get_events_v1.go | 124 +----- .../internal/rpcv2/stores/event/match.go | 310 +------------ .../internal/rpcv2/stores/event/match_test.go | 5 +- cmd/stellar-rpc/internal/store/event_match.go | 409 ++++++++++++++++++ 4 files changed, 434 insertions(+), 414 deletions(-) create mode 100644 cmd/stellar-rpc/internal/store/event_match.go diff --git a/cmd/stellar-rpc/internal/rpcv2/eventsapi/get_events_v1.go b/cmd/stellar-rpc/internal/rpcv2/eventsapi/get_events_v1.go index a9e3f7fbb..2f1463984 100644 --- a/cmd/stellar-rpc/internal/rpcv2/eventsapi/get_events_v1.go +++ b/cmd/stellar-rpc/internal/rpcv2/eventsapi/get_events_v1.go @@ -16,9 +16,7 @@ import ( "github.com/creachadair/jrpc2" protocol "github.com/stellar/go-stellar-sdk/protocols/rpc" - "github.com/stellar/go-stellar-sdk/strkey" supportlog "github.com/stellar/go-stellar-sdk/support/log" - "github.com/stellar/go-stellar-sdk/xdr" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/methods" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/adapters" @@ -185,126 +183,10 @@ func v1Response( } } -// v1Filters expands a validated v1 filter list into the pager's filters. The -// OR dimensions within one v1 filter (contract ids, topic filters) multiply -// out, one store filter per combination: at most 5 filters x 5 contract ids -// x 5 topics = 125, under the pager's cap. A combination with no constraints -// matches every event, so the whole query collapses to the pager's -// match-all (nil). +// v1Filters compiles a validated v1 filter list into the pager's filters; +// nil matches every event. func v1Filters(in []protocol.EventFilter) ([]event.Filter, error) { - var out []event.Filter - for i := range in { - expanded, matchAll, err := expandV1Filter(&in[i]) - if err != nil { - return nil, err - } - if matchAll { - return nil, nil - } - out = append(out, expanded...) - } - return out, nil -} - -func expandV1Filter(f *protocol.EventFilter) ([]event.Filter, bool, error) { - // A validated type set holds only contract and system: naming both - // constrains nothing, naming one is one term. Either way the type never - // multiplies the expansion. - var eventType *xdr.ContractEventType - if len(f.EventType) == 1 { - name := f.EventType.Keys()[0] - typ, ok := protocol.GetEventTypeXDRFromEventType()[name] - if !ok { - // Valid admits only contract and system, so a name that is - // neither is a handler bug, not client input. - return nil, false, fmt.Errorf("unsupported event type %q", name) - } - eventType = &typ - } - contracts := [][]byte{nil} - if len(f.ContractIDs) > 0 { - contracts = make([][]byte, 0, len(f.ContractIDs)) - for _, id := range f.ContractIDs { - raw, err := strkey.Decode(strkey.VersionByteContract, id) - if err != nil { - // Unreachable: req.Valid decoded it already. The message is - // the v1 handler's backstop wording. - return nil, false, fmt.Errorf("invalid contract ID: %v", id) - } - contracts = append(contracts, raw) - } - } - shapes := []topicShape{{}} - if len(f.Topics) > 0 { - shapes = make([]topicShape, 0, len(f.Topics)) - for _, tf := range f.Topics { - shape, err := topicShapeOf(tf) - if err != nil { - return nil, false, err - } - shapes = append(shapes, shape) - } - } - - out := make([]event.Filter, 0, len(contracts)*len(shapes)) - for _, cid := range contracts { - for _, sh := range shapes { - flt := event.Filter{ - ContractID: cid, EventType: eventType, - Topics: sh.topics, TopicCount: sh.count, - } - if isMatchAll(&flt) { - return nil, true, nil - } - out = append(out, flt) - } - } - return out, false, nil -} - -// topicShape is one v1 TopicFilter in the store's terms: the pinned -// positional values plus the arity constraint. N segments match exactly N -// topics; a trailing "**" relaxes that to at least the prefix, and "at least -// zero" is the zero value, no constraint. -type topicShape struct { - topics [protocol.MaxTopicCount][]byte - count event.TopicCountFilter -} - -func topicShapeOf(tf protocol.TopicFilter) (topicShape, error) { - segs := tf - shape := topicShape{count: event.TopicCountFilter{Count: len(tf), Exact: true}} - if n := len(tf); n > 0 && tf[n-1].Wildcard != nil && *tf[n-1].Wildcard == protocol.WildCardZeroOrMore { - // "At least zero" is the wildcard, which is the zero value. - segs = tf[:n-1] - shape.count = event.TopicCountFilter{Count: len(segs)} - } - for i, s := range segs { - // "*" is any value, and the position still exists via the count. - // A segment with neither value nor wildcard is skipped the way the - // shared handler skips it, rather than dereferenced. - if s.Wildcard != nil || s.ScVal == nil { - continue - } - raw, err := s.ScVal.MarshalBinary() - if err != nil { - return topicShape{}, fmt.Errorf("failed to marshal segment: %w", err) - } - shape.topics[i] = raw - } - return shape, nil -} - -func isMatchAll(f *event.Filter) bool { - if f.EventType != nil || len(f.ContractID) > 0 || f.TopicCount != (event.TopicCountFilter{}) { - return false - } - for i := range f.Topics { - if len(f.Topics[i]) > 0 { - return false - } - } - return true + return store.CompileV1EventFilters(in) } // eventInfoV1 mints the v1 response event: the same stored-payload decode as diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/match.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/match.go index d263667dd..0e879b02b 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/match.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/match.go @@ -23,58 +23,23 @@ import ( protocol "github.com/stellar/go-stellar-sdk/protocols/rpc" "github.com/stellar/go-stellar-sdk/xdr" + + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/store" ) -// Filter is one item in the union of an events query. Within a -// single filter, every constrained field is AND-ed together against -// the corresponding indexed field of an event. Fields left at their -// zero value are wildcards. -// -// Topics[i] constrains topic position i. Positions beyond -// protocol.MaxTopicCount are not indexed (see TermsForBytes). -type Filter struct { - ContractID []byte - Topics [protocol.MaxTopicCount][]byte - // EventType constrains the event's type. A nil pointer is a - // wildcard. A filter accepting several types is several filters. - EventType *xdr.ContractEventType - // TopicCount constrains how many topics the event carries. - TopicCount TopicCountFilter -} +// Filter is one clause in the union of an events query; see +// store.EventFilter. Aliased so the sqlite backend shares the matcher. +type Filter = store.EventFilter -// TopicCountFilter constrains an event's topic count to at least -// Count, or to exactly Count when Exact is set. Its zero value, "at -// least zero", is the wildcard. -// -// This is getEvents v1's topic arity: a topic filter ending in "**" -// matches events with at least as many topics as the filter names, and -// one that does not match events with exactly that many. -type TopicCountFilter struct { - Count int - Exact bool -} - -func (f TopicCountFilter) isWildcard() bool { return f == TopicCountFilter{} } - -// matches reports whether an event carrying n topics satisfies f. A -// negative n stands for an event with no V0 body, which carries no -// topics at all and satisfies no constraint. -func (f TopicCountFilter) matches(n int) bool { - if n < 0 { - return false - } - if f.Exact { - return n == f.Count - } - return n >= f.Count -} +// TopicCountFilter constrains an event's topic count; see store.TopicCountFilter. +type TopicCountFilter = store.TopicCountFilter // termKeys returns the topic-count buckets whose union covers f. Every // count ValidateFilters admits has a bucket of its own, and an "at // least" union is closed by the overflow bucket, so the union never // holds an event f does not match. -func (f TopicCountFilter) termKeys() []TermKey { - if f.isWildcard() { +func topicCountTermKeys(f TopicCountFilter) []TermKey { + if f.IsWildcard() { return nil } if f.Exact { @@ -89,7 +54,7 @@ func (f TopicCountFilter) termKeys() []TermKey { // over which values a filter names. The topic-count buckets are not // value terms; termGroups adds them separately and the budget does // not count them. -func (f *Filter) valueTermKeys() []TermKey { +func filterValueTermKeys(f *Filter) []TermKey { var keys []TermKey if len(f.ContractID) > 0 { keys = append(keys, ComputeTermKey(f.ContractID, FieldContractID)) @@ -109,9 +74,9 @@ func (f *Filter) valueTermKeys() []TermKey { // termGroups returns the indexed terms this filter constrains, grouped // by field: the bitmaps within a group are OR-ed and the groups are // AND-ed. Only the topic-count group ever holds more than one term. -func (f *Filter) termGroups() [][]TermKey { +func filterTermGroups(f *Filter) [][]TermKey { var groups [][]TermKey - for _, key := range f.valueTermKeys() { + for _, key := range filterValueTermKeys(f) { groups = append(groups, []TermKey{key}) } // A constrained topic position already implies an "at least" count at @@ -119,8 +84,8 @@ func (f *Filter) termGroups() [][]TermKey { // that position. Skipping the group there keeps the common // ["a", "**"] shape from OR-ing chunk-sized bucket bitmaps; the // post-filter enforces the count either way. - if !f.impliesTopicCount() { - if keys := f.TopicCount.termKeys(); len(keys) > 0 { + if !filterImpliesTopicCount(f) { + if keys := topicCountTermKeys(f.TopicCount); len(keys) > 0 { groups = append(groups, keys) } } @@ -129,7 +94,7 @@ func (f *Filter) termGroups() [][]TermKey { // impliesTopicCount reports whether f's constrained topic positions // already guarantee its topic-count bound. -func (f *Filter) impliesTopicCount() bool { +func filterImpliesTopicCount(f *Filter) bool { if f.TopicCount.Exact { return false } @@ -339,7 +304,7 @@ func unionForFilters( var uniqueKeys []TermKey for i := range filters { - groups := filters[i].termGroups() + groups := filterTermGroups(&filters[i]) if len(groups) == 0 { return nil, true, nil } @@ -569,7 +534,7 @@ func ValidateFilters(filters []Filter) error { func CountDistinctTerms(filters []Filter) int { unique := make(map[TermKey]struct{}) for i := range filters { - for _, key := range filters[i].valueTermKeys() { + for _, key := range filterValueTermKeys(&filters[i]) { unique[key] = struct{}{} } } @@ -687,9 +652,9 @@ func streamRange( // Match.Ordinal. func postFilter(payloads []Payload, ids []uint32, filters []Filter) ([]Match, error) { out := make([]Match, 0, len(payloads)) - plan := planFilters(filters) + plan := store.PlanFilters(filters) for i := range payloads { - ok, err := matchesAnyFilterView(payloads[i].ContractEventBytes, filters, &plan) + ok, err := store.MatchesAnyFilterView(xdr.ContractEventView(payloads[i].ContractEventBytes), filters, &plan) if err != nil { return nil, err } @@ -699,240 +664,3 @@ func postFilter(payloads []Payload, ids []uint32, filters []Filter) ([]Match, er } return out, nil } - -// filterPlan caches per-query info computed once at postFilter entry -// and consumed by matchesAnyFilterView: the topic positions any -// clause constrains and the highest constrained position (caps the -// view-path topic walk). -type filterPlan struct { - anyTopic bool - maxTopicIdx int // -1 if no clause constrains any topic - needsTopic [protocol.MaxTopicCount]bool -} - -func planFilters(filters []Filter) filterPlan { - plan := filterPlan{maxTopicIdx: -1} - for fi := range filters { - f := &filters[fi] - for i, want := range f.Topics { - if len(want) == 0 { - continue - } - plan.needsTopic[i] = true - plan.anyTopic = true - if i > plan.maxTopicIdx { - plan.maxTopicIdx = i - } - } - } - return plan -} - -// eventFields holds the decoded fields matchesAnyFilterView pulls out -// of one event, each resolved at most once and only when some clause -// asks for it. -type eventFields struct { - contractID []byte - contractIDDone bool - - eventType xdr.ContractEventType - eventTypeDone bool - - topicCount int - topicCountDone bool - - topics [protocol.MaxTopicCount][]byte - topicsDone bool -} - -// matchesAnyFilterView reports whether the event encoded in raw -// satisfies at least one filter clause. It resolves each field a -// clause constrains via xdr.ContractEventView navigation, -// byte-comparing aliased .Raw() slices against the filter clauses. -// Zero per-event allocation — every byte slice involved aliases into -// raw. -// -// Fields are resolved at most once and only when a clause asks for -// them, cheapest first: events that fail every clause's type or -// ContractId check never trigger the topic walk, and events that pass -// do exactly one linear walk over Topics up to the highest constrained -// position. -// -//nolint:gocognit,cyclop // linear clause loop with per-field lazy caches; helpers would fragment the invariant -func matchesAnyFilterView(raw []byte, filters []Filter, plan *filterPlan) (bool, error) { - ev := xdr.ContractEventView(raw) - var got eventFields - - for fi := range filters { - f := &filters[fi] - if f.EventType != nil { - if !got.eventTypeDone { - eventType, err := resolveViewEventType(ev) - if err != nil { - return false, err - } - got.eventType, got.eventTypeDone = eventType, true - } - if got.eventType != *f.EventType { - continue - } - } - if len(f.ContractID) > 0 { - if !got.contractIDDone { - cid, err := resolveViewContractID(ev) - if err != nil { - return false, err - } - got.contractID, got.contractIDDone = cid, true - } - if !bytes.Equal(got.contractID, f.ContractID) { - continue - } - } - if !f.TopicCount.isWildcard() { - if !got.topicCountDone { - n, err := resolveViewTopicCount(ev) - if err != nil { - return false, err - } - got.topicCount, got.topicCountDone = n, true - } - if !f.TopicCount.matches(got.topicCount) { - continue - } - } - matched := true - for i, want := range f.Topics { - if len(want) == 0 { - continue - } - if !got.topicsDone { - if err := collectTopicViewBytes(ev, plan, &got.topics); err != nil { - return false, err - } - got.topicsDone = true - } - g := got.topics[i] - if g == nil || !bytes.Equal(g, want) { - matched = false - break - } - } - if matched { - return true, nil - } - } - return false, nil -} - -func resolveViewEventType(ev xdr.ContractEventView) (xdr.ContractEventType, error) { - typeView, err := ev.Type() - if err != nil { - return 0, fmt.Errorf("events: post-filter view Type: %w", err) - } - eventType, err := typeView.Value() - if err != nil { - return 0, fmt.Errorf("events: post-filter view Type value: %w", err) - } - return eventType, nil -} - -// resolveViewTopics returns the event's Body.V0.Topics. ok is false for -// a body version that carries no topics at all. -func resolveViewTopics(ev xdr.ContractEventView) (xdr.ContractEventV0TopicsView, bool, error) { - body, err := ev.Body() - if err != nil { - return nil, false, fmt.Errorf("events: post-filter view Body: %w", err) - } - bodyV, err := body.V() - if err != nil { - return nil, false, fmt.Errorf("events: post-filter view Body.V: %w", err) - } - if bodyV != 0 { - return nil, false, nil - } - v0, err := body.V0() - if err != nil { - return nil, false, fmt.Errorf("events: post-filter view Body.V0: %w", err) - } - topics, err := v0.Topics() - if err != nil { - return nil, false, fmt.Errorf("events: post-filter view Body.V0.Topics: %w", err) - } - return topics, true, nil -} - -// resolveViewTopicCount returns how many topics the event carries, or -// -1 when it has no V0 body, which TopicCountFilter.matches rejects for -// every constraint. -func resolveViewTopicCount(ev xdr.ContractEventView) (int, error) { - topics, ok, err := resolveViewTopics(ev) - if err != nil || !ok { - return -1, err - } - count, err := topics.Count() - if err != nil { - return 0, fmt.Errorf("events: post-filter view Body.V0.Topics.Count: %w", err) - } - return count, nil -} - -// resolveViewContractID returns the event's ContractId aliased into the -// raw buffer, or nil when it has none. -func resolveViewContractID(ev xdr.ContractEventView) ([]byte, error) { - cidOpt, err := ev.ContractId() - if err != nil { - return nil, fmt.Errorf("events: post-filter view ContractId opt: %w", err) - } - cidView, present, err := cidOpt.Unwrap() - if err != nil { - return nil, fmt.Errorf("events: post-filter view ContractId unwrap: %w", err) - } - if !present { - return nil, nil - } - cid, err := cidView.Raw() - if err != nil { - return nil, fmt.Errorf("events: post-filter view ContractId raw: %w", err) - } - return cid, nil -} - -// collectTopicViewBytes walks the ContractEventView's Body.V0.Topics -// once linearly and captures each constrained position's .Raw() bytes -// into topicRaw. Stops after the highest constrained position so the -// walk is O(plan.maxTopicIdx+1) rather than the O(MaxTopicCount²) -// that calling .At(j) for each j would produce (ScVecView.At is a -// prefix walk under the hood). A body version with no topics leaves -// topicRaw zero (every constrained position will mismatch downstream). -func collectTopicViewBytes( - ev xdr.ContractEventView, - plan *filterPlan, - topicRaw *[protocol.MaxTopicCount][]byte, -) error { - if !plan.anyTopic { - return nil - } - topicsArr, ok, err := resolveViewTopics(ev) - if err != nil || !ok { - return err - } - i := 0 - for topic, ierr := range topicsArr.Iter() { - if ierr != nil { - return fmt.Errorf("events: post-filter view topic iter: %w", ierr) - } - if i > plan.maxTopicIdx || i >= protocol.MaxTopicCount { - break - } - if plan.needsTopic[i] { - rawBytes, err := topic.Raw() - if err != nil { - return fmt.Errorf("events: post-filter view topic[%d].Raw: %w", i, err) - } - topicRaw[i] = rawBytes - } - i++ - } - return nil -} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/match_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/match_test.go index 671bc2718..7de433401 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/match_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/match_test.go @@ -15,6 +15,7 @@ import ( "github.com/stellar/go-stellar-sdk/xdr" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/store" ) // Query and QueryOptions are the engine's historical one-shot surface @@ -1078,8 +1079,8 @@ func TestMatchesAnyFilterView_TypeAndCount(t *testing.T) { } { t.Run(name, func(t *testing.T) { filters := []Filter{tc.filter} - plan := planFilters(filters) - got, err := matchesAnyFilterView(tc.raw, filters, &plan) + plan := store.PlanFilters(filters) + got, err := store.MatchesAnyFilterView(xdr.ContractEventView(tc.raw), filters, &plan) require.NoError(t, err) assert.Equal(t, tc.want, got) }) diff --git a/cmd/stellar-rpc/internal/store/event_match.go b/cmd/stellar-rpc/internal/store/event_match.go new file mode 100644 index 000000000..2d73a9b56 --- /dev/null +++ b/cmd/stellar-rpc/internal/store/event_match.go @@ -0,0 +1,409 @@ +package store + +import ( + "bytes" + "fmt" + + protocol "github.com/stellar/go-stellar-sdk/protocols/rpc" + "github.com/stellar/go-stellar-sdk/strkey" + "github.com/stellar/go-stellar-sdk/xdr" +) + +// EventFilter is one clause in the union of an events query. Within a +// clause every constrained field is AND-ed against the event; zero values +// are wildcards. Shared by the sqlite (rpcv1) and chunk (rpcv2) backends. +// +// Topics[i] constrains topic position i. +type EventFilter struct { + ContractID []byte + Topics [protocol.MaxTopicCount][]byte + // EventType constrains the event's type. A nil pointer is a + // wildcard. A filter accepting several types is several filters. + EventType *xdr.ContractEventType + // TopicCount constrains how many topics the event carries. + TopicCount TopicCountFilter +} + +// TopicCountFilter constrains an event's topic count to at least +// Count, or to exactly Count when Exact is set. Its zero value, "at +// least zero", is the wildcard. +// +// This is getEvents v1's topic arity: a topic filter ending in "**" +// matches events with at least as many topics as the filter names, and +// one that does not match events with exactly that many. +type TopicCountFilter struct { + Count int + Exact bool +} + +// IsWildcard reports whether f constrains nothing. +func (f TopicCountFilter) IsWildcard() bool { return f == TopicCountFilter{} } + +// Matches reports whether an event carrying n topics satisfies f. A +// negative n stands for an event with no V0 body, which carries no +// topics at all and satisfies no constraint. +func (f TopicCountFilter) Matches(n int) bool { + if n < 0 { + return false + } + if f.Exact { + return n == f.Count + } + return n >= f.Count +} + +// CompileV1EventFilters expands a validated v1 filter list into clauses. The +// OR dimensions within one v1 filter (contract ids, topic filters) multiply +// out, one clause per combination: at most 5 filters x 5 contract ids +// x 5 topics = 125. A combination with no constraints matches every event, so +// the whole query collapses to match-all (nil). +func CompileV1EventFilters(in []protocol.EventFilter) ([]EventFilter, error) { + var out []EventFilter + for i := range in { + expanded, matchAll, err := expandV1Filter(&in[i]) + if err != nil { + return nil, err + } + if matchAll { + return nil, nil + } + out = append(out, expanded...) + } + return out, nil +} + +func expandV1Filter(f *protocol.EventFilter) ([]EventFilter, bool, error) { + // A validated type set holds only contract and system: naming both + // constrains nothing, naming one is one term. Either way the type never + // multiplies the expansion. + var eventType *xdr.ContractEventType + if len(f.EventType) == 1 { + name := f.EventType.Keys()[0] + typ, ok := protocol.GetEventTypeXDRFromEventType()[name] + if !ok { + // Valid admits only contract and system, so a name that is + // neither is a handler bug, not client input. + return nil, false, fmt.Errorf("unsupported event type %q", name) + } + eventType = &typ + } + contracts := [][]byte{nil} + if len(f.ContractIDs) > 0 { + contracts = make([][]byte, 0, len(f.ContractIDs)) + for _, id := range f.ContractIDs { + raw, err := strkey.Decode(strkey.VersionByteContract, id) + if err != nil { + // Unreachable: req.Valid decoded it already. The message is + // the v1 handler's backstop wording. + return nil, false, fmt.Errorf("invalid contract ID: %v", id) + } + contracts = append(contracts, raw) + } + } + shapes := []topicShape{{}} + if len(f.Topics) > 0 { + shapes = make([]topicShape, 0, len(f.Topics)) + for _, tf := range f.Topics { + shape, err := topicShapeOf(tf) + if err != nil { + return nil, false, err + } + shapes = append(shapes, shape) + } + } + + out := make([]EventFilter, 0, len(contracts)*len(shapes)) + for _, cid := range contracts { + for _, sh := range shapes { + flt := EventFilter{ + ContractID: cid, EventType: eventType, + Topics: sh.topics, TopicCount: sh.count, + } + if isMatchAll(&flt) { + return nil, true, nil + } + out = append(out, flt) + } + } + return out, false, nil +} + +// topicShape is one v1 TopicFilter in clause terms: the pinned positional +// values plus the arity constraint. N segments match exactly N topics; a +// trailing "**" relaxes that to at least the prefix, and "at least zero" is +// the zero value, no constraint. +type topicShape struct { + topics [protocol.MaxTopicCount][]byte + count TopicCountFilter +} + +func topicShapeOf(tf protocol.TopicFilter) (topicShape, error) { + segs := tf + shape := topicShape{count: TopicCountFilter{Count: len(tf), Exact: true}} + if n := len(tf); n > 0 && tf[n-1].Wildcard != nil && *tf[n-1].Wildcard == protocol.WildCardZeroOrMore { + // "At least zero" is the wildcard, which is the zero value. + segs = tf[:n-1] + shape.count = TopicCountFilter{Count: len(segs)} + } + for i, s := range segs { + // "*" is any value, and the position still exists via the count. + // A segment with neither value nor wildcard is skipped rather than + // dereferenced. + if s.Wildcard != nil || s.ScVal == nil { + continue + } + raw, err := s.ScVal.MarshalBinary() + if err != nil { + return topicShape{}, fmt.Errorf("failed to marshal segment: %w", err) + } + shape.topics[i] = raw + } + return shape, nil +} + +func isMatchAll(f *EventFilter) bool { + if f.EventType != nil || len(f.ContractID) > 0 || f.TopicCount != (TopicCountFilter{}) { + return false + } + for i := range f.Topics { + if len(f.Topics[i]) > 0 { + return false + } + } + return true +} + +// FilterPlan caches per-query info computed once by PlanFilters and +// consumed by MatchesAnyFilterView: the topic positions any clause +// constrains and the highest constrained position (caps the topic walk). +type FilterPlan struct { + anyTopic bool + maxTopicIdx int // -1 if no clause constrains any topic + needsTopic [protocol.MaxTopicCount]bool +} + +// PlanFilters computes the FilterPlan for filters. +func PlanFilters(filters []EventFilter) FilterPlan { + plan := FilterPlan{maxTopicIdx: -1} + for fi := range filters { + f := &filters[fi] + for i, want := range f.Topics { + if len(want) == 0 { + continue + } + plan.needsTopic[i] = true + plan.anyTopic = true + if i > plan.maxTopicIdx { + plan.maxTopicIdx = i + } + } + } + return plan +} + +// eventFields holds the decoded fields MatchesAnyFilterView pulls out +// of one event, each resolved at most once and only when some clause +// asks for it. +type eventFields struct { + contractID []byte + contractIDDone bool + + eventType xdr.ContractEventType + eventTypeDone bool + + topicCount int + topicCountDone bool + + topics [protocol.MaxTopicCount][]byte + topicsDone bool +} + +// MatchesAnyFilterView reports whether the event behind ev satisfies at +// least one clause. It resolves each field a clause constrains via view +// navigation, byte-comparing aliased .Raw() slices against the clause +// values. Zero per-event allocation: every byte slice involved aliases +// into ev. +// +// Fields are resolved at most once and only when a clause asks for +// them, cheapest first: events that fail every clause's type or +// ContractId check never trigger the topic walk, and events that pass +// do exactly one linear walk over Topics up to the highest constrained +// position. +// +//nolint:gocognit,cyclop // linear clause loop with per-field lazy caches; helpers would fragment the invariant +func MatchesAnyFilterView(ev xdr.ContractEventView, filters []EventFilter, plan *FilterPlan) (bool, error) { + var got eventFields + + for fi := range filters { + f := &filters[fi] + if f.EventType != nil { + if !got.eventTypeDone { + eventType, err := resolveViewEventType(ev) + if err != nil { + return false, err + } + got.eventType, got.eventTypeDone = eventType, true + } + if got.eventType != *f.EventType { + continue + } + } + if len(f.ContractID) > 0 { + if !got.contractIDDone { + cid, err := resolveViewContractID(ev) + if err != nil { + return false, err + } + got.contractID, got.contractIDDone = cid, true + } + if !bytes.Equal(got.contractID, f.ContractID) { + continue + } + } + if !f.TopicCount.IsWildcard() { + if !got.topicCountDone { + n, err := resolveViewTopicCount(ev) + if err != nil { + return false, err + } + got.topicCount, got.topicCountDone = n, true + } + if !f.TopicCount.Matches(got.topicCount) { + continue + } + } + matched := true + for i, want := range f.Topics { + if len(want) == 0 { + continue + } + if !got.topicsDone { + if err := collectTopicViewBytes(ev, plan, &got.topics); err != nil { + return false, err + } + got.topicsDone = true + } + g := got.topics[i] + if g == nil || !bytes.Equal(g, want) { + matched = false + break + } + } + if matched { + return true, nil + } + } + return false, nil +} + +func resolveViewEventType(ev xdr.ContractEventView) (xdr.ContractEventType, error) { + typeView, err := ev.Type() + if err != nil { + return 0, fmt.Errorf("events: post-filter view Type: %w", err) + } + eventType, err := typeView.Value() + if err != nil { + return 0, fmt.Errorf("events: post-filter view Type value: %w", err) + } + return eventType, nil +} + +// resolveViewTopics returns the event's Body.V0.Topics. ok is false for +// a body version that carries no topics at all. +func resolveViewTopics(ev xdr.ContractEventView) (xdr.ContractEventV0TopicsView, bool, error) { + body, err := ev.Body() + if err != nil { + return nil, false, fmt.Errorf("events: post-filter view Body: %w", err) + } + bodyV, err := body.V() + if err != nil { + return nil, false, fmt.Errorf("events: post-filter view Body.V: %w", err) + } + if bodyV != 0 { + return nil, false, nil + } + v0, err := body.V0() + if err != nil { + return nil, false, fmt.Errorf("events: post-filter view Body.V0: %w", err) + } + topics, err := v0.Topics() + if err != nil { + return nil, false, fmt.Errorf("events: post-filter view Body.V0.Topics: %w", err) + } + return topics, true, nil +} + +// resolveViewTopicCount returns how many topics the event carries, or +// -1 when it has no V0 body, which TopicCountFilter.Matches rejects for +// every constraint. +func resolveViewTopicCount(ev xdr.ContractEventView) (int, error) { + topics, ok, err := resolveViewTopics(ev) + if err != nil || !ok { + return -1, err + } + count, err := topics.Count() + if err != nil { + return 0, fmt.Errorf("events: post-filter view Body.V0.Topics.Count: %w", err) + } + return count, nil +} + +// resolveViewContractID returns the event's ContractId aliased into the +// raw buffer, or nil when it has none. +func resolveViewContractID(ev xdr.ContractEventView) ([]byte, error) { + cidOpt, err := ev.ContractId() + if err != nil { + return nil, fmt.Errorf("events: post-filter view ContractId opt: %w", err) + } + cidView, present, err := cidOpt.Unwrap() + if err != nil { + return nil, fmt.Errorf("events: post-filter view ContractId unwrap: %w", err) + } + if !present { + return nil, nil + } + cid, err := cidView.Raw() + if err != nil { + return nil, fmt.Errorf("events: post-filter view ContractId raw: %w", err) + } + return cid, nil +} + +// collectTopicViewBytes walks the ContractEventView's Body.V0.Topics +// once linearly and captures each constrained position's .Raw() bytes +// into topicRaw. Stops after the highest constrained position so the +// walk is O(plan.maxTopicIdx+1) rather than the O(MaxTopicCount²) +// that calling .At(j) for each j would produce (ScVecView.At is a +// prefix walk under the hood). A body version with no topics leaves +// topicRaw zero (every constrained position will mismatch downstream). +func collectTopicViewBytes( + ev xdr.ContractEventView, + plan *FilterPlan, + topicRaw *[protocol.MaxTopicCount][]byte, +) error { + if !plan.anyTopic { + return nil + } + topicsArr, ok, err := resolveViewTopics(ev) + if err != nil || !ok { + return err + } + i := 0 + for topic, ierr := range topicsArr.Iter() { + if ierr != nil { + return fmt.Errorf("events: post-filter view topic iter: %w", ierr) + } + if i > plan.maxTopicIdx || i >= protocol.MaxTopicCount { + break + } + if plan.needsTopic[i] { + rawBytes, err := topic.Raw() + if err != nil { + return fmt.Errorf("events: post-filter view topic[%d].Raw: %w", i, err) + } + topicRaw[i] = rawBytes + } + i++ + } + return nil +} From 97c5c82c23145db48b3c5f54f14797748d679552 Mon Sep 17 00:00:00 2001 From: Christian Jonas Date: Fri, 4 Sep 2026 18:55:15 -0400 Subject: [PATCH 03/22] wire updated handler to shared matcher --- .../internal/methods/get_events.go | 24 ++++++++++++++++--- .../internal/rpcv1/sqlitedb/event.go | 8 +++++-- .../internal/rpcv1/sqlitedb/event_test.go | 4 ++-- .../rpcv2/stores/event/extract_test.go | 10 ++++---- cmd/stellar-rpc/internal/store/event.go | 3 ++- 5 files changed, 37 insertions(+), 12 deletions(-) diff --git a/cmd/stellar-rpc/internal/methods/get_events.go b/cmd/stellar-rpc/internal/methods/get_events.go index 5f43d986d..20d186875 100644 --- a/cmd/stellar-rpc/internal/methods/get_events.go +++ b/cmd/stellar-rpc/internal/methods/get_events.go @@ -195,14 +195,32 @@ func (h eventsRPCHandler) getEvents(ctx context.Context, request protocol.GetEve eventTypes := combineEventTypes(request.Filters) + filters, err := store.CompileV1EventFilters(request.Filters) // nil matches every event + if err != nil { + return protocol.GetEventsResponse{}, &jrpc2.Error{ + Code: jrpc2.InvalidParams, Message: err.Error(), + } + } + plan := store.PlanFilters(filters) + // Scan function to apply filters var eventViewScanFunction store.ViewScanFunction = func( eventView xdr.DiagnosticEventView, cursor protocol.Cursor, ledgerCloseTimestamp int64, txHash *xdr.Hash, - ) bool { - if request.Matches(eventView) { + ) (bool, error) { + matched := filters == nil + if !matched { + event, err := eventView.Event() + if err != nil { + return false, err + } + if matched, err = store.MatchesAnyFilterView(event, filters, &plan); err != nil { + return false, err + } + } + if matched { found = append(found, entry{cursor, ledgerCloseTimestamp, eventView, txHash}) } - return uint(len(found)) < limit + return uint(len(found)) < limit, nil } err = h.dbReader.GetEvents(ctx, cursorRange, contractIDs, topics, eventTypes, eventViewScanFunction) diff --git a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/event.go b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/event.go index cee64af56..8d1b522de 100644 --- a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/event.go +++ b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/event.go @@ -291,7 +291,7 @@ func (eventHandler *eventHandler) trimEvents(latestLedgerSeq uint32, retentionWi // ascending Cursor order. // // If f returns false, the scan terminates early (f will not be applied on -// remaining events in the range). +// remaining events in the range). If f returns an error, the scan aborts with it. // //nolint:funlen,cyclop func (eventHandler *eventHandler) GetEvents( @@ -401,7 +401,11 @@ func (eventHandler *eventHandler) GetEvents( eventView := xdr.DiagnosticEventView(row.eventData) txHash := xdr.Hash(transactionHash) - if !scanner(eventView, cur, ledgerCloseTime, &txHash) { + keepGoing, err := scanner(eventView, cur, ledgerCloseTime, &txHash) + if err != nil { + return errors.Join(err, errors.New("failed to scan event")) + } + if !keepGoing { return nil } } diff --git a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/event_test.go b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/event_test.go index bb45d7ab7..26ccaa3bc 100644 --- a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/event_test.go +++ b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/event_test.go @@ -276,9 +276,9 @@ func TestInsertEventsBatchingExceedsLimit(t *testing.T) { var count int err = eventReader.GetEvents(ctx, cursorRange, nil, nil, nil, - func(_ xdr.DiagnosticEventView, _ protocol.Cursor, _ int64, _ *xdr.Hash) bool { + func(_ xdr.DiagnosticEventView, _ protocol.Cursor, _ int64, _ *xdr.Hash) (bool, error) { count++ - return true + return true, nil }) require.NoError(t, err) diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/extract_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/extract_test.go index 4ad5ed8dd..51b0708fd 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/extract_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/extract_test.go @@ -598,16 +598,18 @@ func sqliteEventRows(t *testing.T, lcm xdr.LedgerCloseMeta) []sqliteEventRow { var rows []sqliteEventRow reader := sqlitedb.NewEventReader(logger, testDB, testPassphrase) err = reader.GetEvents(ctx, cursorRange, nil, nil, nil, - func(event xdr.DiagnosticEvent, cur protocol.Cursor, closeTime int64, txHash *xdr.Hash) bool { - evBytes, merr := event.Event.MarshalBinary() - require.NoError(t, merr) + func(eventView xdr.DiagnosticEventView, cur protocol.Cursor, closeTime int64, txHash *xdr.Hash) (bool, error) { + ev, verr := eventView.Event() + require.NoError(t, verr) + evBytes, rerr := ev.Raw() + require.NoError(t, rerr) rows = append(rows, sqliteEventRow{ cursor: cur, txHash: *txHash, ledgerCloseTime: closeTime, contractEventBytes: evBytes, }) - return true + return true, nil }) require.NoError(t, err, "SQLite GetEvents") return rows diff --git a/cmd/stellar-rpc/internal/store/event.go b/cmd/stellar-rpc/internal/store/event.go index 93eff9c4c..72858dba3 100644 --- a/cmd/stellar-rpc/internal/store/event.go +++ b/cmd/stellar-rpc/internal/store/event.go @@ -24,12 +24,13 @@ type TopicFilter []TopicCondition // matches a row, the DB event is considered a candidate for further filtering. type TopicFilters []TopicFilter +// ViewScanFunction visits one event; returning false stops the scan, an error aborts it. type ViewScanFunction func( eventView xdr.DiagnosticEventView, cursor protocol.Cursor, ledgerCloseTimestamp int64, txHash *xdr.Hash, -) bool +) (bool, error) // EventReader has all the public methods to fetch events from the backend. type EventReader interface { From bc894ed84ca177d12556bb8bb13c9f2634671588 Mon Sep 17 00:00:00 2001 From: Christian Jonas Date: Tue, 8 Sep 2026 13:50:37 -0400 Subject: [PATCH 04/22] adapt migration_test's GetEvents callback to the view scan signature --- cmd/stellar-rpc/internal/rpcv1/sqlitedb/migration_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/migration_test.go b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/migration_test.go index 27bce5678..b990ce7c9 100644 --- a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/migration_test.go +++ b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/migration_test.go @@ -82,9 +82,9 @@ func requireLedgerData(t *testing.T, testDB *DB, lcms []xdr.LedgerCloseMeta) { End: protocol.Cursor{Ledger: lcms[len(lcms)-1].LedgerSequence() + 1}, } require.NoError(t, eventReader.GetEvents(t.Context(), cursorRange, nil, nil, nil, - func(xdr.DiagnosticEvent, protocol.Cursor, int64, *xdr.Hash) bool { + func(xdr.DiagnosticEventView, protocol.Cursor, int64, *xdr.Hash) (bool, error) { eventCount++ - return true + return true, nil })) require.Equal(t, 4*len(lcms), eventCount, "expected 4 events per ledger") } From 4d421dd4e31119f908a6ae0508ef4a219ee0d607 Mon Sep 17 00:00:00 2001 From: Christian Jonas Date: Thu, 10 Sep 2026 19:03:45 -0400 Subject: [PATCH 05/22] use rpcv1 views fns in rpcv2 --- .../internal/methods/get_events.go | 22 +++--- .../internal/rpcv2/eventsapi/get_events_v1.go | 18 +---- .../internal/rpcv2/eventsapi/get_events_v2.go | 76 +++---------------- 3 files changed, 24 insertions(+), 92 deletions(-) diff --git a/cmd/stellar-rpc/internal/methods/get_events.go b/cmd/stellar-rpc/internal/methods/get_events.go index 20d186875..3000365be 100644 --- a/cmd/stellar-rpc/internal/methods/get_events.go +++ b/cmd/stellar-rpc/internal/methods/get_events.go @@ -121,7 +121,7 @@ func combineTopics(filters []protocol.EventFilter) (store.TopicFilters, error) { type entry struct { cursor protocol.Cursor ledgerCloseTimestamp int64 - eventView xdr.DiagnosticEventView + event xdr.ContractEventView txHash *xdr.Hash } @@ -207,18 +207,18 @@ func (h eventsRPCHandler) getEvents(ctx context.Context, request protocol.GetEve var eventViewScanFunction store.ViewScanFunction = func( eventView xdr.DiagnosticEventView, cursor protocol.Cursor, ledgerCloseTimestamp int64, txHash *xdr.Hash, ) (bool, error) { + event, err := eventView.Event() + if err != nil { + return false, err + } matched := filters == nil if !matched { - event, err := eventView.Event() - if err != nil { - return false, err - } if matched, err = store.MatchesAnyFilterView(event, filters, &plan); err != nil { return false, err } } if matched { - found = append(found, entry{cursor, ledgerCloseTimestamp, eventView, txHash}) + found = append(found, entry{cursor, ledgerCloseTimestamp, event, txHash}) } return uint(len(found)) < limit, nil } @@ -232,8 +232,8 @@ func (h eventsRPCHandler) getEvents(ctx context.Context, request protocol.GetEve results := make([]protocol.EventInfo, 0, len(found)) for _, entry := range found { - info, err := eventInfoForEvent( - entry.eventView, + info, err := EventInfoFromView( + entry.event, entry.cursor, time.Unix(entry.ledgerCloseTimestamp, 0).UTC().Format(time.RFC3339), entry.txHash.HexString(), @@ -269,8 +269,9 @@ func (h eventsRPCHandler) getEvents(ctx context.Context, request protocol.GetEve }, nil } -func eventInfoForEvent( - eventView xdr.DiagnosticEventView, +// EventInfoFromView renders one stored ContractEvent into the v1 wire type; rpcv2's eventsapi wraps it. +func EventInfoFromView( + ev xdr.ContractEventView, cursor protocol.Cursor, ledgerClosedAt, txHash, format string, ) (protocol.EventInfo, error) { @@ -281,7 +282,6 @@ func eventInfoForEvent( cidRaw []byte ) err := xdr.TryVoid(func() { - ev := eventView.MustEvent() xdrType = ev.MustType().MustValue() v0 := ev.MustBody().MustV0() // panics on a non-V0 body, replacing "unknown event version" for t := range v0.MustTopics().MustIter() { diff --git a/cmd/stellar-rpc/internal/rpcv2/eventsapi/get_events_v1.go b/cmd/stellar-rpc/internal/rpcv2/eventsapi/get_events_v1.go index 2f1463984..dc08a652d 100644 --- a/cmd/stellar-rpc/internal/rpcv2/eventsapi/get_events_v1.go +++ b/cmd/stellar-rpc/internal/rpcv2/eventsapi/get_events_v1.go @@ -189,25 +189,11 @@ func v1Filters(in []protocol.EventFilter) ([]event.Filter, error) { return store.CompileV1EventFilters(in) } -// eventInfoV1 mints the v1 response event: the same stored-payload decode as -// eventInfoV2, reshaped into the v1 wire type. +// eventInfoV1 mints the v1 response event: eventInfoV2's output in the v1 wire type. func eventInfoV1(p *event.Payload, format string) (protocol.EventInfo, error) { v2, err := eventInfoV2(p, format) if err != nil { return protocol.EventInfo{}, fmt.Errorf("could not parse event: %w", err) } - return protocol.EventInfo{ - EventType: v2.EventType, - Ledger: v2.Ledger, - LedgerClosedAt: v2.LedgerClosedAt, - ContractID: v2.ContractID, - ID: v2.ID, - OpIndex: v2.OpIndex, - TxIndex: v2.TxIndex, - TransactionHash: v2.TransactionHash, - TopicXDR: v2.TopicXDR, - TopicJSON: v2.TopicJSON, - ValueXDR: v2.ValueXDR, - ValueJSON: v2.ValueJSON, - }, nil + return protocol.EventInfo(v2), nil } 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..933e8704a 100644 --- a/cmd/stellar-rpc/internal/rpcv2/eventsapi/get_events_v2.go +++ b/cmd/stellar-rpc/internal/rpcv2/eventsapi/get_events_v2.go @@ -22,10 +22,10 @@ import ( supportlog "github.com/stellar/go-stellar-sdk/support/log" "github.com/stellar/go-stellar-sdk/xdr" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/methods" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/query" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/event" - "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/xdr2json" ) // Limits are the page caps an operator configures, which both events @@ -453,77 +453,23 @@ func responseScanStatus(s query.ScanStatus) string { } } -// eventInfoV2 builds one response event from a stored payload. The ID is -// the same TOID-and-index form v1 mints, and topics and value follow the -// request's xdrFormat. +// eventInfoV2 builds one response event from a stored event payload. func eventInfoV2(p *event.Payload, format string) (protocol.EventInfoV2, error) { - var ev xdr.ContractEvent - if err := ev.UnmarshalBinary(p.ContractEventBytes); err != nil { + ev := xdr.ContractEventView(p.ContractEventBytes) + xdrType, err := xdr.Try(func() xdr.ContractEventType { return ev.MustType().MustValue() }) + if err != nil { return protocol.EventInfoV2{}, fmt.Errorf("rpcv2: stored event bytes: %w", err) } - if ev.Body.V != 0 || ev.Body.V0 == nil { - // Unreachable: UnmarshalBinary above rejects any other discriminant. - // Kept as a guard on the V0 dereferences that follow. - return protocol.EventInfoV2{}, fmt.Errorf("rpcv2: stored event has body version %d", ev.Body.V) - } - - eventType, err := responseEventType(ev.Type) - if err != nil { + if _, err := responseEventType(xdrType); err != nil { // stricter than v1, which also renders diagnostic events return protocol.EventInfoV2{}, err } - // The response field is int32, so a sequence past its range would go - // out negative. v1 rejects the same shape. - if p.LedgerSequence > math.MaxInt32 { - return protocol.EventInfoV2{}, fmt.Errorf( - "rpcv2: ledger sequence %d exceeds supported range", p.LedgerSequence) - } - - info := protocol.EventInfoV2{ - EventType: eventType, - Ledger: int32(p.LedgerSequence), - LedgerClosedAt: time.Unix(p.LedgerClosedAt, 0).UTC().Format(time.RFC3339), - ID: protocol.Cursor{ - Ledger: p.LedgerSequence, Tx: p.TxIdx, Op: p.OpIdx, Event: p.EventIdx, - }.String(), - OpIndex: p.OpIdx, - TxIndex: p.TxIdx, - TransactionHash: p.TxHash.HexString(), - } - if ev.ContractId != nil { - info.ContractID = strkey.MustEncode(strkey.VersionByteContract, ev.ContractId[:]) - } - - if format == protocol.FormatJSON { - info.TopicJSON = make([]json.RawMessage, 0, len(ev.Body.V0.Topics)) - for i := range ev.Body.V0.Topics { - converted, err := xdr2json.ConvertInterface(ev.Body.V0.Topics[i]) - if err != nil { - return protocol.EventInfoV2{}, fmt.Errorf("rpcv2: topic %d to json: %w", i, err) - } - info.TopicJSON = append(info.TopicJSON, converted) - } - valueJSON, err := xdr2json.ConvertInterface(ev.Body.V0.Data) - if err != nil { - return protocol.EventInfoV2{}, fmt.Errorf("rpcv2: value to json: %w", err) - } - info.ValueJSON = valueJSON - return info, nil - } - - info.TopicXDR = make([]string, 0, len(ev.Body.V0.Topics)) - for i := range ev.Body.V0.Topics { - encoded, err := xdr.MarshalBase64(ev.Body.V0.Topics[i]) - if err != nil { - return protocol.EventInfoV2{}, fmt.Errorf("rpcv2: topic %d to xdr: %w", i, err) - } - info.TopicXDR = append(info.TopicXDR, encoded) - } - valueXDR, err := xdr.MarshalBase64(ev.Body.V0.Data) + cursor := protocol.Cursor{Ledger: p.LedgerSequence, Tx: p.TxIdx, Op: p.OpIdx, Event: p.EventIdx} + info, err := methods.EventInfoFromView(ev, cursor, + time.Unix(p.LedgerClosedAt, 0).UTC().Format(time.RFC3339), p.TxHash.HexString(), format) if err != nil { - return protocol.EventInfoV2{}, fmt.Errorf("rpcv2: value to xdr: %w", err) + return protocol.EventInfoV2{}, fmt.Errorf("rpcv2: %w", err) } - info.ValueXDR = valueXDR - return info, nil + return protocol.EventInfoV2(info), nil } // responseEventType: ingest stores contract and system events only. From 7495ec6b06975ed343466cbb83f778ab06103ea7 Mon Sep 17 00:00:00 2001 From: Christian Jonas Date: Mon, 14 Sep 2026 15:10:57 -0400 Subject: [PATCH 06/22] fix comments referencing old function names --- cmd/stellar-rpc/internal/rpcv2/stores/event/match.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/match.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/match.go index 0e879b02b..531db9db6 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/match.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/match.go @@ -34,7 +34,7 @@ type Filter = store.EventFilter // TopicCountFilter constrains an event's topic count; see store.TopicCountFilter. type TopicCountFilter = store.TopicCountFilter -// termKeys returns the topic-count buckets whose union covers f. Every +// topicCountTermKeys returns the topic-count buckets whose union covers f. Every // count ValidateFilters admits has a bucket of its own, and an "at // least" union is closed by the overflow bucket, so the union never // holds an event f does not match. @@ -48,7 +48,7 @@ func topicCountTermKeys(f TopicCountFilter) []TermKey { return TopicCountTermKeysAtLeast(f.Count) } -// valueTermKeys returns one term per constrained value field +// filterValueTermKeys returns one term per constrained value field // (contract ID, event type, topics): the single enumeration // termGroups and CountDistinctTerms share, so the two cannot drift // over which values a filter names. The topic-count buckets are not @@ -71,7 +71,7 @@ func filterValueTermKeys(f *Filter) []TermKey { return keys } -// termGroups returns the indexed terms this filter constrains, grouped +// filterTermGroups returns the indexed terms this filter constrains, grouped // by field: the bitmaps within a group are OR-ed and the groups are // AND-ed. Only the topic-count group ever holds more than one term. func filterTermGroups(f *Filter) [][]TermKey { @@ -92,7 +92,7 @@ func filterTermGroups(f *Filter) [][]TermKey { return groups } -// impliesTopicCount reports whether f's constrained topic positions +// filterImpliesTopicCount reports whether f's constrained topic positions // already guarantee its topic-count bound. func filterImpliesTopicCount(f *Filter) bool { if f.TopicCount.Exact { From 682dd355f32bf84e705ba75e9b02441b4f84ce39 Mon Sep 17 00:00:00 2001 From: Christian Jonas Date: Mon, 14 Sep 2026 15:47:36 -0400 Subject: [PATCH 07/22] restore parity with SDK on diagnostic-typed events --- cmd/stellar-rpc/internal/store/event_match.go | 45 ++++++++++--------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/cmd/stellar-rpc/internal/store/event_match.go b/cmd/stellar-rpc/internal/store/event_match.go index 2d73a9b56..5df19176b 100644 --- a/cmd/stellar-rpc/internal/store/event_match.go +++ b/cmd/stellar-rpc/internal/store/event_match.go @@ -3,6 +3,7 @@ package store import ( "bytes" "fmt" + "slices" protocol "github.com/stellar/go-stellar-sdk/protocols/rpc" "github.com/stellar/go-stellar-sdk/strkey" @@ -73,19 +74,20 @@ func CompileV1EventFilters(in []protocol.EventFilter) ([]EventFilter, error) { } func expandV1Filter(f *protocol.EventFilter) ([]EventFilter, bool, error) { - // A validated type set holds only contract and system: naming both - // constrains nothing, naming one is one term. Either way the type never - // multiplies the expansion. - var eventType *xdr.ContractEventType - if len(f.EventType) == 1 { - name := f.EventType.Keys()[0] - typ, ok := protocol.GetEventTypeXDRFromEventType()[name] - if !ok { - // Valid admits only contract and system, so a name that is - // neither is a handler bug, not client input. - return nil, false, fmt.Errorf("unsupported event type %q", name) + // One clause per named type + an empty set is the wildcard. Valid admits + // only contract and system, so len is 0, 1 or 2. + eventTypes := []*xdr.ContractEventType{nil} + if len(f.EventType) > 0 { + names := f.EventType.Keys() + slices.Sort(names) // keep clause order deterministic + eventTypes = eventTypes[:0] // drop the initial nil, we have explicit names + for _, name := range names { + typ, ok := protocol.GetEventTypeXDRFromEventType()[name] + if !ok { + return nil, false, fmt.Errorf("unsupported event type %q", name) + } + eventTypes = append(eventTypes, &typ) } - eventType = &typ } contracts := [][]byte{nil} if len(f.ContractIDs) > 0 { @@ -112,17 +114,16 @@ func expandV1Filter(f *protocol.EventFilter) ([]EventFilter, bool, error) { } } - out := make([]EventFilter, 0, len(contracts)*len(shapes)) - for _, cid := range contracts { - for _, sh := range shapes { - flt := EventFilter{ - ContractID: cid, EventType: eventType, - Topics: sh.topics, TopicCount: sh.count, - } - if isMatchAll(&flt) { - return nil, true, nil + out := make([]EventFilter, 0, len(eventTypes)*len(contracts)*len(shapes)) + for _, typ := range eventTypes { + for _, cid := range contracts { + for _, sh := range shapes { + flt := EventFilter{ContractID: cid, EventType: typ, Topics: sh.topics, TopicCount: sh.count} + if isMatchAll(&flt) { + return nil, true, nil + } + out = append(out, flt) } - out = append(out, flt) } } return out, false, nil From 798fd04c0dab5c8e6d285dfc60ebe55dd52f4abb Mon Sep 17 00:00:00 2001 From: Christian Jonas Date: Mon, 14 Sep 2026 16:13:41 -0400 Subject: [PATCH 08/22] render eventInfo in view scanner callback fn --- .../internal/methods/get_events.go | 40 +++++++------------ cmd/stellar-rpc/internal/store/event.go | 4 +- 2 files changed, 17 insertions(+), 27 deletions(-) diff --git a/cmd/stellar-rpc/internal/methods/get_events.go b/cmd/stellar-rpc/internal/methods/get_events.go index 3000365be..ba1b8b16a 100644 --- a/cmd/stellar-rpc/internal/methods/get_events.go +++ b/cmd/stellar-rpc/internal/methods/get_events.go @@ -118,13 +118,6 @@ func combineTopics(filters []protocol.EventFilter) (store.TopicFilters, error) { return topicFilters, nil } -type entry struct { - cursor protocol.Cursor - ledgerCloseTimestamp int64 - event xdr.ContractEventView - txHash *xdr.Hash -} - // TODO: remove this linter exclusions // //nolint:cyclop,funlen @@ -177,8 +170,6 @@ func (h eventsRPCHandler) getEvents(ctx context.Context, request protocol.GetEve } } - found := make([]entry, 0, limit) - contractIDs, err := combineContractIDs(request.Filters) if err != nil { return protocol.GetEventsResponse{}, &jrpc2.Error{ @@ -203,6 +194,7 @@ func (h eventsRPCHandler) getEvents(ctx context.Context, request protocol.GetEve } plan := store.PlanFilters(filters) + results := []protocol.EventInfo{} // Scan function to apply filters var eventViewScanFunction store.ViewScanFunction = func( eventView xdr.DiagnosticEventView, cursor protocol.Cursor, ledgerCloseTimestamp int64, txHash *xdr.Hash, @@ -217,10 +209,21 @@ func (h eventsRPCHandler) getEvents(ctx context.Context, request protocol.GetEve return false, err } } + // Render the matched event in the callback to prevent two passes if matched { - found = append(found, entry{cursor, ledgerCloseTimestamp, event, txHash}) + info, err := EventInfoFromView( + event, + cursor, + time.Unix(ledgerCloseTimestamp, 0).UTC().Format(time.RFC3339), + txHash.HexString(), + request.Format, + ) + if err != nil { + return false, errors.Wrap(err, "could not parse event") + } + results = append(results, info) } - return uint(len(found)) < limit, nil + return uint(len(results)) < limit, nil } err = h.dbReader.GetEvents(ctx, cursorRange, contractIDs, topics, eventTypes, eventViewScanFunction) @@ -230,21 +233,6 @@ func (h eventsRPCHandler) getEvents(ctx context.Context, request protocol.GetEve } } - results := make([]protocol.EventInfo, 0, len(found)) - for _, entry := range found { - info, err := EventInfoFromView( - entry.event, - entry.cursor, - time.Unix(entry.ledgerCloseTimestamp, 0).UTC().Format(time.RFC3339), - entry.txHash.HexString(), - request.Format, - ) - if err != nil { - return protocol.GetEventsResponse{}, errors.Wrap(err, "could not parse event") - } - results = append(results, info) - } - var cursor string if uint(len(results)) == limit { lastEvent := results[len(results)-1] diff --git a/cmd/stellar-rpc/internal/store/event.go b/cmd/stellar-rpc/internal/store/event.go index 72858dba3..e9dec25a9 100644 --- a/cmd/stellar-rpc/internal/store/event.go +++ b/cmd/stellar-rpc/internal/store/event.go @@ -24,7 +24,9 @@ type TopicFilter []TopicCondition // matches a row, the DB event is considered a candidate for further filtering. type TopicFilters []TopicFilter -// ViewScanFunction visits one event; returning false stops the scan, an error aborts it. +// ViewScanFunction visits one event. eventView and txHash are on loan for the +// call's duration, so copy anything that must persist. Returning false stops +// the scan and an error aborts it. type ViewScanFunction func( eventView xdr.DiagnosticEventView, cursor protocol.Cursor, From cdb4987002e733db52be4013a63fa5edefe98301 Mon Sep 17 00:00:00 2001 From: Christian Jonas Date: Mon, 14 Sep 2026 16:38:34 -0400 Subject: [PATCH 09/22] fix number of clauses referenced in comment --- cmd/stellar-rpc/internal/store/event_match.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/stellar-rpc/internal/store/event_match.go b/cmd/stellar-rpc/internal/store/event_match.go index 5df19176b..76a0d30cf 100644 --- a/cmd/stellar-rpc/internal/store/event_match.go +++ b/cmd/stellar-rpc/internal/store/event_match.go @@ -56,8 +56,8 @@ func (f TopicCountFilter) Matches(n int) bool { // CompileV1EventFilters expands a validated v1 filter list into clauses. The // OR dimensions within one v1 filter (contract ids, topic filters) multiply // out, one clause per combination: at most 5 filters x 5 contract ids -// x 5 topics = 125. A combination with no constraints matches every event, so -// the whole query collapses to match-all (nil). +// x 5 topics x 2 filter types = 250. A combination with no constraints matches +// every event, so the whole query collapses to match-all (nil). func CompileV1EventFilters(in []protocol.EventFilter) ([]EventFilter, error) { var out []EventFilter for i := range in { From 0ab2d6339ae96dc8ccdaeabcd65404eb205e14c0 Mon Sep 17 00:00:00 2001 From: Christian Jonas Date: Mon, 14 Sep 2026 16:39:49 -0400 Subject: [PATCH 10/22] update test to account for sorted keys --- .../rpcv2/eventsapi/v1_conversions_test.go | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/cmd/stellar-rpc/internal/rpcv2/eventsapi/v1_conversions_test.go b/cmd/stellar-rpc/internal/rpcv2/eventsapi/v1_conversions_test.go index a7e70ada6..ab63f7878 100644 --- a/cmd/stellar-rpc/internal/rpcv2/eventsapi/v1_conversions_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/eventsapi/v1_conversions_test.go @@ -43,16 +43,23 @@ func TestV1FiltersTranslation(t *testing.T) { // A validated set holds only contract and system, so a set of both // constrains nothing; with nothing else in the filter, one branch // matches everything and the query collapses to match-all. - "type set of both collapses to match-all": { - in: []protocol.EventFilter{{EventType: bothTypes}}, - want: nil, + "type set of both is one clause per type": { + in: []protocol.EventFilter{{EventType: bothTypes}}, + want: []event.Filter{ + {EventType: eventTypePtr(xdr.ContractEventTypeContract)}, + {EventType: eventTypePtr(xdr.ContractEventTypeSystem)}, + }, }, - "type set of both with a contract id keeps the contract id": { + "type set of both with a contract id keeps the contract id on each clause": { in: []protocol.EventFilter{{ EventType: bothTypes, ContractIDs: []string{testContractStrkey(t, 0xAA)}, }}, - want: []event.Filter{{ContractID: testContractRaw(0xAA)}}, + want: []event.Filter{ + {ContractID: testContractRaw(0xAA), EventType: eventTypePtr(xdr.ContractEventTypeContract)}, + {ContractID: testContractRaw(0xAA), EventType: eventTypePtr(xdr.ContractEventTypeSystem)}, + }, }, + // N segments without a trailing "**" match exactly N topics. "one-segment topic is exact arity one": { in: []protocol.EventFilter{{Topics: []protocol.TopicFilter{{seg(xferVal)}}}}, From 62ad52e67a5e28eecf582d3cb53605e4e07e633e Mon Sep 17 00:00:00 2001 From: Christian Jonas Date: Mon, 14 Sep 2026 16:41:02 -0400 Subject: [PATCH 11/22] use sql.RawBytes in getEvents to avoid needless event data copying --- cmd/stellar-rpc/internal/rpcv1/sqlitedb/event.go | 15 +++++++-------- .../internal/rpcv2/stores/event/extract_test.go | 3 ++- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/event.go b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/event.go index 8d1b522de..1e319c12b 100644 --- a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/event.go +++ b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/event.go @@ -3,6 +3,7 @@ package sqlitedb import ( "context" + "database/sql" "errors" "fmt" "io" @@ -370,18 +371,15 @@ func (eventHandler *eventHandler) GetEvents( defer rows.Close() - type rowResult struct { - eventCursorID string - eventData []byte - transactionHash []byte + var row struct { + eventCursorID string + // Use sql.RawBytes over []byte to avoid unnecessary copying + eventData sql.RawBytes + transactionHash sql.RawBytes ledgerCloseTime int64 } - foundRows := 0 for rows.Next() { - foundRows++ - - row := rowResult{} if err := rows.Scan( &row.eventCursorID, &row.eventData, @@ -408,6 +406,7 @@ func (eventHandler *eventHandler) GetEvents( if !keepGoing { return nil } + foundRows++ } eventHandler.log. diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/extract_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/extract_test.go index 51b0708fd..b398c8d05 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/extract_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/extract_test.go @@ -1,6 +1,7 @@ package event_test import ( + "bytes" "fmt" "path" "testing" @@ -607,7 +608,7 @@ func sqliteEventRows(t *testing.T, lcm xdr.LedgerCloseMeta) []sqliteEventRow { cursor: cur, txHash: *txHash, ledgerCloseTime: closeTime, - contractEventBytes: evBytes, + contractEventBytes: bytes.Clone(evBytes), }) return true, nil }) From d719cd7e26248a38e5387deb7843de9300e4c351 Mon Sep 17 00:00:00 2001 From: Christian Jonas Date: Mon, 14 Sep 2026 18:24:47 -0400 Subject: [PATCH 12/22] move event match test to store + add test --- .../rpcv2/eventsapi/v1_conversions_test.go | 158 ---------- .../internal/rpcv2/stores/event/match_test.go | 42 --- .../internal/store/event_match_test.go | 291 ++++++++++++++++++ 3 files changed, 291 insertions(+), 200 deletions(-) delete mode 100644 cmd/stellar-rpc/internal/rpcv2/eventsapi/v1_conversions_test.go create mode 100644 cmd/stellar-rpc/internal/store/event_match_test.go diff --git a/cmd/stellar-rpc/internal/rpcv2/eventsapi/v1_conversions_test.go b/cmd/stellar-rpc/internal/rpcv2/eventsapi/v1_conversions_test.go deleted file mode 100644 index ab63f7878..000000000 --- a/cmd/stellar-rpc/internal/rpcv2/eventsapi/v1_conversions_test.go +++ /dev/null @@ -1,158 +0,0 @@ -package eventsapi - -// Translation rules for the v1 filter model: each case pins one rule against -// the exact store filters v1Filters must produce. The parity harness -// cross-checks the same rules end to end against the shared v1 handler. - -import ( - "testing" - - "github.com/stretchr/testify/require" - - protocol "github.com/stellar/go-stellar-sdk/protocols/rpc" - "github.com/stellar/go-stellar-sdk/xdr" - - "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/event" -) - -//nolint:funlen // one table, one case per translation rule -func TestV1FiltersTranslation(t *testing.T) { - xferVal, xferRaw := symbolScVal(t, "xfer") - mintVal, mintRaw := symbolScVal(t, "mint") - aliceVal, aliceRaw := symbolScVal(t, "alice") - star, dstar := "*", "**" - seg := func(v xdr.ScVal) protocol.SegmentFilter { return protocol.SegmentFilter{ScVal: &v} } - wild := func(w string) protocol.SegmentFilter { return protocol.SegmentFilter{Wildcard: &w} } - contractType := protocol.EventTypeSet{protocol.EventTypeContract: nil} - bothTypes := protocol.EventTypeSet{ - protocol.EventTypeContract: nil, protocol.EventTypeSystem: nil, - } - - for name, tc := range map[string]struct { - in []protocol.EventFilter - want []event.Filter // nil means the whole query matches every event - }{ - "contract id alone": { - in: []protocol.EventFilter{{ContractIDs: []string{testContractStrkey(t, 0xAA)}}}, - want: []event.Filter{{ContractID: testContractRaw(0xAA)}}, - }, - "single type": { - in: []protocol.EventFilter{{EventType: contractType}}, - want: []event.Filter{{EventType: eventTypePtr(xdr.ContractEventTypeContract)}}, - }, - // A validated set holds only contract and system, so a set of both - // constrains nothing; with nothing else in the filter, one branch - // matches everything and the query collapses to match-all. - "type set of both is one clause per type": { - in: []protocol.EventFilter{{EventType: bothTypes}}, - want: []event.Filter{ - {EventType: eventTypePtr(xdr.ContractEventTypeContract)}, - {EventType: eventTypePtr(xdr.ContractEventTypeSystem)}, - }, - }, - "type set of both with a contract id keeps the contract id on each clause": { - in: []protocol.EventFilter{{ - EventType: bothTypes, ContractIDs: []string{testContractStrkey(t, 0xAA)}, - }}, - want: []event.Filter{ - {ContractID: testContractRaw(0xAA), EventType: eventTypePtr(xdr.ContractEventTypeContract)}, - {ContractID: testContractRaw(0xAA), EventType: eventTypePtr(xdr.ContractEventTypeSystem)}, - }, - }, - - // N segments without a trailing "**" match exactly N topics. - "one-segment topic is exact arity one": { - in: []protocol.EventFilter{{Topics: []protocol.TopicFilter{{seg(xferVal)}}}}, - want: []event.Filter{{ - Topics: [protocol.MaxTopicCount][]byte{xferRaw}, - TopicCount: event.TopicCountFilter{Count: 1, Exact: true}, - }}, - }, - // A trailing "**" keeps the prefix constraints and relaxes the - // arity to at-least-prefix. - "trailing double-star is at-least arity": { - in: []protocol.EventFilter{{Topics: []protocol.TopicFilter{ - {seg(xferVal), wild(dstar)}, - }}}, - want: []event.Filter{{ - Topics: [protocol.MaxTopicCount][]byte{xferRaw}, - TopicCount: event.TopicCountFilter{Count: 1, Exact: false}, - }}, - }, - // "*" constrains no value, only the position's existence via arity. - "star segment is positionless exact arity": { - in: []protocol.EventFilter{{Topics: []protocol.TopicFilter{{wild(star)}}}}, - want: []event.Filter{{TopicCount: event.TopicCountFilter{Count: 1, Exact: true}}}, - }, - "star then value pins position one": { - in: []protocol.EventFilter{{Topics: []protocol.TopicFilter{ - {wild(star), seg(aliceVal)}, - }}}, - want: []event.Filter{{ - Topics: [protocol.MaxTopicCount][]byte{nil, aliceRaw}, - TopicCount: event.TopicCountFilter{Count: 2, Exact: true}, - }}, - }, - // "**" alone is at-least-zero: no constraint at all, so the branch - // matches everything and the query collapses to match-all. - "double-star alone collapses to match-all": { - in: []protocol.EventFilter{{Topics: []protocol.TopicFilter{{wild(dstar)}}}}, - want: nil, - }, - // OR dimensions multiply: type x contractIds x topics. - "cross-product expansion": { - in: []protocol.EventFilter{{ - EventType: contractType, - ContractIDs: []string{ - testContractStrkey(t, 0xAA), testContractStrkey(t, 0xBB), - }, - Topics: []protocol.TopicFilter{{seg(xferVal)}, {seg(mintVal)}}, - }}, - want: []event.Filter{ - { - EventType: eventTypePtr(xdr.ContractEventTypeContract), - ContractID: testContractRaw(0xAA), - Topics: [protocol.MaxTopicCount][]byte{xferRaw}, - TopicCount: event.TopicCountFilter{Count: 1, Exact: true}, - }, - { - EventType: eventTypePtr(xdr.ContractEventTypeContract), - ContractID: testContractRaw(0xAA), - Topics: [protocol.MaxTopicCount][]byte{mintRaw}, - TopicCount: event.TopicCountFilter{Count: 1, Exact: true}, - }, - { - EventType: eventTypePtr(xdr.ContractEventTypeContract), - ContractID: testContractRaw(0xBB), - Topics: [protocol.MaxTopicCount][]byte{xferRaw}, - TopicCount: event.TopicCountFilter{Count: 1, Exact: true}, - }, - { - EventType: eventTypePtr(xdr.ContractEventTypeContract), - ContractID: testContractRaw(0xBB), - Topics: [protocol.MaxTopicCount][]byte{mintRaw}, - TopicCount: event.TopicCountFilter{Count: 1, Exact: true}, - }, - }, - }, - "separate filters stay separate branches": { - in: []protocol.EventFilter{ - {ContractIDs: []string{testContractStrkey(t, 0xAA)}}, - {Topics: []protocol.TopicFilter{{seg(mintVal)}}}, - }, - want: []event.Filter{ - {ContractID: testContractRaw(0xAA)}, - { - Topics: [protocol.MaxTopicCount][]byte{mintRaw}, - TopicCount: event.TopicCountFilter{Count: 1, Exact: true}, - }, - }, - }, - } { - t.Run(name, func(t *testing.T) { - got, err := v1Filters(tc.in) - require.NoError(t, err) - require.ElementsMatch(t, tc.want, got) - }) - } -} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/match_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/match_test.go index 7de433401..81b4655a5 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/match_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/match_test.go @@ -15,7 +15,6 @@ import ( "github.com/stellar/go-stellar-sdk/xdr" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" - "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/store" ) // Query and QueryOptions are the engine's historical one-shot surface @@ -1038,11 +1037,6 @@ func TestQuery_UnionOfTypeAndCountFilters(t *testing.T) { Filter{TopicCount: TopicCountFilter{Count: 0, Exact: true}})) } -// TestMatchesAnyFilterView_TypeAndCount covers the post-filter's type and -// topic-count checks. Both index families are exact, so a single-clause query -// never reaches them with a mismatch: they fire only when a union clause falls -// through to the next one, or when a term hash collides. Nothing else in the -// package covers them, and every other test passes with either check disabled. // Labels the tests repeat enough for goconst to insist on names. const ( evtAAB = "evt-a-ab" @@ -1051,42 +1045,6 @@ const ( labelC0 = "c-0" ) -func TestMatchesAnyFilterView_TypeAndCount(t *testing.T) { - // Only arity matters here, so both events carry the same topic value. - var cid xdr.ContractId - sym := xdr.ScSymbol("alpha") - topic := xdr.ScVal{Type: xdr.ScValTypeScvSymbol, Sym: &sym} - oneTopic := payloadFor(t, cid, "one-topic", topic).ContractEventBytes - twoTopics := payloadFor(t, cid, "two-topics", topic, topic).ContractEventBytes - - system, contract := xdr.ContractEventTypeSystem, xdr.ContractEventTypeContract - exactly1 := Filter{TopicCount: TopicCountFilter{Count: 1, Exact: true}} - exactly2 := Filter{TopicCount: TopicCountFilter{Count: 2, Exact: true}} - atLeast2 := Filter{TopicCount: TopicCountFilter{Count: 2}} - - for name, tc := range map[string]struct { - raw []byte - filter Filter - want bool - }{ - "wrong type rejected": {oneTopic, Filter{EventType: &system}, false}, - "right type accepted": {oneTopic, Filter{EventType: &contract}, true}, - "count above exact": {twoTopics, exactly1, false}, - "count below exact": {oneTopic, exactly2, false}, - "exact count accepted": {twoTopics, exactly2, true}, - "count below the minimum": {oneTopic, atLeast2, false}, - "at least count accepted": {twoTopics, atLeast2, true}, - } { - t.Run(name, func(t *testing.T) { - filters := []Filter{tc.filter} - plan := store.PlanFilters(filters) - got, err := store.MatchesAnyFilterView(xdr.ContractEventView(tc.raw), filters, &plan) - require.NoError(t, err) - assert.Equal(t, tc.want, got) - }) - } -} - // TestQuery_InvalidFilterRejected covers the values that would key a term no // event is indexed under, which would otherwise return nothing with no signal. func TestQuery_InvalidFilterRejected(t *testing.T) { diff --git a/cmd/stellar-rpc/internal/store/event_match_test.go b/cmd/stellar-rpc/internal/store/event_match_test.go new file mode 100644 index 000000000..6be038704 --- /dev/null +++ b/cmd/stellar-rpc/internal/store/event_match_test.go @@ -0,0 +1,291 @@ +package store + +// Translation rules for the v1 filter model and the matcher checks that only +// the compiled clauses enforce. eventsapi's parity harness cross-checks the +// same rules end to end against the shared v1 handler. + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + protocol "github.com/stellar/go-stellar-sdk/protocols/rpc" + "github.com/stellar/go-stellar-sdk/strkey" + "github.com/stellar/go-stellar-sdk/xdr" +) + +func testContractRaw(b byte) []byte { return bytes.Repeat([]byte{b}, 32) } + +func testContractStrkey(t *testing.T, b byte) string { + t.Helper() + s, err := strkey.Encode(strkey.VersionByteContract, testContractRaw(b)) + require.NoError(t, err) + return s +} + +// symbolScVal returns an ScVal symbol and its canonical XDR bytes, the form clauses carry. +func symbolScVal(t *testing.T, s string) (xdr.ScVal, []byte) { + t.Helper() + val := xdr.ScVal{Type: xdr.ScValTypeScvSymbol, Sym: (*xdr.ScSymbol)(&s)} + raw, err := val.MarshalBinary() + require.NoError(t, err) + return val, raw +} + +func eventTypePtr(v xdr.ContractEventType) *xdr.ContractEventType { return new(v) } + +// contractEventBytes marshals a V0 ContractEvent with the given contract id, type and topics. +func contractEventBytes(t *testing.T, cid []byte, typ xdr.ContractEventType, topics ...xdr.ScVal) []byte { + t.Helper() + sym := xdr.ScSymbol("data") + ev := xdr.ContractEvent{ + Type: typ, + Body: xdr.ContractEventBody{V: 0, V0: &xdr.ContractEventV0{ + Topics: topics, + Data: xdr.ScVal{Type: xdr.ScValTypeScvSymbol, Sym: &sym}, + }}, + } + if cid != nil { + var id xdr.ContractId + copy(id[:], cid) + ev.ContractId = &id + } + raw, err := ev.MarshalBinary() + require.NoError(t, err) + return raw +} + +//nolint:funlen // one table, one case per translation rule +func TestCompileV1EventFilters(t *testing.T) { + xferVal, xferRaw := symbolScVal(t, "xfer") + mintVal, mintRaw := symbolScVal(t, "mint") + aliceVal, aliceRaw := symbolScVal(t, "alice") + star, dstar := "*", "**" + seg := func(v xdr.ScVal) protocol.SegmentFilter { return protocol.SegmentFilter{ScVal: &v} } + wild := func(w string) protocol.SegmentFilter { return protocol.SegmentFilter{Wildcard: &w} } + contractType := protocol.EventTypeSet{protocol.EventTypeContract: nil} + bothTypes := protocol.EventTypeSet{ + protocol.EventTypeContract: nil, protocol.EventTypeSystem: nil, + } + + for name, tc := range map[string]struct { + in []protocol.EventFilter + want []EventFilter // nil means the whole query matches every event + }{ + "contract id alone": { + in: []protocol.EventFilter{{ContractIDs: []string{testContractStrkey(t, 0xAA)}}}, + want: []EventFilter{{ContractID: testContractRaw(0xAA)}}, + }, + "single type": { + in: []protocol.EventFilter{{EventType: contractType}}, + want: []EventFilter{{EventType: eventTypePtr(xdr.ContractEventTypeContract)}}, + }, + // Valid admits only contract and system, so a set of both is one clause + // per type, never a wildcard: the SDK matcher rejects a diagnostic-typed + // event for it, and so must the compiled clauses. + "type set of both is one clause per type": { + in: []protocol.EventFilter{{EventType: bothTypes}}, + want: []EventFilter{ + {EventType: eventTypePtr(xdr.ContractEventTypeContract)}, + {EventType: eventTypePtr(xdr.ContractEventTypeSystem)}, + }, + }, + "type set of both with a contract id keeps the contract id on each clause": { + in: []protocol.EventFilter{{ + EventType: bothTypes, ContractIDs: []string{testContractStrkey(t, 0xAA)}, + }}, + want: []EventFilter{ + {ContractID: testContractRaw(0xAA), EventType: eventTypePtr(xdr.ContractEventTypeContract)}, + {ContractID: testContractRaw(0xAA), EventType: eventTypePtr(xdr.ContractEventTypeSystem)}, + }, + }, + // N segments without a trailing "**" match exactly N topics. + "one-segment topic is exact arity one": { + in: []protocol.EventFilter{{Topics: []protocol.TopicFilter{{seg(xferVal)}}}}, + want: []EventFilter{{ + Topics: [protocol.MaxTopicCount][]byte{xferRaw}, + TopicCount: TopicCountFilter{Count: 1, Exact: true}, + }}, + }, + // A trailing "**" keeps the prefix constraints and relaxes the + // arity to at-least-prefix. + "trailing double-star is at-least arity": { + in: []protocol.EventFilter{{Topics: []protocol.TopicFilter{ + {seg(xferVal), wild(dstar)}, + }}}, + want: []EventFilter{{ + Topics: [protocol.MaxTopicCount][]byte{xferRaw}, + TopicCount: TopicCountFilter{Count: 1, Exact: false}, + }}, + }, + // "*" constrains no value, only the position's existence via arity. + "star segment is positionless exact arity": { + in: []protocol.EventFilter{{Topics: []protocol.TopicFilter{{wild(star)}}}}, + want: []EventFilter{{TopicCount: TopicCountFilter{Count: 1, Exact: true}}}, + }, + "star then value pins position one": { + in: []protocol.EventFilter{{Topics: []protocol.TopicFilter{ + {wild(star), seg(aliceVal)}, + }}}, + want: []EventFilter{{ + Topics: [protocol.MaxTopicCount][]byte{nil, aliceRaw}, + TopicCount: TopicCountFilter{Count: 2, Exact: true}, + }}, + }, + // "**" alone is at-least-zero: no constraint at all, so the branch + // matches everything and the query collapses to match-all. + "double-star alone collapses to match-all": { + in: []protocol.EventFilter{{Topics: []protocol.TopicFilter{{wild(dstar)}}}}, + want: nil, + }, + // OR dimensions multiply: type x contractIds x topics. + "cross-product expansion": { + in: []protocol.EventFilter{{ + EventType: contractType, + ContractIDs: []string{ + testContractStrkey(t, 0xAA), testContractStrkey(t, 0xBB), + }, + Topics: []protocol.TopicFilter{{seg(xferVal)}, {seg(mintVal)}}, + }}, + want: []EventFilter{ + { + EventType: eventTypePtr(xdr.ContractEventTypeContract), + ContractID: testContractRaw(0xAA), + Topics: [protocol.MaxTopicCount][]byte{xferRaw}, + TopicCount: TopicCountFilter{Count: 1, Exact: true}, + }, + { + EventType: eventTypePtr(xdr.ContractEventTypeContract), + ContractID: testContractRaw(0xAA), + Topics: [protocol.MaxTopicCount][]byte{mintRaw}, + TopicCount: TopicCountFilter{Count: 1, Exact: true}, + }, + { + EventType: eventTypePtr(xdr.ContractEventTypeContract), + ContractID: testContractRaw(0xBB), + Topics: [protocol.MaxTopicCount][]byte{xferRaw}, + TopicCount: TopicCountFilter{Count: 1, Exact: true}, + }, + { + EventType: eventTypePtr(xdr.ContractEventTypeContract), + ContractID: testContractRaw(0xBB), + Topics: [protocol.MaxTopicCount][]byte{mintRaw}, + TopicCount: TopicCountFilter{Count: 1, Exact: true}, + }, + }, + }, + "separate filters stay separate branches": { + in: []protocol.EventFilter{ + {ContractIDs: []string{testContractStrkey(t, 0xAA)}}, + {Topics: []protocol.TopicFilter{{seg(mintVal)}}}, + }, + want: []EventFilter{ + {ContractID: testContractRaw(0xAA)}, + { + Topics: [protocol.MaxTopicCount][]byte{mintRaw}, + TopicCount: TopicCountFilter{Count: 1, Exact: true}, + }, + }, + }, + } { + t.Run(name, func(t *testing.T) { + got, err := CompileV1EventFilters(tc.in) + require.NoError(t, err) + require.ElementsMatch(t, tc.want, got) + }) + } +} + +// TestMatchesAnyFilterView_TypeAndCount covers the matcher's type and +// topic-count checks. rpcv2's exact indexes only reach them when a union +// clause falls through or a term hash collides, so nothing else covers them. +func TestMatchesAnyFilterView_TypeAndCount(t *testing.T) { + // Only arity matters here, so both events carry the same topic value. + cid := testContractRaw(0x00) + topic, _ := symbolScVal(t, "alpha") + oneTopic := contractEventBytes(t, cid, xdr.ContractEventTypeContract, topic) + twoTopics := contractEventBytes(t, cid, xdr.ContractEventTypeContract, topic, topic) + + system, contract := xdr.ContractEventTypeSystem, xdr.ContractEventTypeContract + exactly1 := EventFilter{TopicCount: TopicCountFilter{Count: 1, Exact: true}} + exactly2 := EventFilter{TopicCount: TopicCountFilter{Count: 2, Exact: true}} + atLeast2 := EventFilter{TopicCount: TopicCountFilter{Count: 2}} + + for name, tc := range map[string]struct { + raw []byte + filter EventFilter + want bool + }{ + "wrong type rejected": {oneTopic, EventFilter{EventType: &system}, false}, + "right type accepted": {oneTopic, EventFilter{EventType: &contract}, true}, + "count above exact": {twoTopics, exactly1, false}, + "count below exact": {oneTopic, exactly2, false}, + "exact count accepted": {twoTopics, exactly2, true}, + "count below the minimum": {oneTopic, atLeast2, false}, + "at least count accepted": {twoTopics, atLeast2, true}, + } { + t.Run(name, func(t *testing.T) { + filters := []EventFilter{tc.filter} + plan := PlanFilters(filters) + got, err := MatchesAnyFilterView(xdr.ContractEventView(tc.raw), filters, &plan) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +// TestMatchesAnyFilterView_TypeSetParity pins the SDK matcher's set semantics +// on the compiled clauses: a set naming both contract and system rejects a +// diagnostic-typed event, which the sqlite table can hold, while an empty set +// admits every type. The cells run the compiler and matcher together because +// the sqlite SQL prefilter drops its type clause as soon as any filter omits a +// type, leaving the matcher to decide alone. +func TestMatchesAnyFilterView_TypeSetParity(t *testing.T) { + cid := testContractRaw(0xAA) + events := map[string][]byte{ + protocol.EventTypeContract: contractEventBytes(t, cid, xdr.ContractEventTypeContract), + protocol.EventTypeSystem: contractEventBytes(t, cid, xdr.ContractEventTypeSystem), + protocol.EventTypeDiagnostic: contractEventBytes(t, cid, xdr.ContractEventTypeDiagnostic), + } + bothTypes := protocol.EventTypeSet{protocol.EventTypeContract: nil, protocol.EventTypeSystem: nil} + onlyContract := []string{testContractStrkey(t, 0xAA)} + contractOrSystem := map[string]bool{protocol.EventTypeContract: true, protocol.EventTypeSystem: true} + everyType := map[string]bool{ + protocol.EventTypeContract: true, protocol.EventTypeSystem: true, protocol.EventTypeDiagnostic: true, + } + + for name, tc := range map[string]struct { + in []protocol.EventFilter + want map[string]bool // by event type + }{ + "both types alone": { + in: []protocol.EventFilter{{EventType: bothTypes}}, + want: contractOrSystem, + }, + "both types with contract id, beside an untyped clause": { + in: []protocol.EventFilter{ + {EventType: bothTypes, ContractIDs: onlyContract}, + {ContractIDs: []string{testContractStrkey(t, 0xBB)}}, + }, + want: contractOrSystem, + }, + "empty type set admits every type": { + in: []protocol.EventFilter{{ContractIDs: onlyContract}}, + want: everyType, + }, + } { + t.Run(name, func(t *testing.T) { + filters, err := CompileV1EventFilters(tc.in) + require.NoError(t, err) + require.NotNil(t, filters, "a constrained filter must not collapse to match-all") + plan := PlanFilters(filters) + for typ, raw := range events { + got, err := MatchesAnyFilterView(xdr.ContractEventView(raw), filters, &plan) + require.NoError(t, err) + assert.Equal(t, tc.want[typ], got, typ) + } + }) + } +} From 513ad327c07d5b9f910d45718570109aeaf78709 Mon Sep 17 00:00:00 2001 From: Christian Jonas Date: Mon, 14 Sep 2026 18:36:09 -0400 Subject: [PATCH 13/22] make error messages in shared events file backend neutral --- cmd/stellar-rpc/internal/store/event_match.go | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/cmd/stellar-rpc/internal/store/event_match.go b/cmd/stellar-rpc/internal/store/event_match.go index 76a0d30cf..14be23029 100644 --- a/cmd/stellar-rpc/internal/store/event_match.go +++ b/cmd/stellar-rpc/internal/store/event_match.go @@ -300,11 +300,11 @@ func MatchesAnyFilterView(ev xdr.ContractEventView, filters []EventFilter, plan func resolveViewEventType(ev xdr.ContractEventView) (xdr.ContractEventType, error) { typeView, err := ev.Type() if err != nil { - return 0, fmt.Errorf("events: post-filter view Type: %w", err) + return 0, fmt.Errorf("events: match Type: %w", err) } eventType, err := typeView.Value() if err != nil { - return 0, fmt.Errorf("events: post-filter view Type value: %w", err) + return 0, fmt.Errorf("events: match Type value: %w", err) } return eventType, nil } @@ -314,22 +314,22 @@ func resolveViewEventType(ev xdr.ContractEventView) (xdr.ContractEventType, erro func resolveViewTopics(ev xdr.ContractEventView) (xdr.ContractEventV0TopicsView, bool, error) { body, err := ev.Body() if err != nil { - return nil, false, fmt.Errorf("events: post-filter view Body: %w", err) + return nil, false, fmt.Errorf("events: match Body: %w", err) } bodyV, err := body.V() if err != nil { - return nil, false, fmt.Errorf("events: post-filter view Body.V: %w", err) + return nil, false, fmt.Errorf("events: match Body.V: %w", err) } if bodyV != 0 { return nil, false, nil } v0, err := body.V0() if err != nil { - return nil, false, fmt.Errorf("events: post-filter view Body.V0: %w", err) + return nil, false, fmt.Errorf("events: match Body.V0: %w", err) } topics, err := v0.Topics() if err != nil { - return nil, false, fmt.Errorf("events: post-filter view Body.V0.Topics: %w", err) + return nil, false, fmt.Errorf("events: match Body.V0.Topics: %w", err) } return topics, true, nil } @@ -344,7 +344,7 @@ func resolveViewTopicCount(ev xdr.ContractEventView) (int, error) { } count, err := topics.Count() if err != nil { - return 0, fmt.Errorf("events: post-filter view Body.V0.Topics.Count: %w", err) + return 0, fmt.Errorf("events: match Body.V0.Topics.Count: %w", err) } return count, nil } @@ -354,18 +354,18 @@ func resolveViewTopicCount(ev xdr.ContractEventView) (int, error) { func resolveViewContractID(ev xdr.ContractEventView) ([]byte, error) { cidOpt, err := ev.ContractId() if err != nil { - return nil, fmt.Errorf("events: post-filter view ContractId opt: %w", err) + return nil, fmt.Errorf("events: match ContractId opt: %w", err) } cidView, present, err := cidOpt.Unwrap() if err != nil { - return nil, fmt.Errorf("events: post-filter view ContractId unwrap: %w", err) + return nil, fmt.Errorf("events: match ContractId unwrap: %w", err) } if !present { return nil, nil } cid, err := cidView.Raw() if err != nil { - return nil, fmt.Errorf("events: post-filter view ContractId raw: %w", err) + return nil, fmt.Errorf("events: match ContractId raw: %w", err) } return cid, nil } @@ -392,7 +392,7 @@ func collectTopicViewBytes( i := 0 for topic, ierr := range topicsArr.Iter() { if ierr != nil { - return fmt.Errorf("events: post-filter view topic iter: %w", ierr) + return fmt.Errorf("events: match topic iter: %w", ierr) } if i > plan.maxTopicIdx || i >= protocol.MaxTopicCount { break @@ -400,7 +400,7 @@ func collectTopicViewBytes( if plan.needsTopic[i] { rawBytes, err := topic.Raw() if err != nil { - return fmt.Errorf("events: post-filter view topic[%d].Raw: %w", i, err) + return fmt.Errorf("events: match topic[%d].Raw: %w", i, err) } topicRaw[i] = rawBytes } From de7a7b4e33c691969f04d103aaa64c5e17df5a58 Mon Sep 17 00:00:00 2001 From: Christian Jonas Date: Mon, 14 Sep 2026 18:36:56 -0400 Subject: [PATCH 14/22] fix stale comments --- cmd/stellar-rpc/internal/rpcv2/stores/event/match.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/match.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/match.go index 531db9db6..ac403ee03 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/match.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/match.go @@ -50,9 +50,9 @@ func topicCountTermKeys(f TopicCountFilter) []TermKey { // filterValueTermKeys returns one term per constrained value field // (contract ID, event type, topics): the single enumeration -// termGroups and CountDistinctTerms share, so the two cannot drift +// filterTermGroups and CountDistinctTerms share, so the two cannot drift // over which values a filter names. The topic-count buckets are not -// value terms; termGroups adds them separately and the budget does +// value terms; filterTermGroups adds them separately and the budget does // not count them. func filterValueTermKeys(f *Filter) []TermKey { var keys []TermKey @@ -528,7 +528,7 @@ func ValidateFilters(filters []Filter) error { // count twice. Topic-count buckets are excluded: they are an // implementation detail of the engine's grouping, not a value the // client named. Exported for the v2 handler's term-budget check. It -// lives here, beside termGroups, so the budget and the engine's +// lives here, beside filterTermGroups, so the budget and the engine's // lookups agree on what a value term is: TermKey over the store's // canonical bytes. func CountDistinctTerms(filters []Filter) int { From 3b9a453edf8d15139fb5694259cf41484f5efe5a Mon Sep 17 00:00:00 2001 From: Christian Jonas Date: Mon, 14 Sep 2026 18:40:37 -0400 Subject: [PATCH 15/22] minor shared renderer optimizations --- .../internal/methods/get_events.go | 56 +++++++++---------- .../rpcv2/eventsapi/conversions_test.go | 15 ++--- .../internal/rpcv2/eventsapi/get_events_v2.go | 30 ++++------ 3 files changed, 44 insertions(+), 57 deletions(-) diff --git a/cmd/stellar-rpc/internal/methods/get_events.go b/cmd/stellar-rpc/internal/methods/get_events.go index ba1b8b16a..4bf292d2d 100644 --- a/cmd/stellar-rpc/internal/methods/get_events.go +++ b/cmd/stellar-rpc/internal/methods/get_events.go @@ -3,7 +3,6 @@ package methods import ( "context" "encoding/base64" - "encoding/json" "fmt" "math" "time" @@ -272,8 +271,10 @@ func EventInfoFromView( err := xdr.TryVoid(func() { xdrType = ev.MustType().MustValue() v0 := ev.MustBody().MustV0() // panics on a non-V0 body, replacing "unknown event version" - for t := range v0.MustTopics().MustIter() { - topics = append(topics, t.MustRaw()) + views := v0.MustTopics().MustAll() + topics = make([][]byte, len(views)) + for i, t := range views { + topics[i] = t.MustRaw() } dataRaw = v0.MustData().MustRaw() if cid, ok := ev.MustContractId().MustUnwrap(); ok { @@ -289,7 +290,7 @@ func EventInfoFromView( } info := protocol.EventInfo{ - EventType: protocol.GetEventTypeFromEventTypeXDR()[xdrType], + EventType: eventTypeName(xdrType), Ledger: int32(cursor.Ledger), LedgerClosedAt: ledgerClosedAt, ID: cursor.String(), @@ -299,43 +300,42 @@ func EventInfoFromView( } if cidRaw != nil { - info.ContractID = strkey.MustEncode( - strkey.VersionByteContract, - cidRaw, - ) + info.ContractID = strkey.MustEncode(strkey.VersionByteContract, cidRaw) } switch format { case protocol.FormatJSON: - // json encode the topic - info.TopicJSON = make([]json.RawMessage, 0, protocol.MaxTopicCount) - for _, topicView := range topics { - topic, err := xdr2json.ConvertBytes(xdr.ScVal{}, topicView) - if err != nil { - return protocol.EventInfo{}, err - } - info.TopicJSON = append(info.TopicJSON, topic) + if info.TopicJSON, err = jsonifySlice(xdr.ScVal{}, topics); err != nil { + return protocol.EventInfo{}, err } - - var convErr error - info.ValueJSON, convErr = xdr2json.ConvertBytes(xdr.ScVal{}, dataRaw) - if convErr != nil { - return protocol.EventInfo{}, convErr + if info.ValueJSON, err = xdr2json.ConvertBytes(xdr.ScVal{}, dataRaw); err != nil { + return protocol.EventInfo{}, err } - default: - // base64-xdr encode the topic - topic := make([]string, 0, protocol.MaxTopicCount) - for _, segment := range topics { - topic = append(topic, base64.StdEncoding.EncodeToString(segment)) + info.TopicXDR = make([]string, len(topics)) + for i, segment := range topics { + info.TopicXDR[i] = base64.StdEncoding.EncodeToString(segment) } - info.TopicXDR = topic - info.ValueXDR = base64.StdEncoding.EncodeToString(dataRaw) // base64-xdr encode the data + info.ValueXDR = base64.StdEncoding.EncodeToString(dataRaw) } return info, nil } +// eventTypeName is protocol.GetEventTypeFromEventTypeXDR without the per-call map; "" for an unknown type. +func eventTypeName(t xdr.ContractEventType) string { + switch t { + case xdr.ContractEventTypeSystem: + return protocol.EventTypeSystem + case xdr.ContractEventTypeContract: + return protocol.EventTypeContract + case xdr.ContractEventTypeDiagnostic: + return protocol.EventTypeDiagnostic + default: + return "" + } +} + // NewGetEventsHandler returns a json rpc handler to fetch and filter events func NewGetEventsHandler( logger *log.Entry, diff --git a/cmd/stellar-rpc/internal/rpcv2/eventsapi/conversions_test.go b/cmd/stellar-rpc/internal/rpcv2/eventsapi/conversions_test.go index 240744738..317899c04 100644 --- a/cmd/stellar-rpc/internal/rpcv2/eventsapi/conversions_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/eventsapi/conversions_test.go @@ -466,15 +466,8 @@ func TestResponseScanStatus(t *testing.T) { // Ingest stores contract and system events only, so a diagnostic event // reaching the response means the store is corrupt. -func TestResponseEventType(t *testing.T) { - contract, err := responseEventType(xdr.ContractEventTypeContract) - require.NoError(t, err) - assert.Equal(t, protocol.EventTypeContract, contract) - - system, err := responseEventType(xdr.ContractEventTypeSystem) - require.NoError(t, err) - assert.Equal(t, protocol.EventTypeSystem, system) - - _, err = responseEventType(xdr.ContractEventTypeDiagnostic) - require.ErrorContains(t, err, "stored event has type") +func TestCheckResponseEventType(t *testing.T) { + require.NoError(t, checkResponseEventType(protocol.EventTypeContract)) + require.NoError(t, checkResponseEventType(protocol.EventTypeSystem)) + require.ErrorContains(t, checkResponseEventType(protocol.EventTypeDiagnostic), "stored event has type") } 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 933e8704a..eecbdb7f2 100644 --- a/cmd/stellar-rpc/internal/rpcv2/eventsapi/get_events_v2.go +++ b/cmd/stellar-rpc/internal/rpcv2/eventsapi/get_events_v2.go @@ -455,32 +455,26 @@ func responseScanStatus(s query.ScanStatus) string { // eventInfoV2 builds one response event from a stored event payload. func eventInfoV2(p *event.Payload, format string) (protocol.EventInfoV2, error) { - ev := xdr.ContractEventView(p.ContractEventBytes) - xdrType, err := xdr.Try(func() xdr.ContractEventType { return ev.MustType().MustValue() }) - if err != nil { - return protocol.EventInfoV2{}, fmt.Errorf("rpcv2: stored event bytes: %w", err) - } - if _, err := responseEventType(xdrType); err != nil { // stricter than v1, which also renders diagnostic events - return protocol.EventInfoV2{}, err - } cursor := protocol.Cursor{Ledger: p.LedgerSequence, Tx: p.TxIdx, Op: p.OpIdx, Event: p.EventIdx} - info, err := methods.EventInfoFromView(ev, cursor, + info, err := methods.EventInfoFromView(xdr.ContractEventView(p.ContractEventBytes), cursor, time.Unix(p.LedgerClosedAt, 0).UTC().Format(time.RFC3339), p.TxHash.HexString(), format) if err != nil { return protocol.EventInfoV2{}, fmt.Errorf("rpcv2: %w", err) } + // Stricter than v1, which also renders diagnostic events. + if err := checkResponseEventType(info.EventType); err != nil { + return protocol.EventInfoV2{}, err + } return protocol.EventInfoV2(info), nil } -// responseEventType: ingest stores contract and system events only. -func responseEventType(t xdr.ContractEventType) (string, error) { - switch t { - case xdr.ContractEventTypeSystem: - return protocol.EventTypeSystem, nil - case xdr.ContractEventTypeContract: - return protocol.EventTypeContract, nil +// checkResponseEventType: ingest stores contract and system events only. +func checkResponseEventType(name string) error { + switch name { + case protocol.EventTypeSystem, protocol.EventTypeContract: + return nil default: - return "", fmt.Errorf("rpcv2: stored event has type %d;"+ - " this endpoint serves contract and system events only", t) + return fmt.Errorf("rpcv2: stored event has type %q;"+ + " this endpoint serves contract and system events only", name) } } From ac9ca1bd6c512e4309ba2cd67b55e47a77682654 Mon Sep 17 00:00:00 2001 From: Christian Jonas Date: Mon, 14 Sep 2026 18:43:16 -0400 Subject: [PATCH 16/22] change handler to check length to see if match found --- cmd/stellar-rpc/internal/methods/get_events.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/stellar-rpc/internal/methods/get_events.go b/cmd/stellar-rpc/internal/methods/get_events.go index 4bf292d2d..a83efc59c 100644 --- a/cmd/stellar-rpc/internal/methods/get_events.go +++ b/cmd/stellar-rpc/internal/methods/get_events.go @@ -202,7 +202,7 @@ func (h eventsRPCHandler) getEvents(ctx context.Context, request protocol.GetEve if err != nil { return false, err } - matched := filters == nil + matched := len(filters) == 0 if !matched { if matched, err = store.MatchesAnyFilterView(event, filters, &plan); err != nil { return false, err From 387ed77701362fe7ac0d8bbb820f7ad50ae12118 Mon Sep 17 00:00:00 2001 From: Christian Jonas Date: Tue, 15 Sep 2026 11:55:43 -0400 Subject: [PATCH 17/22] get event fields in one pass per level --- .../internal/methods/get_events.go | 57 ++++++++++++------- 1 file changed, 38 insertions(+), 19 deletions(-) diff --git a/cmd/stellar-rpc/internal/methods/get_events.go b/cmd/stellar-rpc/internal/methods/get_events.go index a83efc59c..bf999fdf1 100644 --- a/cmd/stellar-rpc/internal/methods/get_events.go +++ b/cmd/stellar-rpc/internal/methods/get_events.go @@ -262,25 +262,7 @@ func EventInfoFromView( cursor protocol.Cursor, ledgerClosedAt, txHash, format string, ) (protocol.EventInfo, error) { - var ( - xdrType xdr.ContractEventType - topics [][]byte - dataRaw []byte - cidRaw []byte - ) - err := xdr.TryVoid(func() { - xdrType = ev.MustType().MustValue() - v0 := ev.MustBody().MustV0() // panics on a non-V0 body, replacing "unknown event version" - views := v0.MustTopics().MustAll() - topics = make([][]byte, len(views)) - for i, t := range views { - topics[i] = t.MustRaw() - } - dataRaw = v0.MustData().MustRaw() - if cid, ok := ev.MustContractId().MustUnwrap(); ok { - cidRaw = cid.MustRaw() - } - }) + xdrType, cidRaw, topics, dataRaw, err := eventRawFields(ev) if err != nil { return protocol.EventInfo{}, errors.Wrap(err, "malformed event") } @@ -322,6 +304,43 @@ func EventInfoFromView( return info, nil } +// eventRawFields locates the event's type, contract id, topics and data in one pass per level. +// Fields and All return views already trimmed to their extent, so their bytes are the raw XDR +// and calling Raw() would only re-walk them to size them again. +func eventRawFields(ev xdr.ContractEventView) (xdr.ContractEventType, []byte, [][]byte, []byte, error) { + f, err := ev.Fields() + if err != nil { + return 0, nil, nil, nil, err + } + xdrType, err := f.Type.Value() + if err != nil { + return 0, nil, nil, nil, err + } + var cidRaw []byte + if cid, ok, err := f.ContractId.Unwrap(); err != nil { + return 0, nil, nil, nil, err + } else if ok { + cidRaw = []byte(cid) + } + v0, err := f.Body.V0() // fails on a non-V0 body, replacing "unknown event version" + if err != nil { + return 0, nil, nil, nil, err + } + v0f, err := v0.Fields() + if err != nil { + return 0, nil, nil, nil, err + } + views, err := v0f.Topics.All() + if err != nil { + return 0, nil, nil, nil, err + } + topics := make([][]byte, len(views)) + for i, t := range views { + topics[i] = []byte(t) + } + return xdrType, cidRaw, topics, []byte(v0f.Data), nil +} + // eventTypeName is protocol.GetEventTypeFromEventTypeXDR without the per-call map; "" for an unknown type. func eventTypeName(t xdr.ContractEventType) string { switch t { From 609ff54e00b0ff078f016434b8f1d005c20c27b4 Mon Sep 17 00:00:00 2001 From: Christian Jonas Date: Tue, 15 Sep 2026 12:26:10 -0400 Subject: [PATCH 18/22] fix scan function error handling --- .../internal/methods/get_events.go | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/cmd/stellar-rpc/internal/methods/get_events.go b/cmd/stellar-rpc/internal/methods/get_events.go index bf999fdf1..87c2603e8 100644 --- a/cmd/stellar-rpc/internal/methods/get_events.go +++ b/cmd/stellar-rpc/internal/methods/get_events.go @@ -194,18 +194,20 @@ func (h eventsRPCHandler) getEvents(ctx context.Context, request protocol.GetEve plan := store.PlanFilters(filters) results := []protocol.EventInfo{} - // Scan function to apply filters + // procErr keeps the callback's own error out of the reader's wrap, as getTransactions does, + // so a render failure stays a plain (system) error rather than an InvalidRequest. + var procErr error var eventViewScanFunction store.ViewScanFunction = func( eventView xdr.DiagnosticEventView, cursor protocol.Cursor, ledgerCloseTimestamp int64, txHash *xdr.Hash, ) (bool, error) { - event, err := eventView.Event() - if err != nil { - return false, err + var event xdr.ContractEventView + if event, procErr = eventView.Event(); procErr != nil { + return false, procErr } matched := len(filters) == 0 if !matched { - if matched, err = store.MatchesAnyFilterView(event, filters, &plan); err != nil { - return false, err + if matched, procErr = store.MatchesAnyFilterView(event, filters, &plan); procErr != nil { + return false, procErr } } // Render the matched event in the callback to prevent two passes @@ -218,7 +220,8 @@ func (h eventsRPCHandler) getEvents(ctx context.Context, request protocol.GetEve request.Format, ) if err != nil { - return false, errors.Wrap(err, "could not parse event") + procErr = errors.Wrap(err, "could not parse event") + return false, procErr } results = append(results, info) } @@ -226,7 +229,10 @@ func (h eventsRPCHandler) getEvents(ctx context.Context, request protocol.GetEve } err = h.dbReader.GetEvents(ctx, cursorRange, contractIDs, topics, eventTypes, eventViewScanFunction) - if err != nil { + switch { + case procErr != nil: + return protocol.GetEventsResponse{}, procErr + case err != nil: return protocol.GetEventsResponse{}, &jrpc2.Error{ Code: jrpc2.InvalidRequest, Message: err.Error(), } From 719aba26353e09e7581eef8add1108d26403f447 Mon Sep 17 00:00:00 2001 From: Christian Jonas Date: Tue, 15 Sep 2026 13:21:43 -0400 Subject: [PATCH 19/22] revert to contract/system event type fold --- cmd/stellar-rpc/internal/store/event_match.go | 46 ++++++------ .../internal/store/event_match_test.go | 75 ++----------------- 2 files changed, 28 insertions(+), 93 deletions(-) diff --git a/cmd/stellar-rpc/internal/store/event_match.go b/cmd/stellar-rpc/internal/store/event_match.go index 14be23029..e985dabea 100644 --- a/cmd/stellar-rpc/internal/store/event_match.go +++ b/cmd/stellar-rpc/internal/store/event_match.go @@ -3,7 +3,6 @@ package store import ( "bytes" "fmt" - "slices" protocol "github.com/stellar/go-stellar-sdk/protocols/rpc" "github.com/stellar/go-stellar-sdk/strkey" @@ -56,8 +55,8 @@ func (f TopicCountFilter) Matches(n int) bool { // CompileV1EventFilters expands a validated v1 filter list into clauses. The // OR dimensions within one v1 filter (contract ids, topic filters) multiply // out, one clause per combination: at most 5 filters x 5 contract ids -// x 5 topics x 2 filter types = 250. A combination with no constraints matches -// every event, so the whole query collapses to match-all (nil). +// x 5 topics = 125. A combination with no constraints matches every event, +// so the whole query collapses to match-all (nil). func CompileV1EventFilters(in []protocol.EventFilter) ([]EventFilter, error) { var out []EventFilter for i := range in { @@ -74,20 +73,19 @@ func CompileV1EventFilters(in []protocol.EventFilter) ([]EventFilter, error) { } func expandV1Filter(f *protocol.EventFilter) ([]EventFilter, bool, error) { - // One clause per named type + an empty set is the wildcard. Valid admits - // only contract and system, so len is 0, 1 or 2. - eventTypes := []*xdr.ContractEventType{nil} - if len(f.EventType) > 0 { - names := f.EventType.Keys() - slices.Sort(names) // keep clause order deterministic - eventTypes = eventTypes[:0] // drop the initial nil, we have explicit names - for _, name := range names { - typ, ok := protocol.GetEventTypeXDRFromEventType()[name] - if !ok { - return nil, false, fmt.Errorf("unsupported event type %q", name) - } - eventTypes = append(eventTypes, &typ) + // A validated type set holds only contract and system, the only types + // either backend ingests: naming both constrains nothing, naming one is + // one term. Either way the type never multiplies the expansion. + var eventType *xdr.ContractEventType + if len(f.EventType) == 1 { + name := f.EventType.Keys()[0] + typ, ok := protocol.GetEventTypeXDRFromEventType()[name] + if !ok { + // Valid admits only contract and system, so anything else is a + // handler bug, not client input. + return nil, false, fmt.Errorf("unsupported event type %q", name) } + eventType = &typ } contracts := [][]byte{nil} if len(f.ContractIDs) > 0 { @@ -114,16 +112,14 @@ func expandV1Filter(f *protocol.EventFilter) ([]EventFilter, bool, error) { } } - out := make([]EventFilter, 0, len(eventTypes)*len(contracts)*len(shapes)) - for _, typ := range eventTypes { - for _, cid := range contracts { - for _, sh := range shapes { - flt := EventFilter{ContractID: cid, EventType: typ, Topics: sh.topics, TopicCount: sh.count} - if isMatchAll(&flt) { - return nil, true, nil - } - out = append(out, flt) + out := make([]EventFilter, 0, len(contracts)*len(shapes)) + for _, cid := range contracts { + for _, sh := range shapes { + flt := EventFilter{ContractID: cid, EventType: eventType, Topics: sh.topics, TopicCount: sh.count} + if isMatchAll(&flt) { + return nil, true, nil } + out = append(out, flt) } } return out, false, nil diff --git a/cmd/stellar-rpc/internal/store/event_match_test.go b/cmd/stellar-rpc/internal/store/event_match_test.go index 6be038704..bc7afac1e 100644 --- a/cmd/stellar-rpc/internal/store/event_match_test.go +++ b/cmd/stellar-rpc/internal/store/event_match_test.go @@ -82,24 +82,17 @@ func TestCompileV1EventFilters(t *testing.T) { in: []protocol.EventFilter{{EventType: contractType}}, want: []EventFilter{{EventType: eventTypePtr(xdr.ContractEventTypeContract)}}, }, - // Valid admits only contract and system, so a set of both is one clause - // per type, never a wildcard: the SDK matcher rejects a diagnostic-typed - // event for it, and so must the compiled clauses. - "type set of both is one clause per type": { - in: []protocol.EventFilter{{EventType: bothTypes}}, - want: []EventFilter{ - {EventType: eventTypePtr(xdr.ContractEventTypeContract)}, - {EventType: eventTypePtr(xdr.ContractEventTypeSystem)}, - }, + // Valid admits only contract and system, the only types either backend + // ingests, so a set of both constrains nothing. + "type set of both is a wildcard": { + in: []protocol.EventFilter{{EventType: bothTypes}}, + want: nil, }, - "type set of both with a contract id keeps the contract id on each clause": { + "type set of both with a contract id keeps only the contract id": { in: []protocol.EventFilter{{ EventType: bothTypes, ContractIDs: []string{testContractStrkey(t, 0xAA)}, }}, - want: []EventFilter{ - {ContractID: testContractRaw(0xAA), EventType: eventTypePtr(xdr.ContractEventTypeContract)}, - {ContractID: testContractRaw(0xAA), EventType: eventTypePtr(xdr.ContractEventTypeSystem)}, - }, + want: []EventFilter{{ContractID: testContractRaw(0xAA)}}, }, // N segments without a trailing "**" match exactly N topics. "one-segment topic is exact arity one": { @@ -235,57 +228,3 @@ func TestMatchesAnyFilterView_TypeAndCount(t *testing.T) { }) } } - -// TestMatchesAnyFilterView_TypeSetParity pins the SDK matcher's set semantics -// on the compiled clauses: a set naming both contract and system rejects a -// diagnostic-typed event, which the sqlite table can hold, while an empty set -// admits every type. The cells run the compiler and matcher together because -// the sqlite SQL prefilter drops its type clause as soon as any filter omits a -// type, leaving the matcher to decide alone. -func TestMatchesAnyFilterView_TypeSetParity(t *testing.T) { - cid := testContractRaw(0xAA) - events := map[string][]byte{ - protocol.EventTypeContract: contractEventBytes(t, cid, xdr.ContractEventTypeContract), - protocol.EventTypeSystem: contractEventBytes(t, cid, xdr.ContractEventTypeSystem), - protocol.EventTypeDiagnostic: contractEventBytes(t, cid, xdr.ContractEventTypeDiagnostic), - } - bothTypes := protocol.EventTypeSet{protocol.EventTypeContract: nil, protocol.EventTypeSystem: nil} - onlyContract := []string{testContractStrkey(t, 0xAA)} - contractOrSystem := map[string]bool{protocol.EventTypeContract: true, protocol.EventTypeSystem: true} - everyType := map[string]bool{ - protocol.EventTypeContract: true, protocol.EventTypeSystem: true, protocol.EventTypeDiagnostic: true, - } - - for name, tc := range map[string]struct { - in []protocol.EventFilter - want map[string]bool // by event type - }{ - "both types alone": { - in: []protocol.EventFilter{{EventType: bothTypes}}, - want: contractOrSystem, - }, - "both types with contract id, beside an untyped clause": { - in: []protocol.EventFilter{ - {EventType: bothTypes, ContractIDs: onlyContract}, - {ContractIDs: []string{testContractStrkey(t, 0xBB)}}, - }, - want: contractOrSystem, - }, - "empty type set admits every type": { - in: []protocol.EventFilter{{ContractIDs: onlyContract}}, - want: everyType, - }, - } { - t.Run(name, func(t *testing.T) { - filters, err := CompileV1EventFilters(tc.in) - require.NoError(t, err) - require.NotNil(t, filters, "a constrained filter must not collapse to match-all") - plan := PlanFilters(filters) - for typ, raw := range events { - got, err := MatchesAnyFilterView(xdr.ContractEventView(raw), filters, &plan) - require.NoError(t, err) - assert.Equal(t, tc.want[typ], got, typ) - } - }) - } -} From d7630dfc5164fc6db2d728d63fa07062cd929518 Mon Sep 17 00:00:00 2001 From: Christian Jonas Date: Tue, 15 Sep 2026 14:52:53 -0400 Subject: [PATCH 20/22] revert event match machinery relocation --- .../internal/methods/event_filter.go | 111 +++++ .../internal/methods/event_filter_test.go | 78 ++++ .../internal/methods/get_events.go | 166 ++++--- .../internal/rpcv2/eventsapi/get_events_v1.go | 124 +++++- .../rpcv2/eventsapi/v1_conversions_test.go | 151 +++++++ .../internal/rpcv2/stores/event/match.go | 324 ++++++++++++-- .../internal/rpcv2/stores/event/match_test.go | 41 ++ cmd/stellar-rpc/internal/store/event_match.go | 406 ------------------ .../internal/store/event_match_test.go | 230 ---------- 9 files changed, 896 insertions(+), 735 deletions(-) create mode 100644 cmd/stellar-rpc/internal/methods/event_filter.go create mode 100644 cmd/stellar-rpc/internal/methods/event_filter_test.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/eventsapi/v1_conversions_test.go delete mode 100644 cmd/stellar-rpc/internal/store/event_match.go delete mode 100644 cmd/stellar-rpc/internal/store/event_match_test.go diff --git a/cmd/stellar-rpc/internal/methods/event_filter.go b/cmd/stellar-rpc/internal/methods/event_filter.go new file mode 100644 index 000000000..e159d3891 --- /dev/null +++ b/cmd/stellar-rpc/internal/methods/event_filter.go @@ -0,0 +1,111 @@ +package methods + +import ( + "bytes" + "fmt" + "slices" + + protocol "github.com/stellar/go-stellar-sdk/protocols/rpc" + "github.com/stellar/go-stellar-sdk/strkey" + "github.com/stellar/go-stellar-sdk/xdr" +) + +// eventFilters is a validated GetEventsRequest.Filters with comparison values in wire form, +// matched against a view's raw bytes the way GetEventsRequest.Matches matches a decoded event. +// Empty matches every event. +type eventFilters []eventFilter + +// eventFilter is one protocol.EventFilter; an empty field matches everything. +type eventFilter struct { + types []xdr.ContractEventType + contractIDs [][]byte + topics []topicFilter +} + +// topicFilter is one protocol.TopicFilter: a nil value is "*", atLeast is a trailing "**". +type topicFilter struct { + values [][]byte + atLeast bool +} + +func compileFilters(in []protocol.EventFilter) (eventFilters, error) { + out := make(eventFilters, 0, len(in)) + for _, f := range in { + c := eventFilter{} + for _, name := range f.EventType.Keys() { + typ, ok := protocol.GetEventTypeXDRFromEventType()[name] + if !ok { // Valid admits only contract and system + return nil, fmt.Errorf("unsupported event type %q", name) + } + c.types = append(c.types, typ) + } + for _, id := range f.ContractIDs { + raw, err := strkey.Decode(strkey.VersionByteContract, id) + if err != nil { // unreachable: Valid decoded it already + return nil, fmt.Errorf("invalid contract ID: %v", id) + } + c.contractIDs = append(c.contractIDs, raw) + } + for _, tf := range f.Topics { + t, err := compileTopicFilter(tf) + if err != nil { + return nil, err + } + c.topics = append(c.topics, t) + } + out = append(out, c) + } + return out, nil +} + +func compileTopicFilter(tf protocol.TopicFilter) (topicFilter, error) { + var t topicFilter + if n := len(tf); n > 0 && tf[n-1].Wildcard != nil && *tf[n-1].Wildcard == protocol.WildCardZeroOrMore { + tf, t.atLeast = tf[:n-1], true + } + t.values = make([][]byte, len(tf)) + for i, s := range tf { + if s.ScVal == nil { // "*", or a segment Valid would have rejected + continue + } + raw, err := s.ScVal.MarshalBinary() + if err != nil { + return topicFilter{}, fmt.Errorf("failed to marshal segment: %w", err) + } + t.values[i] = raw + } + return t, nil +} + +// matchHeader reports whether some filter accepts the type and contract id. It never rejects +// an event match would accept, so a false answer skips reading the topics. +func (fs eventFilters) matchHeader(h eventHead) bool { + return len(fs) == 0 || slices.ContainsFunc(fs, func(f eventFilter) bool { return f.matchHeader(h) }) +} + +// match reports whether some filter accepts the whole event. +func (fs eventFilters) match(h eventHead, topics [][]byte) bool { + return len(fs) == 0 || slices.ContainsFunc(fs, func(f eventFilter) bool { + return f.matchHeader(h) && (len(f.topics) == 0 || + slices.ContainsFunc(f.topics, func(t topicFilter) bool { return t.matches(topics) })) + }) +} + +func (f eventFilter) matchHeader(h eventHead) bool { + return (len(f.types) == 0 || slices.Contains(f.types, h.typ)) && + (len(f.contractIDs) == 0 || slices.ContainsFunc(f.contractIDs, func(id []byte) bool { + return bytes.Equal(id, h.cid) + })) +} + +func (t topicFilter) matches(topics [][]byte) bool { + if len(topics) < len(t.values) || (!t.atLeast && len(topics) != len(t.values)) { + return false + } + for i, want := range t.values { + if want != nil && !bytes.Equal(topics[i], want) { + return false + } + } + return true +} diff --git a/cmd/stellar-rpc/internal/methods/event_filter_test.go b/cmd/stellar-rpc/internal/methods/event_filter_test.go new file mode 100644 index 000000000..c3c4b31a2 --- /dev/null +++ b/cmd/stellar-rpc/internal/methods/event_filter_test.go @@ -0,0 +1,78 @@ +package methods + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + protocol "github.com/stellar/go-stellar-sdk/protocols/rpc" + "github.com/stellar/go-stellar-sdk/strkey" + "github.com/stellar/go-stellar-sdk/xdr" +) + +// TestEventFilters pins the port of GetEventsRequest.Matches: filters OR, fields AND, +// "*" is any one topic, a trailing "**" relaxes the arity, and matchHeader never rejects +// an event match accepts. +func TestEventFilters(t *testing.T) { + sym := func(s string) xdr.ScVal { return xdr.ScVal{Type: xdr.ScValTypeScvSymbol, Sym: (*xdr.ScSymbol)(&s)} } + raw := func(v xdr.ScVal) []byte { b, err := v.MarshalBinary(); require.NoError(t, err); return b } + seg := func(s string) protocol.SegmentFilter { v := sym(s); return protocol.SegmentFilter{ScVal: &v} } + wild := func(w string) protocol.SegmentFilter { return protocol.SegmentFilter{Wildcard: &w} } + cidA, cidB := bytes.Repeat([]byte{0xAA}, 32), bytes.Repeat([]byte{0xBB}, 32) + strkeyA := strkey.MustEncode(strkey.VersionByteContract, cidA) + strkeyB := strkey.MustEncode(strkey.VersionByteContract, cidB) + topics := func(tf ...protocol.SegmentFilter) []protocol.TopicFilter { return []protocol.TopicFilter{tf} } + contract := protocol.EventTypeSet{protocol.EventTypeContract: nil} + + xferAlice := [][]byte{raw(sym("xfer")), raw(sym("alice"))} + headA := eventHead{typ: xdr.ContractEventTypeContract, cid: cidA} + headB := eventHead{typ: xdr.ContractEventTypeSystem, cid: cidB} + + for name, tc := range map[string]struct { + in []protocol.EventFilter + head eventHead + topics [][]byte + want bool + }{ + "no filters match everything": {nil, headB, nil, true}, + "contract id": {[]protocol.EventFilter{{ContractIDs: []string{strkeyA}}}, headA, nil, true}, + "contract id mismatch": {[]protocol.EventFilter{{ContractIDs: []string{strkeyA}}}, headB, nil, false}, + "type": {[]protocol.EventFilter{{EventType: contract}}, headB, nil, false}, + "fields AND within a filter": { + []protocol.EventFilter{{ + EventType: contract, ContractIDs: []string{strkeyA}, + Topics: topics(seg("xfer"), wild("*")), + }}, headA, xferAlice, true, + }, + "filters OR": { + []protocol.EventFilter{ + {ContractIDs: []string{strkeyA}, Topics: topics(seg("mint"))}, + {Topics: topics(seg("xfer"), seg("alice"))}, + }, headA, xferAlice, true, + }, + "header and topics must come from one filter": { + []protocol.EventFilter{ + {ContractIDs: []string{strkeyA}, Topics: topics(seg("mint"))}, + {ContractIDs: []string{strkeyB}, Topics: topics(seg("xfer"), wild("**"))}, + }, + headA, xferAlice, false, + }, + "exact arity": {[]protocol.EventFilter{{Topics: topics(seg("xfer"))}}, headA, xferAlice, false}, + "trailing ** relaxes": {[]protocol.EventFilter{{Topics: topics(seg("xfer"), wild("**"))}}, headA, xferAlice, true}, + "** needs the prefix": { + []protocol.EventFilter{{Topics: topics(seg("a"), seg("b"), seg("c"), wild("**"))}}, headA, xferAlice, false, + }, + } { + t.Run(name, func(t *testing.T) { + fs, err := compileFilters(tc.in) + require.NoError(t, err) + got := fs.match(tc.head, tc.topics) + assert.Equal(t, tc.want, got) + if got { + assert.True(t, fs.matchHeader(tc.head), "matchHeader must not reject a matching event") + } + }) + } +} diff --git a/cmd/stellar-rpc/internal/methods/get_events.go b/cmd/stellar-rpc/internal/methods/get_events.go index 87c2603e8..047f37ae4 100644 --- a/cmd/stellar-rpc/internal/methods/get_events.go +++ b/cmd/stellar-rpc/internal/methods/get_events.go @@ -185,13 +185,12 @@ func (h eventsRPCHandler) getEvents(ctx context.Context, request protocol.GetEve eventTypes := combineEventTypes(request.Filters) - filters, err := store.CompileV1EventFilters(request.Filters) // nil matches every event + filters, err := compileFilters(request.Filters) if err != nil { return protocol.GetEventsResponse{}, &jrpc2.Error{ Code: jrpc2.InvalidParams, Message: err.Error(), } } - plan := store.PlanFilters(filters) results := []protocol.EventInfo{} // procErr keeps the callback's own error out of the reader's wrap, as getTransactions does, @@ -204,27 +203,31 @@ func (h eventsRPCHandler) getEvents(ctx context.Context, request protocol.GetEve if event, procErr = eventView.Event(); procErr != nil { return false, procErr } - matched := len(filters) == 0 - if !matched { - if matched, procErr = store.MatchesAnyFilterView(event, filters, &plan); procErr != nil { - return false, procErr - } + // Fields are pulled off the view once and feed both the match and the render. + // Topics wait until type and contract id pass, so a rejected event never sizes them. + head, err := eventHeader(event) + if err != nil { + procErr = errors.Wrap(err, "could not parse event") + return false, procErr } - // Render the matched event in the callback to prevent two passes - if matched { - info, err := EventInfoFromView( - event, - cursor, - time.Unix(ledgerCloseTimestamp, 0).UTC().Format(time.RFC3339), - txHash.HexString(), - request.Format, - ) - if err != nil { - procErr = errors.Wrap(err, "could not parse event") - return false, procErr - } - results = append(results, info) + if !filters.matchHeader(head) { + return true, nil + } + body, err := eventBody(head.v0) + if err != nil { + procErr = errors.Wrap(err, "could not parse event") + return false, procErr + } + if !filters.match(head, body.topics) { + return true, nil + } + info, err := eventInfo(head, body, cursor, + time.Unix(ledgerCloseTimestamp, 0).UTC().Format(time.RFC3339), txHash.HexString(), request.Format) + if err != nil { + procErr = errors.Wrap(err, "could not parse event") + return false, procErr } + results = append(results, info) return uint(len(results)) < limit, nil } @@ -264,21 +267,80 @@ func (h eventsRPCHandler) getEvents(ctx context.Context, request protocol.GetEve // EventInfoFromView renders one stored ContractEvent into the v1 wire type; rpcv2's eventsapi wraps it. func EventInfoFromView( - ev xdr.ContractEventView, - cursor protocol.Cursor, - ledgerClosedAt, txHash, format string, + ev xdr.ContractEventView, cursor protocol.Cursor, ledgerClosedAt, txHash, format string, ) (protocol.EventInfo, error) { - xdrType, cidRaw, topics, dataRaw, err := eventRawFields(ev) + head, err := eventHeader(ev) + if err != nil { + return protocol.EventInfo{}, errors.Wrap(err, "malformed event") + } + body, err := eventBody(head.v0) if err != nil { return protocol.EventInfo{}, errors.Wrap(err, "malformed event") } + return eventInfo(head, body, cursor, ledgerClosedAt, txHash, format) +} + +// eventHead is the event's fields above the body, plus the V0 body view to read on demand. +type eventHead struct { + typ xdr.ContractEventType + cid []byte // nil when absent + v0 xdr.ContractEventV0View +} +// eventV0 is the V0 body's topics and data as raw XDR. +type eventV0 struct { + topics [][]byte + data []byte +} + +// eventHeader locates type, contract id and body in one pass. Fields returns views trimmed +// to their extent, so their bytes are the raw XDR and Raw() would only re-walk them. +func eventHeader(ev xdr.ContractEventView) (eventHead, error) { + f, err := ev.Fields() + if err != nil { + return eventHead{}, err + } + var h eventHead + if h.typ, err = f.Type.Value(); err != nil { + return eventHead{}, err + } + if cid, ok, err := f.ContractId.Unwrap(); err != nil { + return eventHead{}, err + } else if ok { + h.cid = []byte(cid) + } + if h.v0, err = f.Body.V0(); err != nil { // fails on a non-V0 body, replacing "unknown event version" + return eventHead{}, err + } + return h, nil +} + +// eventBody locates topics and data in one pass; All returns each topic trimmed. +func eventBody(v0 xdr.ContractEventV0View) (eventV0, error) { + f, err := v0.Fields() + if err != nil { + return eventV0{}, err + } + views, err := f.Topics.All() + if err != nil { + return eventV0{}, err + } + b := eventV0{topics: make([][]byte, len(views)), data: []byte(f.Data)} + for i, t := range views { + b.topics[i] = []byte(t) + } + return b, nil +} + +func eventInfo( + head eventHead, body eventV0, cursor protocol.Cursor, ledgerClosedAt, txHash, format string, +) (protocol.EventInfo, error) { if cursor.Ledger > math.MaxInt32 { return protocol.EventInfo{}, fmt.Errorf("ledger sequence %d exceeds supported range", cursor.Ledger) } info := protocol.EventInfo{ - EventType: eventTypeName(xdrType), + EventType: eventTypeName(head.typ), Ledger: int32(cursor.Ledger), LedgerClosedAt: ledgerClosedAt, ID: cursor.String(), @@ -287,66 +349,30 @@ func EventInfoFromView( TxIndex: cursor.Tx, } - if cidRaw != nil { - info.ContractID = strkey.MustEncode(strkey.VersionByteContract, cidRaw) + if head.cid != nil { + info.ContractID = strkey.MustEncode(strkey.VersionByteContract, head.cid) } + var err error switch format { case protocol.FormatJSON: - if info.TopicJSON, err = jsonifySlice(xdr.ScVal{}, topics); err != nil { + if info.TopicJSON, err = jsonifySlice(xdr.ScVal{}, body.topics); err != nil { return protocol.EventInfo{}, err } - if info.ValueJSON, err = xdr2json.ConvertBytes(xdr.ScVal{}, dataRaw); err != nil { + if info.ValueJSON, err = xdr2json.ConvertBytes(xdr.ScVal{}, body.data); err != nil { return protocol.EventInfo{}, err } default: - info.TopicXDR = make([]string, len(topics)) - for i, segment := range topics { + info.TopicXDR = make([]string, len(body.topics)) + for i, segment := range body.topics { info.TopicXDR[i] = base64.StdEncoding.EncodeToString(segment) } - info.ValueXDR = base64.StdEncoding.EncodeToString(dataRaw) + info.ValueXDR = base64.StdEncoding.EncodeToString(body.data) } return info, nil } -// eventRawFields locates the event's type, contract id, topics and data in one pass per level. -// Fields and All return views already trimmed to their extent, so their bytes are the raw XDR -// and calling Raw() would only re-walk them to size them again. -func eventRawFields(ev xdr.ContractEventView) (xdr.ContractEventType, []byte, [][]byte, []byte, error) { - f, err := ev.Fields() - if err != nil { - return 0, nil, nil, nil, err - } - xdrType, err := f.Type.Value() - if err != nil { - return 0, nil, nil, nil, err - } - var cidRaw []byte - if cid, ok, err := f.ContractId.Unwrap(); err != nil { - return 0, nil, nil, nil, err - } else if ok { - cidRaw = []byte(cid) - } - v0, err := f.Body.V0() // fails on a non-V0 body, replacing "unknown event version" - if err != nil { - return 0, nil, nil, nil, err - } - v0f, err := v0.Fields() - if err != nil { - return 0, nil, nil, nil, err - } - views, err := v0f.Topics.All() - if err != nil { - return 0, nil, nil, nil, err - } - topics := make([][]byte, len(views)) - for i, t := range views { - topics[i] = []byte(t) - } - return xdrType, cidRaw, topics, []byte(v0f.Data), nil -} - // eventTypeName is protocol.GetEventTypeFromEventTypeXDR without the per-call map; "" for an unknown type. func eventTypeName(t xdr.ContractEventType) string { switch t { diff --git a/cmd/stellar-rpc/internal/rpcv2/eventsapi/get_events_v1.go b/cmd/stellar-rpc/internal/rpcv2/eventsapi/get_events_v1.go index dc08a652d..5eb7c401e 100644 --- a/cmd/stellar-rpc/internal/rpcv2/eventsapi/get_events_v1.go +++ b/cmd/stellar-rpc/internal/rpcv2/eventsapi/get_events_v1.go @@ -16,7 +16,9 @@ import ( "github.com/creachadair/jrpc2" protocol "github.com/stellar/go-stellar-sdk/protocols/rpc" + "github.com/stellar/go-stellar-sdk/strkey" supportlog "github.com/stellar/go-stellar-sdk/support/log" + "github.com/stellar/go-stellar-sdk/xdr" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/methods" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/adapters" @@ -183,10 +185,126 @@ func v1Response( } } -// v1Filters compiles a validated v1 filter list into the pager's filters; -// nil matches every event. +// v1Filters expands a validated v1 filter list into the pager's filters. The +// OR dimensions within one v1 filter (contract ids, topic filters) multiply +// out, one store filter per combination: at most 5 filters x 5 contract ids +// x 5 topics = 125, under the pager's cap. A combination with no constraints +// matches every event, so the whole query collapses to the pager's +// match-all (nil). func v1Filters(in []protocol.EventFilter) ([]event.Filter, error) { - return store.CompileV1EventFilters(in) + var out []event.Filter + for i := range in { + expanded, matchAll, err := expandV1Filter(&in[i]) + if err != nil { + return nil, err + } + if matchAll { + return nil, nil + } + out = append(out, expanded...) + } + return out, nil +} + +func expandV1Filter(f *protocol.EventFilter) ([]event.Filter, bool, error) { + // A validated type set holds only contract and system: naming both + // constrains nothing, naming one is one term. Either way the type never + // multiplies the expansion. + var eventType *xdr.ContractEventType + if len(f.EventType) == 1 { + name := f.EventType.Keys()[0] + typ, ok := protocol.GetEventTypeXDRFromEventType()[name] + if !ok { + // Valid admits only contract and system, so a name that is + // neither is a handler bug, not client input. + return nil, false, fmt.Errorf("unsupported event type %q", name) + } + eventType = &typ + } + contracts := [][]byte{nil} + if len(f.ContractIDs) > 0 { + contracts = make([][]byte, 0, len(f.ContractIDs)) + for _, id := range f.ContractIDs { + raw, err := strkey.Decode(strkey.VersionByteContract, id) + if err != nil { + // Unreachable: req.Valid decoded it already. The message is + // the v1 handler's backstop wording. + return nil, false, fmt.Errorf("invalid contract ID: %v", id) + } + contracts = append(contracts, raw) + } + } + shapes := []topicShape{{}} + if len(f.Topics) > 0 { + shapes = make([]topicShape, 0, len(f.Topics)) + for _, tf := range f.Topics { + shape, err := topicShapeOf(tf) + if err != nil { + return nil, false, err + } + shapes = append(shapes, shape) + } + } + + out := make([]event.Filter, 0, len(contracts)*len(shapes)) + for _, cid := range contracts { + for _, sh := range shapes { + flt := event.Filter{ + ContractID: cid, EventType: eventType, + Topics: sh.topics, TopicCount: sh.count, + } + if isMatchAll(&flt) { + return nil, true, nil + } + out = append(out, flt) + } + } + return out, false, nil +} + +// topicShape is one v1 TopicFilter in the store's terms: the pinned +// positional values plus the arity constraint. N segments match exactly N +// topics; a trailing "**" relaxes that to at least the prefix, and "at least +// zero" is the zero value, no constraint. +type topicShape struct { + topics [protocol.MaxTopicCount][]byte + count event.TopicCountFilter +} + +func topicShapeOf(tf protocol.TopicFilter) (topicShape, error) { + segs := tf + shape := topicShape{count: event.TopicCountFilter{Count: len(tf), Exact: true}} + if n := len(tf); n > 0 && tf[n-1].Wildcard != nil && *tf[n-1].Wildcard == protocol.WildCardZeroOrMore { + // "At least zero" is the wildcard, which is the zero value. + segs = tf[:n-1] + shape.count = event.TopicCountFilter{Count: len(segs)} + } + for i, s := range segs { + // "*" is any value, and the position still exists via the count. + // A segment with neither value nor wildcard is skipped the way the + // shared handler skips it, rather than dereferenced. + if s.Wildcard != nil || s.ScVal == nil { + continue + } + raw, err := s.ScVal.MarshalBinary() + if err != nil { + return topicShape{}, fmt.Errorf("failed to marshal segment: %w", err) + } + shape.topics[i] = raw + } + return shape, nil +} + +func isMatchAll(f *event.Filter) bool { + if f.EventType != nil || len(f.ContractID) > 0 || f.TopicCount != (event.TopicCountFilter{}) { + return false + } + for i := range f.Topics { + if len(f.Topics[i]) > 0 { + return false + } + } + return true } // eventInfoV1 mints the v1 response event: eventInfoV2's output in the v1 wire type. diff --git a/cmd/stellar-rpc/internal/rpcv2/eventsapi/v1_conversions_test.go b/cmd/stellar-rpc/internal/rpcv2/eventsapi/v1_conversions_test.go new file mode 100644 index 000000000..a7e70ada6 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/eventsapi/v1_conversions_test.go @@ -0,0 +1,151 @@ +package eventsapi + +// Translation rules for the v1 filter model: each case pins one rule against +// the exact store filters v1Filters must produce. The parity harness +// cross-checks the same rules end to end against the shared v1 handler. + +import ( + "testing" + + "github.com/stretchr/testify/require" + + protocol "github.com/stellar/go-stellar-sdk/protocols/rpc" + "github.com/stellar/go-stellar-sdk/xdr" + + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/event" +) + +//nolint:funlen // one table, one case per translation rule +func TestV1FiltersTranslation(t *testing.T) { + xferVal, xferRaw := symbolScVal(t, "xfer") + mintVal, mintRaw := symbolScVal(t, "mint") + aliceVal, aliceRaw := symbolScVal(t, "alice") + star, dstar := "*", "**" + seg := func(v xdr.ScVal) protocol.SegmentFilter { return protocol.SegmentFilter{ScVal: &v} } + wild := func(w string) protocol.SegmentFilter { return protocol.SegmentFilter{Wildcard: &w} } + contractType := protocol.EventTypeSet{protocol.EventTypeContract: nil} + bothTypes := protocol.EventTypeSet{ + protocol.EventTypeContract: nil, protocol.EventTypeSystem: nil, + } + + for name, tc := range map[string]struct { + in []protocol.EventFilter + want []event.Filter // nil means the whole query matches every event + }{ + "contract id alone": { + in: []protocol.EventFilter{{ContractIDs: []string{testContractStrkey(t, 0xAA)}}}, + want: []event.Filter{{ContractID: testContractRaw(0xAA)}}, + }, + "single type": { + in: []protocol.EventFilter{{EventType: contractType}}, + want: []event.Filter{{EventType: eventTypePtr(xdr.ContractEventTypeContract)}}, + }, + // A validated set holds only contract and system, so a set of both + // constrains nothing; with nothing else in the filter, one branch + // matches everything and the query collapses to match-all. + "type set of both collapses to match-all": { + in: []protocol.EventFilter{{EventType: bothTypes}}, + want: nil, + }, + "type set of both with a contract id keeps the contract id": { + in: []protocol.EventFilter{{ + EventType: bothTypes, ContractIDs: []string{testContractStrkey(t, 0xAA)}, + }}, + want: []event.Filter{{ContractID: testContractRaw(0xAA)}}, + }, + // N segments without a trailing "**" match exactly N topics. + "one-segment topic is exact arity one": { + in: []protocol.EventFilter{{Topics: []protocol.TopicFilter{{seg(xferVal)}}}}, + want: []event.Filter{{ + Topics: [protocol.MaxTopicCount][]byte{xferRaw}, + TopicCount: event.TopicCountFilter{Count: 1, Exact: true}, + }}, + }, + // A trailing "**" keeps the prefix constraints and relaxes the + // arity to at-least-prefix. + "trailing double-star is at-least arity": { + in: []protocol.EventFilter{{Topics: []protocol.TopicFilter{ + {seg(xferVal), wild(dstar)}, + }}}, + want: []event.Filter{{ + Topics: [protocol.MaxTopicCount][]byte{xferRaw}, + TopicCount: event.TopicCountFilter{Count: 1, Exact: false}, + }}, + }, + // "*" constrains no value, only the position's existence via arity. + "star segment is positionless exact arity": { + in: []protocol.EventFilter{{Topics: []protocol.TopicFilter{{wild(star)}}}}, + want: []event.Filter{{TopicCount: event.TopicCountFilter{Count: 1, Exact: true}}}, + }, + "star then value pins position one": { + in: []protocol.EventFilter{{Topics: []protocol.TopicFilter{ + {wild(star), seg(aliceVal)}, + }}}, + want: []event.Filter{{ + Topics: [protocol.MaxTopicCount][]byte{nil, aliceRaw}, + TopicCount: event.TopicCountFilter{Count: 2, Exact: true}, + }}, + }, + // "**" alone is at-least-zero: no constraint at all, so the branch + // matches everything and the query collapses to match-all. + "double-star alone collapses to match-all": { + in: []protocol.EventFilter{{Topics: []protocol.TopicFilter{{wild(dstar)}}}}, + want: nil, + }, + // OR dimensions multiply: type x contractIds x topics. + "cross-product expansion": { + in: []protocol.EventFilter{{ + EventType: contractType, + ContractIDs: []string{ + testContractStrkey(t, 0xAA), testContractStrkey(t, 0xBB), + }, + Topics: []protocol.TopicFilter{{seg(xferVal)}, {seg(mintVal)}}, + }}, + want: []event.Filter{ + { + EventType: eventTypePtr(xdr.ContractEventTypeContract), + ContractID: testContractRaw(0xAA), + Topics: [protocol.MaxTopicCount][]byte{xferRaw}, + TopicCount: event.TopicCountFilter{Count: 1, Exact: true}, + }, + { + EventType: eventTypePtr(xdr.ContractEventTypeContract), + ContractID: testContractRaw(0xAA), + Topics: [protocol.MaxTopicCount][]byte{mintRaw}, + TopicCount: event.TopicCountFilter{Count: 1, Exact: true}, + }, + { + EventType: eventTypePtr(xdr.ContractEventTypeContract), + ContractID: testContractRaw(0xBB), + Topics: [protocol.MaxTopicCount][]byte{xferRaw}, + TopicCount: event.TopicCountFilter{Count: 1, Exact: true}, + }, + { + EventType: eventTypePtr(xdr.ContractEventTypeContract), + ContractID: testContractRaw(0xBB), + Topics: [protocol.MaxTopicCount][]byte{mintRaw}, + TopicCount: event.TopicCountFilter{Count: 1, Exact: true}, + }, + }, + }, + "separate filters stay separate branches": { + in: []protocol.EventFilter{ + {ContractIDs: []string{testContractStrkey(t, 0xAA)}}, + {Topics: []protocol.TopicFilter{{seg(mintVal)}}}, + }, + want: []event.Filter{ + {ContractID: testContractRaw(0xAA)}, + { + Topics: [protocol.MaxTopicCount][]byte{mintRaw}, + TopicCount: event.TopicCountFilter{Count: 1, Exact: true}, + }, + }, + }, + } { + t.Run(name, func(t *testing.T) { + got, err := v1Filters(tc.in) + require.NoError(t, err) + require.ElementsMatch(t, tc.want, got) + }) + } +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/match.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/match.go index ac403ee03..d263667dd 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/match.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/match.go @@ -23,23 +23,58 @@ import ( protocol "github.com/stellar/go-stellar-sdk/protocols/rpc" "github.com/stellar/go-stellar-sdk/xdr" - - "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/store" ) -// Filter is one clause in the union of an events query; see -// store.EventFilter. Aliased so the sqlite backend shares the matcher. -type Filter = store.EventFilter +// Filter is one item in the union of an events query. Within a +// single filter, every constrained field is AND-ed together against +// the corresponding indexed field of an event. Fields left at their +// zero value are wildcards. +// +// Topics[i] constrains topic position i. Positions beyond +// protocol.MaxTopicCount are not indexed (see TermsForBytes). +type Filter struct { + ContractID []byte + Topics [protocol.MaxTopicCount][]byte + // EventType constrains the event's type. A nil pointer is a + // wildcard. A filter accepting several types is several filters. + EventType *xdr.ContractEventType + // TopicCount constrains how many topics the event carries. + TopicCount TopicCountFilter +} -// TopicCountFilter constrains an event's topic count; see store.TopicCountFilter. -type TopicCountFilter = store.TopicCountFilter +// TopicCountFilter constrains an event's topic count to at least +// Count, or to exactly Count when Exact is set. Its zero value, "at +// least zero", is the wildcard. +// +// This is getEvents v1's topic arity: a topic filter ending in "**" +// matches events with at least as many topics as the filter names, and +// one that does not match events with exactly that many. +type TopicCountFilter struct { + Count int + Exact bool +} + +func (f TopicCountFilter) isWildcard() bool { return f == TopicCountFilter{} } -// topicCountTermKeys returns the topic-count buckets whose union covers f. Every +// matches reports whether an event carrying n topics satisfies f. A +// negative n stands for an event with no V0 body, which carries no +// topics at all and satisfies no constraint. +func (f TopicCountFilter) matches(n int) bool { + if n < 0 { + return false + } + if f.Exact { + return n == f.Count + } + return n >= f.Count +} + +// termKeys returns the topic-count buckets whose union covers f. Every // count ValidateFilters admits has a bucket of its own, and an "at // least" union is closed by the overflow bucket, so the union never // holds an event f does not match. -func topicCountTermKeys(f TopicCountFilter) []TermKey { - if f.IsWildcard() { +func (f TopicCountFilter) termKeys() []TermKey { + if f.isWildcard() { return nil } if f.Exact { @@ -48,13 +83,13 @@ func topicCountTermKeys(f TopicCountFilter) []TermKey { return TopicCountTermKeysAtLeast(f.Count) } -// filterValueTermKeys returns one term per constrained value field +// valueTermKeys returns one term per constrained value field // (contract ID, event type, topics): the single enumeration -// filterTermGroups and CountDistinctTerms share, so the two cannot drift +// termGroups and CountDistinctTerms share, so the two cannot drift // over which values a filter names. The topic-count buckets are not -// value terms; filterTermGroups adds them separately and the budget does +// value terms; termGroups adds them separately and the budget does // not count them. -func filterValueTermKeys(f *Filter) []TermKey { +func (f *Filter) valueTermKeys() []TermKey { var keys []TermKey if len(f.ContractID) > 0 { keys = append(keys, ComputeTermKey(f.ContractID, FieldContractID)) @@ -71,12 +106,12 @@ func filterValueTermKeys(f *Filter) []TermKey { return keys } -// filterTermGroups returns the indexed terms this filter constrains, grouped +// termGroups returns the indexed terms this filter constrains, grouped // by field: the bitmaps within a group are OR-ed and the groups are // AND-ed. Only the topic-count group ever holds more than one term. -func filterTermGroups(f *Filter) [][]TermKey { +func (f *Filter) termGroups() [][]TermKey { var groups [][]TermKey - for _, key := range filterValueTermKeys(f) { + for _, key := range f.valueTermKeys() { groups = append(groups, []TermKey{key}) } // A constrained topic position already implies an "at least" count at @@ -84,17 +119,17 @@ func filterTermGroups(f *Filter) [][]TermKey { // that position. Skipping the group there keeps the common // ["a", "**"] shape from OR-ing chunk-sized bucket bitmaps; the // post-filter enforces the count either way. - if !filterImpliesTopicCount(f) { - if keys := topicCountTermKeys(f.TopicCount); len(keys) > 0 { + if !f.impliesTopicCount() { + if keys := f.TopicCount.termKeys(); len(keys) > 0 { groups = append(groups, keys) } } return groups } -// filterImpliesTopicCount reports whether f's constrained topic positions +// impliesTopicCount reports whether f's constrained topic positions // already guarantee its topic-count bound. -func filterImpliesTopicCount(f *Filter) bool { +func (f *Filter) impliesTopicCount() bool { if f.TopicCount.Exact { return false } @@ -304,7 +339,7 @@ func unionForFilters( var uniqueKeys []TermKey for i := range filters { - groups := filterTermGroups(&filters[i]) + groups := filters[i].termGroups() if len(groups) == 0 { return nil, true, nil } @@ -528,13 +563,13 @@ func ValidateFilters(filters []Filter) error { // count twice. Topic-count buckets are excluded: they are an // implementation detail of the engine's grouping, not a value the // client named. Exported for the v2 handler's term-budget check. It -// lives here, beside filterTermGroups, so the budget and the engine's +// lives here, beside termGroups, so the budget and the engine's // lookups agree on what a value term is: TermKey over the store's // canonical bytes. func CountDistinctTerms(filters []Filter) int { unique := make(map[TermKey]struct{}) for i := range filters { - for _, key := range filterValueTermKeys(&filters[i]) { + for _, key := range filters[i].valueTermKeys() { unique[key] = struct{}{} } } @@ -652,9 +687,9 @@ func streamRange( // Match.Ordinal. func postFilter(payloads []Payload, ids []uint32, filters []Filter) ([]Match, error) { out := make([]Match, 0, len(payloads)) - plan := store.PlanFilters(filters) + plan := planFilters(filters) for i := range payloads { - ok, err := store.MatchesAnyFilterView(xdr.ContractEventView(payloads[i].ContractEventBytes), filters, &plan) + ok, err := matchesAnyFilterView(payloads[i].ContractEventBytes, filters, &plan) if err != nil { return nil, err } @@ -664,3 +699,240 @@ func postFilter(payloads []Payload, ids []uint32, filters []Filter) ([]Match, er } return out, nil } + +// filterPlan caches per-query info computed once at postFilter entry +// and consumed by matchesAnyFilterView: the topic positions any +// clause constrains and the highest constrained position (caps the +// view-path topic walk). +type filterPlan struct { + anyTopic bool + maxTopicIdx int // -1 if no clause constrains any topic + needsTopic [protocol.MaxTopicCount]bool +} + +func planFilters(filters []Filter) filterPlan { + plan := filterPlan{maxTopicIdx: -1} + for fi := range filters { + f := &filters[fi] + for i, want := range f.Topics { + if len(want) == 0 { + continue + } + plan.needsTopic[i] = true + plan.anyTopic = true + if i > plan.maxTopicIdx { + plan.maxTopicIdx = i + } + } + } + return plan +} + +// eventFields holds the decoded fields matchesAnyFilterView pulls out +// of one event, each resolved at most once and only when some clause +// asks for it. +type eventFields struct { + contractID []byte + contractIDDone bool + + eventType xdr.ContractEventType + eventTypeDone bool + + topicCount int + topicCountDone bool + + topics [protocol.MaxTopicCount][]byte + topicsDone bool +} + +// matchesAnyFilterView reports whether the event encoded in raw +// satisfies at least one filter clause. It resolves each field a +// clause constrains via xdr.ContractEventView navigation, +// byte-comparing aliased .Raw() slices against the filter clauses. +// Zero per-event allocation — every byte slice involved aliases into +// raw. +// +// Fields are resolved at most once and only when a clause asks for +// them, cheapest first: events that fail every clause's type or +// ContractId check never trigger the topic walk, and events that pass +// do exactly one linear walk over Topics up to the highest constrained +// position. +// +//nolint:gocognit,cyclop // linear clause loop with per-field lazy caches; helpers would fragment the invariant +func matchesAnyFilterView(raw []byte, filters []Filter, plan *filterPlan) (bool, error) { + ev := xdr.ContractEventView(raw) + var got eventFields + + for fi := range filters { + f := &filters[fi] + if f.EventType != nil { + if !got.eventTypeDone { + eventType, err := resolveViewEventType(ev) + if err != nil { + return false, err + } + got.eventType, got.eventTypeDone = eventType, true + } + if got.eventType != *f.EventType { + continue + } + } + if len(f.ContractID) > 0 { + if !got.contractIDDone { + cid, err := resolveViewContractID(ev) + if err != nil { + return false, err + } + got.contractID, got.contractIDDone = cid, true + } + if !bytes.Equal(got.contractID, f.ContractID) { + continue + } + } + if !f.TopicCount.isWildcard() { + if !got.topicCountDone { + n, err := resolveViewTopicCount(ev) + if err != nil { + return false, err + } + got.topicCount, got.topicCountDone = n, true + } + if !f.TopicCount.matches(got.topicCount) { + continue + } + } + matched := true + for i, want := range f.Topics { + if len(want) == 0 { + continue + } + if !got.topicsDone { + if err := collectTopicViewBytes(ev, plan, &got.topics); err != nil { + return false, err + } + got.topicsDone = true + } + g := got.topics[i] + if g == nil || !bytes.Equal(g, want) { + matched = false + break + } + } + if matched { + return true, nil + } + } + return false, nil +} + +func resolveViewEventType(ev xdr.ContractEventView) (xdr.ContractEventType, error) { + typeView, err := ev.Type() + if err != nil { + return 0, fmt.Errorf("events: post-filter view Type: %w", err) + } + eventType, err := typeView.Value() + if err != nil { + return 0, fmt.Errorf("events: post-filter view Type value: %w", err) + } + return eventType, nil +} + +// resolveViewTopics returns the event's Body.V0.Topics. ok is false for +// a body version that carries no topics at all. +func resolveViewTopics(ev xdr.ContractEventView) (xdr.ContractEventV0TopicsView, bool, error) { + body, err := ev.Body() + if err != nil { + return nil, false, fmt.Errorf("events: post-filter view Body: %w", err) + } + bodyV, err := body.V() + if err != nil { + return nil, false, fmt.Errorf("events: post-filter view Body.V: %w", err) + } + if bodyV != 0 { + return nil, false, nil + } + v0, err := body.V0() + if err != nil { + return nil, false, fmt.Errorf("events: post-filter view Body.V0: %w", err) + } + topics, err := v0.Topics() + if err != nil { + return nil, false, fmt.Errorf("events: post-filter view Body.V0.Topics: %w", err) + } + return topics, true, nil +} + +// resolveViewTopicCount returns how many topics the event carries, or +// -1 when it has no V0 body, which TopicCountFilter.matches rejects for +// every constraint. +func resolveViewTopicCount(ev xdr.ContractEventView) (int, error) { + topics, ok, err := resolveViewTopics(ev) + if err != nil || !ok { + return -1, err + } + count, err := topics.Count() + if err != nil { + return 0, fmt.Errorf("events: post-filter view Body.V0.Topics.Count: %w", err) + } + return count, nil +} + +// resolveViewContractID returns the event's ContractId aliased into the +// raw buffer, or nil when it has none. +func resolveViewContractID(ev xdr.ContractEventView) ([]byte, error) { + cidOpt, err := ev.ContractId() + if err != nil { + return nil, fmt.Errorf("events: post-filter view ContractId opt: %w", err) + } + cidView, present, err := cidOpt.Unwrap() + if err != nil { + return nil, fmt.Errorf("events: post-filter view ContractId unwrap: %w", err) + } + if !present { + return nil, nil + } + cid, err := cidView.Raw() + if err != nil { + return nil, fmt.Errorf("events: post-filter view ContractId raw: %w", err) + } + return cid, nil +} + +// collectTopicViewBytes walks the ContractEventView's Body.V0.Topics +// once linearly and captures each constrained position's .Raw() bytes +// into topicRaw. Stops after the highest constrained position so the +// walk is O(plan.maxTopicIdx+1) rather than the O(MaxTopicCount²) +// that calling .At(j) for each j would produce (ScVecView.At is a +// prefix walk under the hood). A body version with no topics leaves +// topicRaw zero (every constrained position will mismatch downstream). +func collectTopicViewBytes( + ev xdr.ContractEventView, + plan *filterPlan, + topicRaw *[protocol.MaxTopicCount][]byte, +) error { + if !plan.anyTopic { + return nil + } + topicsArr, ok, err := resolveViewTopics(ev) + if err != nil || !ok { + return err + } + i := 0 + for topic, ierr := range topicsArr.Iter() { + if ierr != nil { + return fmt.Errorf("events: post-filter view topic iter: %w", ierr) + } + if i > plan.maxTopicIdx || i >= protocol.MaxTopicCount { + break + } + if plan.needsTopic[i] { + rawBytes, err := topic.Raw() + if err != nil { + return fmt.Errorf("events: post-filter view topic[%d].Raw: %w", i, err) + } + topicRaw[i] = rawBytes + } + i++ + } + return nil +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/match_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/match_test.go index 81b4655a5..671bc2718 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/match_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/match_test.go @@ -1037,6 +1037,11 @@ func TestQuery_UnionOfTypeAndCountFilters(t *testing.T) { Filter{TopicCount: TopicCountFilter{Count: 0, Exact: true}})) } +// TestMatchesAnyFilterView_TypeAndCount covers the post-filter's type and +// topic-count checks. Both index families are exact, so a single-clause query +// never reaches them with a mismatch: they fire only when a union clause falls +// through to the next one, or when a term hash collides. Nothing else in the +// package covers them, and every other test passes with either check disabled. // Labels the tests repeat enough for goconst to insist on names. const ( evtAAB = "evt-a-ab" @@ -1045,6 +1050,42 @@ const ( labelC0 = "c-0" ) +func TestMatchesAnyFilterView_TypeAndCount(t *testing.T) { + // Only arity matters here, so both events carry the same topic value. + var cid xdr.ContractId + sym := xdr.ScSymbol("alpha") + topic := xdr.ScVal{Type: xdr.ScValTypeScvSymbol, Sym: &sym} + oneTopic := payloadFor(t, cid, "one-topic", topic).ContractEventBytes + twoTopics := payloadFor(t, cid, "two-topics", topic, topic).ContractEventBytes + + system, contract := xdr.ContractEventTypeSystem, xdr.ContractEventTypeContract + exactly1 := Filter{TopicCount: TopicCountFilter{Count: 1, Exact: true}} + exactly2 := Filter{TopicCount: TopicCountFilter{Count: 2, Exact: true}} + atLeast2 := Filter{TopicCount: TopicCountFilter{Count: 2}} + + for name, tc := range map[string]struct { + raw []byte + filter Filter + want bool + }{ + "wrong type rejected": {oneTopic, Filter{EventType: &system}, false}, + "right type accepted": {oneTopic, Filter{EventType: &contract}, true}, + "count above exact": {twoTopics, exactly1, false}, + "count below exact": {oneTopic, exactly2, false}, + "exact count accepted": {twoTopics, exactly2, true}, + "count below the minimum": {oneTopic, atLeast2, false}, + "at least count accepted": {twoTopics, atLeast2, true}, + } { + t.Run(name, func(t *testing.T) { + filters := []Filter{tc.filter} + plan := planFilters(filters) + got, err := matchesAnyFilterView(tc.raw, filters, &plan) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + // TestQuery_InvalidFilterRejected covers the values that would key a term no // event is indexed under, which would otherwise return nothing with no signal. func TestQuery_InvalidFilterRejected(t *testing.T) { diff --git a/cmd/stellar-rpc/internal/store/event_match.go b/cmd/stellar-rpc/internal/store/event_match.go deleted file mode 100644 index e985dabea..000000000 --- a/cmd/stellar-rpc/internal/store/event_match.go +++ /dev/null @@ -1,406 +0,0 @@ -package store - -import ( - "bytes" - "fmt" - - protocol "github.com/stellar/go-stellar-sdk/protocols/rpc" - "github.com/stellar/go-stellar-sdk/strkey" - "github.com/stellar/go-stellar-sdk/xdr" -) - -// EventFilter is one clause in the union of an events query. Within a -// clause every constrained field is AND-ed against the event; zero values -// are wildcards. Shared by the sqlite (rpcv1) and chunk (rpcv2) backends. -// -// Topics[i] constrains topic position i. -type EventFilter struct { - ContractID []byte - Topics [protocol.MaxTopicCount][]byte - // EventType constrains the event's type. A nil pointer is a - // wildcard. A filter accepting several types is several filters. - EventType *xdr.ContractEventType - // TopicCount constrains how many topics the event carries. - TopicCount TopicCountFilter -} - -// TopicCountFilter constrains an event's topic count to at least -// Count, or to exactly Count when Exact is set. Its zero value, "at -// least zero", is the wildcard. -// -// This is getEvents v1's topic arity: a topic filter ending in "**" -// matches events with at least as many topics as the filter names, and -// one that does not match events with exactly that many. -type TopicCountFilter struct { - Count int - Exact bool -} - -// IsWildcard reports whether f constrains nothing. -func (f TopicCountFilter) IsWildcard() bool { return f == TopicCountFilter{} } - -// Matches reports whether an event carrying n topics satisfies f. A -// negative n stands for an event with no V0 body, which carries no -// topics at all and satisfies no constraint. -func (f TopicCountFilter) Matches(n int) bool { - if n < 0 { - return false - } - if f.Exact { - return n == f.Count - } - return n >= f.Count -} - -// CompileV1EventFilters expands a validated v1 filter list into clauses. The -// OR dimensions within one v1 filter (contract ids, topic filters) multiply -// out, one clause per combination: at most 5 filters x 5 contract ids -// x 5 topics = 125. A combination with no constraints matches every event, -// so the whole query collapses to match-all (nil). -func CompileV1EventFilters(in []protocol.EventFilter) ([]EventFilter, error) { - var out []EventFilter - for i := range in { - expanded, matchAll, err := expandV1Filter(&in[i]) - if err != nil { - return nil, err - } - if matchAll { - return nil, nil - } - out = append(out, expanded...) - } - return out, nil -} - -func expandV1Filter(f *protocol.EventFilter) ([]EventFilter, bool, error) { - // A validated type set holds only contract and system, the only types - // either backend ingests: naming both constrains nothing, naming one is - // one term. Either way the type never multiplies the expansion. - var eventType *xdr.ContractEventType - if len(f.EventType) == 1 { - name := f.EventType.Keys()[0] - typ, ok := protocol.GetEventTypeXDRFromEventType()[name] - if !ok { - // Valid admits only contract and system, so anything else is a - // handler bug, not client input. - return nil, false, fmt.Errorf("unsupported event type %q", name) - } - eventType = &typ - } - contracts := [][]byte{nil} - if len(f.ContractIDs) > 0 { - contracts = make([][]byte, 0, len(f.ContractIDs)) - for _, id := range f.ContractIDs { - raw, err := strkey.Decode(strkey.VersionByteContract, id) - if err != nil { - // Unreachable: req.Valid decoded it already. The message is - // the v1 handler's backstop wording. - return nil, false, fmt.Errorf("invalid contract ID: %v", id) - } - contracts = append(contracts, raw) - } - } - shapes := []topicShape{{}} - if len(f.Topics) > 0 { - shapes = make([]topicShape, 0, len(f.Topics)) - for _, tf := range f.Topics { - shape, err := topicShapeOf(tf) - if err != nil { - return nil, false, err - } - shapes = append(shapes, shape) - } - } - - out := make([]EventFilter, 0, len(contracts)*len(shapes)) - for _, cid := range contracts { - for _, sh := range shapes { - flt := EventFilter{ContractID: cid, EventType: eventType, Topics: sh.topics, TopicCount: sh.count} - if isMatchAll(&flt) { - return nil, true, nil - } - out = append(out, flt) - } - } - return out, false, nil -} - -// topicShape is one v1 TopicFilter in clause terms: the pinned positional -// values plus the arity constraint. N segments match exactly N topics; a -// trailing "**" relaxes that to at least the prefix, and "at least zero" is -// the zero value, no constraint. -type topicShape struct { - topics [protocol.MaxTopicCount][]byte - count TopicCountFilter -} - -func topicShapeOf(tf protocol.TopicFilter) (topicShape, error) { - segs := tf - shape := topicShape{count: TopicCountFilter{Count: len(tf), Exact: true}} - if n := len(tf); n > 0 && tf[n-1].Wildcard != nil && *tf[n-1].Wildcard == protocol.WildCardZeroOrMore { - // "At least zero" is the wildcard, which is the zero value. - segs = tf[:n-1] - shape.count = TopicCountFilter{Count: len(segs)} - } - for i, s := range segs { - // "*" is any value, and the position still exists via the count. - // A segment with neither value nor wildcard is skipped rather than - // dereferenced. - if s.Wildcard != nil || s.ScVal == nil { - continue - } - raw, err := s.ScVal.MarshalBinary() - if err != nil { - return topicShape{}, fmt.Errorf("failed to marshal segment: %w", err) - } - shape.topics[i] = raw - } - return shape, nil -} - -func isMatchAll(f *EventFilter) bool { - if f.EventType != nil || len(f.ContractID) > 0 || f.TopicCount != (TopicCountFilter{}) { - return false - } - for i := range f.Topics { - if len(f.Topics[i]) > 0 { - return false - } - } - return true -} - -// FilterPlan caches per-query info computed once by PlanFilters and -// consumed by MatchesAnyFilterView: the topic positions any clause -// constrains and the highest constrained position (caps the topic walk). -type FilterPlan struct { - anyTopic bool - maxTopicIdx int // -1 if no clause constrains any topic - needsTopic [protocol.MaxTopicCount]bool -} - -// PlanFilters computes the FilterPlan for filters. -func PlanFilters(filters []EventFilter) FilterPlan { - plan := FilterPlan{maxTopicIdx: -1} - for fi := range filters { - f := &filters[fi] - for i, want := range f.Topics { - if len(want) == 0 { - continue - } - plan.needsTopic[i] = true - plan.anyTopic = true - if i > plan.maxTopicIdx { - plan.maxTopicIdx = i - } - } - } - return plan -} - -// eventFields holds the decoded fields MatchesAnyFilterView pulls out -// of one event, each resolved at most once and only when some clause -// asks for it. -type eventFields struct { - contractID []byte - contractIDDone bool - - eventType xdr.ContractEventType - eventTypeDone bool - - topicCount int - topicCountDone bool - - topics [protocol.MaxTopicCount][]byte - topicsDone bool -} - -// MatchesAnyFilterView reports whether the event behind ev satisfies at -// least one clause. It resolves each field a clause constrains via view -// navigation, byte-comparing aliased .Raw() slices against the clause -// values. Zero per-event allocation: every byte slice involved aliases -// into ev. -// -// Fields are resolved at most once and only when a clause asks for -// them, cheapest first: events that fail every clause's type or -// ContractId check never trigger the topic walk, and events that pass -// do exactly one linear walk over Topics up to the highest constrained -// position. -// -//nolint:gocognit,cyclop // linear clause loop with per-field lazy caches; helpers would fragment the invariant -func MatchesAnyFilterView(ev xdr.ContractEventView, filters []EventFilter, plan *FilterPlan) (bool, error) { - var got eventFields - - for fi := range filters { - f := &filters[fi] - if f.EventType != nil { - if !got.eventTypeDone { - eventType, err := resolveViewEventType(ev) - if err != nil { - return false, err - } - got.eventType, got.eventTypeDone = eventType, true - } - if got.eventType != *f.EventType { - continue - } - } - if len(f.ContractID) > 0 { - if !got.contractIDDone { - cid, err := resolveViewContractID(ev) - if err != nil { - return false, err - } - got.contractID, got.contractIDDone = cid, true - } - if !bytes.Equal(got.contractID, f.ContractID) { - continue - } - } - if !f.TopicCount.IsWildcard() { - if !got.topicCountDone { - n, err := resolveViewTopicCount(ev) - if err != nil { - return false, err - } - got.topicCount, got.topicCountDone = n, true - } - if !f.TopicCount.Matches(got.topicCount) { - continue - } - } - matched := true - for i, want := range f.Topics { - if len(want) == 0 { - continue - } - if !got.topicsDone { - if err := collectTopicViewBytes(ev, plan, &got.topics); err != nil { - return false, err - } - got.topicsDone = true - } - g := got.topics[i] - if g == nil || !bytes.Equal(g, want) { - matched = false - break - } - } - if matched { - return true, nil - } - } - return false, nil -} - -func resolveViewEventType(ev xdr.ContractEventView) (xdr.ContractEventType, error) { - typeView, err := ev.Type() - if err != nil { - return 0, fmt.Errorf("events: match Type: %w", err) - } - eventType, err := typeView.Value() - if err != nil { - return 0, fmt.Errorf("events: match Type value: %w", err) - } - return eventType, nil -} - -// resolveViewTopics returns the event's Body.V0.Topics. ok is false for -// a body version that carries no topics at all. -func resolveViewTopics(ev xdr.ContractEventView) (xdr.ContractEventV0TopicsView, bool, error) { - body, err := ev.Body() - if err != nil { - return nil, false, fmt.Errorf("events: match Body: %w", err) - } - bodyV, err := body.V() - if err != nil { - return nil, false, fmt.Errorf("events: match Body.V: %w", err) - } - if bodyV != 0 { - return nil, false, nil - } - v0, err := body.V0() - if err != nil { - return nil, false, fmt.Errorf("events: match Body.V0: %w", err) - } - topics, err := v0.Topics() - if err != nil { - return nil, false, fmt.Errorf("events: match Body.V0.Topics: %w", err) - } - return topics, true, nil -} - -// resolveViewTopicCount returns how many topics the event carries, or -// -1 when it has no V0 body, which TopicCountFilter.Matches rejects for -// every constraint. -func resolveViewTopicCount(ev xdr.ContractEventView) (int, error) { - topics, ok, err := resolveViewTopics(ev) - if err != nil || !ok { - return -1, err - } - count, err := topics.Count() - if err != nil { - return 0, fmt.Errorf("events: match Body.V0.Topics.Count: %w", err) - } - return count, nil -} - -// resolveViewContractID returns the event's ContractId aliased into the -// raw buffer, or nil when it has none. -func resolveViewContractID(ev xdr.ContractEventView) ([]byte, error) { - cidOpt, err := ev.ContractId() - if err != nil { - return nil, fmt.Errorf("events: match ContractId opt: %w", err) - } - cidView, present, err := cidOpt.Unwrap() - if err != nil { - return nil, fmt.Errorf("events: match ContractId unwrap: %w", err) - } - if !present { - return nil, nil - } - cid, err := cidView.Raw() - if err != nil { - return nil, fmt.Errorf("events: match ContractId raw: %w", err) - } - return cid, nil -} - -// collectTopicViewBytes walks the ContractEventView's Body.V0.Topics -// once linearly and captures each constrained position's .Raw() bytes -// into topicRaw. Stops after the highest constrained position so the -// walk is O(plan.maxTopicIdx+1) rather than the O(MaxTopicCount²) -// that calling .At(j) for each j would produce (ScVecView.At is a -// prefix walk under the hood). A body version with no topics leaves -// topicRaw zero (every constrained position will mismatch downstream). -func collectTopicViewBytes( - ev xdr.ContractEventView, - plan *FilterPlan, - topicRaw *[protocol.MaxTopicCount][]byte, -) error { - if !plan.anyTopic { - return nil - } - topicsArr, ok, err := resolveViewTopics(ev) - if err != nil || !ok { - return err - } - i := 0 - for topic, ierr := range topicsArr.Iter() { - if ierr != nil { - return fmt.Errorf("events: match topic iter: %w", ierr) - } - if i > plan.maxTopicIdx || i >= protocol.MaxTopicCount { - break - } - if plan.needsTopic[i] { - rawBytes, err := topic.Raw() - if err != nil { - return fmt.Errorf("events: match topic[%d].Raw: %w", i, err) - } - topicRaw[i] = rawBytes - } - i++ - } - return nil -} diff --git a/cmd/stellar-rpc/internal/store/event_match_test.go b/cmd/stellar-rpc/internal/store/event_match_test.go deleted file mode 100644 index bc7afac1e..000000000 --- a/cmd/stellar-rpc/internal/store/event_match_test.go +++ /dev/null @@ -1,230 +0,0 @@ -package store - -// Translation rules for the v1 filter model and the matcher checks that only -// the compiled clauses enforce. eventsapi's parity harness cross-checks the -// same rules end to end against the shared v1 handler. - -import ( - "bytes" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - protocol "github.com/stellar/go-stellar-sdk/protocols/rpc" - "github.com/stellar/go-stellar-sdk/strkey" - "github.com/stellar/go-stellar-sdk/xdr" -) - -func testContractRaw(b byte) []byte { return bytes.Repeat([]byte{b}, 32) } - -func testContractStrkey(t *testing.T, b byte) string { - t.Helper() - s, err := strkey.Encode(strkey.VersionByteContract, testContractRaw(b)) - require.NoError(t, err) - return s -} - -// symbolScVal returns an ScVal symbol and its canonical XDR bytes, the form clauses carry. -func symbolScVal(t *testing.T, s string) (xdr.ScVal, []byte) { - t.Helper() - val := xdr.ScVal{Type: xdr.ScValTypeScvSymbol, Sym: (*xdr.ScSymbol)(&s)} - raw, err := val.MarshalBinary() - require.NoError(t, err) - return val, raw -} - -func eventTypePtr(v xdr.ContractEventType) *xdr.ContractEventType { return new(v) } - -// contractEventBytes marshals a V0 ContractEvent with the given contract id, type and topics. -func contractEventBytes(t *testing.T, cid []byte, typ xdr.ContractEventType, topics ...xdr.ScVal) []byte { - t.Helper() - sym := xdr.ScSymbol("data") - ev := xdr.ContractEvent{ - Type: typ, - Body: xdr.ContractEventBody{V: 0, V0: &xdr.ContractEventV0{ - Topics: topics, - Data: xdr.ScVal{Type: xdr.ScValTypeScvSymbol, Sym: &sym}, - }}, - } - if cid != nil { - var id xdr.ContractId - copy(id[:], cid) - ev.ContractId = &id - } - raw, err := ev.MarshalBinary() - require.NoError(t, err) - return raw -} - -//nolint:funlen // one table, one case per translation rule -func TestCompileV1EventFilters(t *testing.T) { - xferVal, xferRaw := symbolScVal(t, "xfer") - mintVal, mintRaw := symbolScVal(t, "mint") - aliceVal, aliceRaw := symbolScVal(t, "alice") - star, dstar := "*", "**" - seg := func(v xdr.ScVal) protocol.SegmentFilter { return protocol.SegmentFilter{ScVal: &v} } - wild := func(w string) protocol.SegmentFilter { return protocol.SegmentFilter{Wildcard: &w} } - contractType := protocol.EventTypeSet{protocol.EventTypeContract: nil} - bothTypes := protocol.EventTypeSet{ - protocol.EventTypeContract: nil, protocol.EventTypeSystem: nil, - } - - for name, tc := range map[string]struct { - in []protocol.EventFilter - want []EventFilter // nil means the whole query matches every event - }{ - "contract id alone": { - in: []protocol.EventFilter{{ContractIDs: []string{testContractStrkey(t, 0xAA)}}}, - want: []EventFilter{{ContractID: testContractRaw(0xAA)}}, - }, - "single type": { - in: []protocol.EventFilter{{EventType: contractType}}, - want: []EventFilter{{EventType: eventTypePtr(xdr.ContractEventTypeContract)}}, - }, - // Valid admits only contract and system, the only types either backend - // ingests, so a set of both constrains nothing. - "type set of both is a wildcard": { - in: []protocol.EventFilter{{EventType: bothTypes}}, - want: nil, - }, - "type set of both with a contract id keeps only the contract id": { - in: []protocol.EventFilter{{ - EventType: bothTypes, ContractIDs: []string{testContractStrkey(t, 0xAA)}, - }}, - want: []EventFilter{{ContractID: testContractRaw(0xAA)}}, - }, - // N segments without a trailing "**" match exactly N topics. - "one-segment topic is exact arity one": { - in: []protocol.EventFilter{{Topics: []protocol.TopicFilter{{seg(xferVal)}}}}, - want: []EventFilter{{ - Topics: [protocol.MaxTopicCount][]byte{xferRaw}, - TopicCount: TopicCountFilter{Count: 1, Exact: true}, - }}, - }, - // A trailing "**" keeps the prefix constraints and relaxes the - // arity to at-least-prefix. - "trailing double-star is at-least arity": { - in: []protocol.EventFilter{{Topics: []protocol.TopicFilter{ - {seg(xferVal), wild(dstar)}, - }}}, - want: []EventFilter{{ - Topics: [protocol.MaxTopicCount][]byte{xferRaw}, - TopicCount: TopicCountFilter{Count: 1, Exact: false}, - }}, - }, - // "*" constrains no value, only the position's existence via arity. - "star segment is positionless exact arity": { - in: []protocol.EventFilter{{Topics: []protocol.TopicFilter{{wild(star)}}}}, - want: []EventFilter{{TopicCount: TopicCountFilter{Count: 1, Exact: true}}}, - }, - "star then value pins position one": { - in: []protocol.EventFilter{{Topics: []protocol.TopicFilter{ - {wild(star), seg(aliceVal)}, - }}}, - want: []EventFilter{{ - Topics: [protocol.MaxTopicCount][]byte{nil, aliceRaw}, - TopicCount: TopicCountFilter{Count: 2, Exact: true}, - }}, - }, - // "**" alone is at-least-zero: no constraint at all, so the branch - // matches everything and the query collapses to match-all. - "double-star alone collapses to match-all": { - in: []protocol.EventFilter{{Topics: []protocol.TopicFilter{{wild(dstar)}}}}, - want: nil, - }, - // OR dimensions multiply: type x contractIds x topics. - "cross-product expansion": { - in: []protocol.EventFilter{{ - EventType: contractType, - ContractIDs: []string{ - testContractStrkey(t, 0xAA), testContractStrkey(t, 0xBB), - }, - Topics: []protocol.TopicFilter{{seg(xferVal)}, {seg(mintVal)}}, - }}, - want: []EventFilter{ - { - EventType: eventTypePtr(xdr.ContractEventTypeContract), - ContractID: testContractRaw(0xAA), - Topics: [protocol.MaxTopicCount][]byte{xferRaw}, - TopicCount: TopicCountFilter{Count: 1, Exact: true}, - }, - { - EventType: eventTypePtr(xdr.ContractEventTypeContract), - ContractID: testContractRaw(0xAA), - Topics: [protocol.MaxTopicCount][]byte{mintRaw}, - TopicCount: TopicCountFilter{Count: 1, Exact: true}, - }, - { - EventType: eventTypePtr(xdr.ContractEventTypeContract), - ContractID: testContractRaw(0xBB), - Topics: [protocol.MaxTopicCount][]byte{xferRaw}, - TopicCount: TopicCountFilter{Count: 1, Exact: true}, - }, - { - EventType: eventTypePtr(xdr.ContractEventTypeContract), - ContractID: testContractRaw(0xBB), - Topics: [protocol.MaxTopicCount][]byte{mintRaw}, - TopicCount: TopicCountFilter{Count: 1, Exact: true}, - }, - }, - }, - "separate filters stay separate branches": { - in: []protocol.EventFilter{ - {ContractIDs: []string{testContractStrkey(t, 0xAA)}}, - {Topics: []protocol.TopicFilter{{seg(mintVal)}}}, - }, - want: []EventFilter{ - {ContractID: testContractRaw(0xAA)}, - { - Topics: [protocol.MaxTopicCount][]byte{mintRaw}, - TopicCount: TopicCountFilter{Count: 1, Exact: true}, - }, - }, - }, - } { - t.Run(name, func(t *testing.T) { - got, err := CompileV1EventFilters(tc.in) - require.NoError(t, err) - require.ElementsMatch(t, tc.want, got) - }) - } -} - -// TestMatchesAnyFilterView_TypeAndCount covers the matcher's type and -// topic-count checks. rpcv2's exact indexes only reach them when a union -// clause falls through or a term hash collides, so nothing else covers them. -func TestMatchesAnyFilterView_TypeAndCount(t *testing.T) { - // Only arity matters here, so both events carry the same topic value. - cid := testContractRaw(0x00) - topic, _ := symbolScVal(t, "alpha") - oneTopic := contractEventBytes(t, cid, xdr.ContractEventTypeContract, topic) - twoTopics := contractEventBytes(t, cid, xdr.ContractEventTypeContract, topic, topic) - - system, contract := xdr.ContractEventTypeSystem, xdr.ContractEventTypeContract - exactly1 := EventFilter{TopicCount: TopicCountFilter{Count: 1, Exact: true}} - exactly2 := EventFilter{TopicCount: TopicCountFilter{Count: 2, Exact: true}} - atLeast2 := EventFilter{TopicCount: TopicCountFilter{Count: 2}} - - for name, tc := range map[string]struct { - raw []byte - filter EventFilter - want bool - }{ - "wrong type rejected": {oneTopic, EventFilter{EventType: &system}, false}, - "right type accepted": {oneTopic, EventFilter{EventType: &contract}, true}, - "count above exact": {twoTopics, exactly1, false}, - "count below exact": {oneTopic, exactly2, false}, - "exact count accepted": {twoTopics, exactly2, true}, - "count below the minimum": {oneTopic, atLeast2, false}, - "at least count accepted": {twoTopics, atLeast2, true}, - } { - t.Run(name, func(t *testing.T) { - filters := []EventFilter{tc.filter} - plan := PlanFilters(filters) - got, err := MatchesAnyFilterView(xdr.ContractEventView(tc.raw), filters, &plan) - require.NoError(t, err) - assert.Equal(t, tc.want, got) - }) - } -} From b2bffae25f836fbdc8c2b79aa37025cb169eaaf2 Mon Sep 17 00:00:00 2001 From: Christian Jonas Date: Thu, 17 Sep 2026 15:01:22 -0400 Subject: [PATCH 21/22] error on an event type outside the enum instead of rendering an empty name --- .../internal/methods/get_events.go | 19 ++++++++++++------- .../internal/methods/get_events_test.go | 18 ++++++++++++++++++ .../internal/rpcv2/eventsapi/get_events_v2.go | 10 ++++------ 3 files changed, 34 insertions(+), 13 deletions(-) diff --git a/cmd/stellar-rpc/internal/methods/get_events.go b/cmd/stellar-rpc/internal/methods/get_events.go index 047f37ae4..13604da29 100644 --- a/cmd/stellar-rpc/internal/methods/get_events.go +++ b/cmd/stellar-rpc/internal/methods/get_events.go @@ -339,8 +339,13 @@ func eventInfo( return protocol.EventInfo{}, fmt.Errorf("ledger sequence %d exceeds supported range", cursor.Ledger) } + typeName, ok := eventTypeName(head.typ) + if !ok { + return protocol.EventInfo{}, fmt.Errorf("unknown XDR ContractEventType type: %d", head.typ) + } + info := protocol.EventInfo{ - EventType: eventTypeName(head.typ), + EventType: typeName, Ledger: int32(cursor.Ledger), LedgerClosedAt: ledgerClosedAt, ID: cursor.String(), @@ -373,17 +378,17 @@ func eventInfo( return info, nil } -// eventTypeName is protocol.GetEventTypeFromEventTypeXDR without the per-call map; "" for an unknown type. -func eventTypeName(t xdr.ContractEventType) string { +// eventTypeName is protocol.GetEventTypeFromEventTypeXDR without the per-call map; ok is false outside the enum. +func eventTypeName(t xdr.ContractEventType) (string, bool) { switch t { case xdr.ContractEventTypeSystem: - return protocol.EventTypeSystem + return protocol.EventTypeSystem, true case xdr.ContractEventTypeContract: - return protocol.EventTypeContract + return protocol.EventTypeContract, true case xdr.ContractEventTypeDiagnostic: - return protocol.EventTypeDiagnostic + return protocol.EventTypeDiagnostic, true default: - return "" + return "", false } } diff --git a/cmd/stellar-rpc/internal/methods/get_events_test.go b/cmd/stellar-rpc/internal/methods/get_events_test.go index 2286905fe..8b2f6196f 100644 --- a/cmd/stellar-rpc/internal/methods/get_events_test.go +++ b/cmd/stellar-rpc/internal/methods/get_events_test.go @@ -1279,6 +1279,24 @@ func setupTwoContractEventsHandler(t *testing.T) (eventsRPCHandler, xdr.Contract }, contractA, contractB } +// TestEventTypeName pins the three wire names and that a type outside the enum is refused, not rendered empty. +func TestEventTypeName(t *testing.T) { + for want, typ := range map[string]xdr.ContractEventType{ + protocol.EventTypeSystem: xdr.ContractEventTypeSystem, + protocol.EventTypeContract: xdr.ContractEventTypeContract, + protocol.EventTypeDiagnostic: xdr.ContractEventTypeDiagnostic, + } { + got, ok := eventTypeName(typ) + require.True(t, ok, want) + assert.Equal(t, want, got) + } + + // The views reject this today; the guard is for an enum that gains a member. + got, ok := eventTypeName(xdr.ContractEventType(99)) + assert.False(t, ok, "a type outside the enum must not render") + assert.Empty(t, got) +} + // Filters are OR-ed together: a filter with no contract IDs matches events // from any contract, so it must not be starved by another filter's contract // ID restriction. 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 eecbdb7f2..b22a33391 100644 --- a/cmd/stellar-rpc/internal/rpcv2/eventsapi/get_events_v2.go +++ b/cmd/stellar-rpc/internal/rpcv2/eventsapi/get_events_v2.go @@ -468,13 +468,11 @@ func eventInfoV2(p *event.Payload, format string) (protocol.EventInfoV2, error) return protocol.EventInfoV2(info), nil } -// checkResponseEventType: ingest stores contract and system events only. +// checkResponseEventType: ingest stores contract and system events only, so only diagnostic is refused. func checkResponseEventType(name string) error { - switch name { - case protocol.EventTypeSystem, protocol.EventTypeContract: + if name != protocol.EventTypeDiagnostic { return nil - default: - return fmt.Errorf("rpcv2: stored event has type %q;"+ - " this endpoint serves contract and system events only", name) } + return fmt.Errorf("rpcv2: stored event has type %q;"+ + " this endpoint serves contract and system events only", name) } From af88dd99ef2e9b074d4c7b5e5128195697c3f0ab Mon Sep 17 00:00:00 2001 From: Christian Jonas Date: Thu, 17 Sep 2026 15:20:52 -0400 Subject: [PATCH 22/22] fix loan doc: only the event view is borrowed, txHash is a copy --- cmd/stellar-rpc/internal/store/event.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/stellar-rpc/internal/store/event.go b/cmd/stellar-rpc/internal/store/event.go index e9dec25a9..9feaa008a 100644 --- a/cmd/stellar-rpc/internal/store/event.go +++ b/cmd/stellar-rpc/internal/store/event.go @@ -24,9 +24,9 @@ type TopicFilter []TopicCondition // matches a row, the DB event is considered a candidate for further filtering. type TopicFilters []TopicFilter -// ViewScanFunction visits one event. eventView and txHash are on loan for the -// call's duration, so copy anything that must persist. Returning false stops -// the scan and an error aborts it. +// ViewScanFunction visits one event. eventView aliases the backend's row buffer and is valid +// only for the call, so copy anything that must persist. txHash points at a per-row copy and +// is free to keep. Returning false stops the scan and an error aborts it. type ViewScanFunction func( eventView xdr.DiagnosticEventView, cursor protocol.Cursor,