Skip to content

Commit d3f5c4c

Browse files
authored
perf(indexer): extract contract events directly from ledger meta (#639)
* perf(indexer): extract contract events from meta directly, skipping envelope hashing ExtractContractEventsForLedger reads contract events straight from the decoded LedgerCloseMeta instead of building a LedgerTransactionReader (which re-hashes every transaction envelope). Byte-for-byte equivalent to the reader path; a differential test on real pubnet fixtures is the merge gate. Sliced as-is from PR #622 for independent review. * test(indexer): broaden ExtractContractEventsForLedger equivalence corpus Validate the meta-only contract-event walk against the reader across older history, and drop the now-unused params: - Add wrapper-V1 (Protocol 20-22) fixtures carrying real Soroban events; the prior corpus was entirely wrapper-V2. The pubnet data lake re-encodes all history to TransactionMetaV4, so the LedgerCloseMeta wrapper version (which CountTransactions/TransactionResultPair/TxApplyProcessing switch on) is the real "older history" axis, not the meta version. - Slim the wrapper-V2 corpus to one fixture per distinct transaction shape. - Add synthetic tests for the V3 SorobanMeta read, fee-bump inner-result unwrap, and footprint-op skip (shapes the lake cannot provide as real data). - Add a guard asserting the corpus keeps both wrapper versions with events. - Drop the unused ctx/networkPassphrase params from ExtractContractEventsForLedger.
1 parent 8ace241 commit d3f5c4c

9 files changed

Lines changed: 319 additions & 20 deletions

internal/indexer/indexer.go

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -370,39 +370,48 @@ func GetLedgerTransactions(ctx context.Context, networkPassphrase string, ledger
370370
return transactions, nil
371371
}
372372

373-
// ExtractContractEventsForLedger walks a ledger's transactions and returns
374-
// the (txIdx, opIdx) → []ContractEvent map that the full indexer pipeline
375-
// would have pushed into the buffer. It performs no participant tracking,
376-
// no state-change processing, and no DB I/O — it's the minimal subset that
377-
// protocol-migrate needs to drive ProtocolProcessor.ProcessLedger without
378-
// running the full indexer.
373+
// ExtractContractEventsForLedger walks a ledger's transactions directly from the
374+
// decoded LedgerCloseMeta and returns the (txIdx, opIdx) → []ContractEvent map
375+
// that the full indexer pipeline would have pushed into the buffer. For each
376+
// transaction index i it reads the result pair, filters operations by their
377+
// result Tr type, and reads events from TxApplyProcessing(i) — without building
378+
// a LedgerTransactionReader, which would re-hash every transaction envelope just
379+
// to pair envelopes with metas we never read here. It is therefore a pure
380+
// function of the decoded ledger and needs neither a context nor the network
381+
// passphrase.
382+
//
383+
// The output is identical to the reader-based path; that equivalence is the
384+
// merge gate (see extractContractEventsViaReader and
385+
// TestExtractContractEventsForLedger_EquivalenceOnRealLedgers).
379386
//
380387
// Only events from successful transactions are returned, matching the live
381388
// indexer's filter in processTransaction.
382-
func ExtractContractEventsForLedger(ctx context.Context, networkPassphrase string, ledgerMeta xdr.LedgerCloseMeta) (map[ContractEventKey][]xdr.ContractEvent, error) {
383-
transactions, err := GetLedgerTransactions(ctx, networkPassphrase, ledgerMeta)
384-
if err != nil {
385-
return nil, fmt.Errorf("getting transactions for ledger %d: %w", ledgerMeta.LedgerSequence(), err)
386-
}
387-
389+
func ExtractContractEventsForLedger(ledgerMeta xdr.LedgerCloseMeta) (map[ContractEventKey][]xdr.ContractEvent, error) {
388390
out := make(map[ContractEventKey][]xdr.ContractEvent)
389-
for _, tx := range transactions {
390-
if !tx.Result.Successful() {
391+
for i := 0; i < ledgerMeta.CountTransactions(); i++ {
392+
result := ledgerMeta.TransactionResultPair(i).Result
393+
if !result.Successful() {
394+
continue
395+
}
396+
opResults, ok := result.OperationResults()
397+
if !ok {
391398
continue
392399
}
393-
for opIdx, op := range tx.Envelope.Operations() {
394-
if op.Body.Type != xdr.OperationTypeInvokeHostFunction {
400+
meta := ledgerMeta.TxApplyProcessing(i)
401+
for opIdx, opr := range opResults {
402+
tr, trOK := opr.GetTr()
403+
if !trOK || tr.Type != xdr.OperationTypeInvokeHostFunction {
395404
continue
396405
}
397-
events, evErr := tx.GetContractEventsForOperation(uint32(opIdx))
406+
events, evErr := meta.GetContractEventsForOperation(uint32(opIdx))
398407
if evErr != nil {
399408
return nil, fmt.Errorf("extracting contract events for ledger %d tx %d op %d: %w",
400-
ledgerMeta.LedgerSequence(), tx.Index, opIdx, evErr)
409+
ledgerMeta.LedgerSequence(), i+1, opIdx, evErr)
401410
}
402411
if len(events) == 0 {
403412
continue
404413
}
405-
out[ContractEventKey{TxIdx: tx.Index, OpIdx: uint32(opIdx)}] = events
414+
out[ContractEventKey{TxIdx: uint32(i + 1), OpIdx: uint32(opIdx)}] = events
406415
}
407416
}
408417
return out, nil

internal/indexer/indexer_test.go

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@
22
package indexer
33

44
import (
5+
"compress/gzip"
56
"context"
67
"errors"
8+
"fmt"
9+
"io"
10+
"os"
11+
"path/filepath"
712
"runtime"
813
"testing"
914

@@ -975,3 +980,287 @@ func TestIndexer_GetLedgerTransactions(t *testing.T) {
975980
})
976981
}
977982
}
983+
984+
// extractContractEventsViaReader is the reader-based reference implementation of
985+
// ExtractContractEventsForLedger, kept as the oracle for the differential
986+
// equivalence test. It constructs a LedgerTransactionReader (which re-hashes
987+
// every transaction envelope) and reads events through the resulting
988+
// LedgerTransaction values. The production function must produce output equal to
989+
// this oracle for every committed ledger fixture.
990+
func extractContractEventsViaReader(ctx context.Context, networkPassphrase string, ledgerMeta xdr.LedgerCloseMeta) (map[ContractEventKey][]xdr.ContractEvent, error) {
991+
transactions, err := GetLedgerTransactions(ctx, networkPassphrase, ledgerMeta)
992+
if err != nil {
993+
return nil, fmt.Errorf("getting transactions for ledger %d: %w", ledgerMeta.LedgerSequence(), err)
994+
}
995+
996+
out := make(map[ContractEventKey][]xdr.ContractEvent)
997+
for _, tx := range transactions {
998+
if !tx.Result.Successful() {
999+
continue
1000+
}
1001+
for opIdx, op := range tx.Envelope.Operations() {
1002+
if op.Body.Type != xdr.OperationTypeInvokeHostFunction {
1003+
continue
1004+
}
1005+
events, evErr := tx.GetContractEventsForOperation(uint32(opIdx))
1006+
if evErr != nil {
1007+
return nil, fmt.Errorf("extracting contract events for ledger %d tx %d op %d: %w",
1008+
ledgerMeta.LedgerSequence(), tx.Index, opIdx, evErr)
1009+
}
1010+
if len(events) == 0 {
1011+
continue
1012+
}
1013+
out[ContractEventKey{TxIdx: tx.Index, OpIdx: uint32(opIdx)}] = events
1014+
}
1015+
}
1016+
return out, nil
1017+
}
1018+
1019+
// loadLedgerFixture reads a gzip-compressed XDR LedgerCloseMeta from testdata/.
1020+
//
1021+
// Fixtures are pubnet ledgers from the public data lake (bucket
1022+
// aws-public-blockchain/v1.1/stellar/ledgers/pubnet, us-east-2). The lake re-encodes
1023+
// all history to TransactionMetaV4, so every fixture — like everything production
1024+
// reads — is V4, even ledgers that closed under older protocols. What does vary is
1025+
// the LedgerCloseMeta wrapper version (V1 for Protocol 20-22, V2 for 23+), which the
1026+
// walk switches on, so the corpus keeps both, with a mix of tx shapes (plain and
1027+
// fee-bumped invocations, failed txs, multi-event ledgers). The V3 meta branch isn't
1028+
// in the lake and is covered by synthetic tests.
1029+
//
1030+
// To add fixtures: build ingest.NewLedgerBackend (datastore + PublicNetworkPassphrase),
1031+
// then per sequence call GetLedger, MarshalBinary, gzip, and write
1032+
// testdata/ledger-<seq>.xdr.gz. Keep both wrapper versions represented.
1033+
func loadLedgerFixture(t *testing.T, path string) xdr.LedgerCloseMeta {
1034+
t.Helper()
1035+
f, err := os.Open(path)
1036+
require.NoError(t, err)
1037+
defer f.Close()
1038+
1039+
gz, err := gzip.NewReader(f)
1040+
require.NoError(t, err)
1041+
defer gz.Close()
1042+
1043+
raw, err := io.ReadAll(gz)
1044+
require.NoError(t, err)
1045+
1046+
var lcm xdr.LedgerCloseMeta
1047+
require.NoError(t, lcm.UnmarshalBinary(raw))
1048+
return lcm
1049+
}
1050+
1051+
func TestExtractContractEventsForLedger_EquivalenceOnRealLedgers(t *testing.T) {
1052+
ctx := context.Background()
1053+
1054+
paths, err := filepath.Glob("testdata/*.xdr.gz")
1055+
require.NoError(t, err)
1056+
require.NotEmpty(t, paths, "no ledger fixtures under testdata/ — regenerate per the loadLedgerFixture recipe")
1057+
1058+
for _, path := range paths {
1059+
t.Run(filepath.Base(path), func(t *testing.T) {
1060+
lcm := loadLedgerFixture(t, path)
1061+
1062+
want, err := extractContractEventsViaReader(ctx, network.PublicNetworkPassphrase, lcm)
1063+
require.NoError(t, err)
1064+
1065+
got, err := ExtractContractEventsForLedger(lcm)
1066+
require.NoError(t, err)
1067+
1068+
require.Equal(t, want, got)
1069+
})
1070+
}
1071+
}
1072+
1073+
// newSyntheticLedgerCloseMeta builds a minimal single-transaction V0
1074+
// LedgerCloseMeta carrying a successful result with the given operation results
1075+
// and apply meta. It omits the TxSet/envelopes: the meta-only extractor never
1076+
// reads them, so this isolates op-result + apply-meta behavior that real
1077+
// fixtures can't produce on demand.
1078+
func newSyntheticLedgerCloseMeta(seq uint32, opResults []xdr.OperationResult, applyMeta xdr.TransactionMeta) xdr.LedgerCloseMeta {
1079+
results := opResults
1080+
return newSyntheticLedgerCloseMetaWithResult(seq, xdr.TransactionResult{
1081+
Result: xdr.TransactionResultResult{
1082+
Code: xdr.TransactionResultCodeTxSuccess,
1083+
Results: &results,
1084+
},
1085+
}, applyMeta)
1086+
}
1087+
1088+
// newSyntheticLedgerCloseMetaWithResult is newSyntheticLedgerCloseMeta with a
1089+
// full TransactionResult, for modelling fee-bump (inner-result) shapes.
1090+
func newSyntheticLedgerCloseMetaWithResult(seq uint32, result xdr.TransactionResult, applyMeta xdr.TransactionMeta) xdr.LedgerCloseMeta {
1091+
return xdr.LedgerCloseMeta{
1092+
V: 0,
1093+
V0: &xdr.LedgerCloseMetaV0{
1094+
LedgerHeader: xdr.LedgerHeaderHistoryEntry{
1095+
Header: xdr.LedgerHeader{LedgerSeq: xdr.Uint32(seq)},
1096+
},
1097+
TxProcessing: []xdr.TransactionResultMeta{
1098+
{
1099+
Result: xdr.TransactionResultPair{Result: result},
1100+
TxApplyProcessing: applyMeta,
1101+
},
1102+
},
1103+
},
1104+
}
1105+
}
1106+
1107+
// TestExtractContractEventsForLedger_V4OperationsShorterThanResults proves the
1108+
// result-Tr.Type filter prevents indexing past the end of TransactionMetaV4.Operations.
1109+
// The V4 apply meta has ONE operation meta entry (index 0), but the tx has TWO
1110+
// operation results: [0] InvokeHostFunction, [1] Payment. A walk that indexed
1111+
// Operations by every result index would panic on Operations[1]; the filter
1112+
// skips the non-InvokeHostFunction result before it is ever used to index.
1113+
func TestExtractContractEventsForLedger_V4OperationsShorterThanResults(t *testing.T) {
1114+
applyMeta := xdr.TransactionMeta{
1115+
V: 4,
1116+
V4: &xdr.TransactionMetaV4{
1117+
Operations: []xdr.OperationMetaV2{
1118+
{Events: []xdr.ContractEvent{{Type: xdr.ContractEventTypeContract}}}, // index 0 only
1119+
},
1120+
},
1121+
}
1122+
opResults := []xdr.OperationResult{
1123+
{Code: xdr.OperationResultCodeOpInner, Tr: &xdr.OperationResultTr{Type: xdr.OperationTypeInvokeHostFunction}},
1124+
{Code: xdr.OperationResultCodeOpInner, Tr: &xdr.OperationResultTr{Type: xdr.OperationTypePayment}},
1125+
}
1126+
1127+
lcm := newSyntheticLedgerCloseMeta(100, opResults, applyMeta)
1128+
1129+
out, err := ExtractContractEventsForLedger(lcm)
1130+
require.NoError(t, err)
1131+
require.Len(t, out, 1)
1132+
events, ok := out[ContractEventKey{TxIdx: 1, OpIdx: 0}]
1133+
require.True(t, ok, "expected events at tx 1 op 0")
1134+
require.Len(t, events, 1)
1135+
}
1136+
1137+
// TestExtractContractEventsForLedger_UnknownMetaVersionErrors confirms the one
1138+
// intentional behavior change: an unsupported TransactionMeta version surfaces
1139+
// as a propagated error (fail loud) rather than being silently dropped.
1140+
func TestExtractContractEventsForLedger_UnknownMetaVersionErrors(t *testing.T) {
1141+
opResults := []xdr.OperationResult{
1142+
{Code: xdr.OperationResultCodeOpInner, Tr: &xdr.OperationResultTr{Type: xdr.OperationTypeInvokeHostFunction}},
1143+
}
1144+
applyMeta := xdr.TransactionMeta{V: 99} // unsupported; all arm pointers nil
1145+
1146+
lcm := newSyntheticLedgerCloseMeta(101, opResults, applyMeta)
1147+
1148+
_, err := ExtractContractEventsForLedger(lcm)
1149+
require.Error(t, err)
1150+
require.Contains(t, err.Error(), "unsupported TransactionMeta version")
1151+
}
1152+
1153+
// In V3 meta, Soroban events live at the transaction level (SorobanMeta.Events)
1154+
// and the op index is ignored. This checks the walk surfaces them at op 0.
1155+
//
1156+
// It's our only V3 coverage: the data lake re-encodes all history to V4, so a real
1157+
// V3 fixture can't be fetched.
1158+
func TestExtractContractEventsForLedger_V3SorobanMetaEvents(t *testing.T) {
1159+
ev1 := xdr.ContractEvent{Type: xdr.ContractEventTypeContract}
1160+
ev2 := xdr.ContractEvent{Type: xdr.ContractEventTypeSystem}
1161+
applyMeta := xdr.TransactionMeta{
1162+
V: 3,
1163+
V3: &xdr.TransactionMetaV3{
1164+
SorobanMeta: &xdr.SorobanTransactionMeta{Events: []xdr.ContractEvent{ev1, ev2}},
1165+
},
1166+
}
1167+
opResults := []xdr.OperationResult{
1168+
{Code: xdr.OperationResultCodeOpInner, Tr: &xdr.OperationResultTr{Type: xdr.OperationTypeInvokeHostFunction}},
1169+
}
1170+
lcm := newSyntheticLedgerCloseMeta(103, opResults, applyMeta)
1171+
1172+
out, err := ExtractContractEventsForLedger(lcm)
1173+
require.NoError(t, err)
1174+
require.Len(t, out, 1)
1175+
got, ok := out[ContractEventKey{TxIdx: 1, OpIdx: 0}]
1176+
require.True(t, ok, "expected events at tx 1 op 0")
1177+
require.Equal(t, []xdr.ContractEvent{ev1, ev2}, got)
1178+
}
1179+
1180+
// A fee-bump's operation results live in its inner transaction. This checks the
1181+
// walk unwraps to those inner results instead of reading the empty outer result.
1182+
// Real fee-bumped invocations are also in the fixture corpus.
1183+
func TestExtractContractEventsForLedger_FeeBumpUnwrapsInnerResults(t *testing.T) {
1184+
ev := xdr.ContractEvent{Type: xdr.ContractEventTypeContract}
1185+
applyMeta := xdr.TransactionMeta{
1186+
V: 3,
1187+
V3: &xdr.TransactionMetaV3{
1188+
SorobanMeta: &xdr.SorobanTransactionMeta{Events: []xdr.ContractEvent{ev}},
1189+
},
1190+
}
1191+
innerOpResults := []xdr.OperationResult{
1192+
{Code: xdr.OperationResultCodeOpInner, Tr: &xdr.OperationResultTr{Type: xdr.OperationTypeInvokeHostFunction}},
1193+
}
1194+
feeBump := xdr.TransactionResult{
1195+
Result: xdr.TransactionResultResult{
1196+
Code: xdr.TransactionResultCodeTxFeeBumpInnerSuccess,
1197+
InnerResultPair: &xdr.InnerTransactionResultPair{
1198+
Result: xdr.InnerTransactionResult{
1199+
Result: xdr.InnerTransactionResultResult{
1200+
Code: xdr.TransactionResultCodeTxSuccess,
1201+
Results: &innerOpResults,
1202+
},
1203+
},
1204+
},
1205+
},
1206+
}
1207+
lcm := newSyntheticLedgerCloseMetaWithResult(104, feeBump, applyMeta)
1208+
1209+
out, err := ExtractContractEventsForLedger(lcm)
1210+
require.NoError(t, err)
1211+
require.Len(t, out, 1, "fee-bump inner op results must be unwrapped")
1212+
got, ok := out[ContractEventKey{TxIdx: 1, OpIdx: 0}]
1213+
require.True(t, ok)
1214+
require.Equal(t, []xdr.ContractEvent{ev}, got)
1215+
}
1216+
1217+
// RestoreFootprint and ExtendFootprintTtl ops carry no contract events, so the
1218+
// walk should skip them and return events only for the InvokeHostFunction op.
1219+
// Done synthetically since these ops weren't found in the scanned pubnet ledgers.
1220+
func TestExtractContractEventsForLedger_FootprintOpsSkipped(t *testing.T) {
1221+
applyMeta := xdr.TransactionMeta{
1222+
V: 4,
1223+
V4: &xdr.TransactionMetaV4{
1224+
Operations: []xdr.OperationMetaV2{
1225+
{Events: []xdr.ContractEvent{{Type: xdr.ContractEventTypeContract}}}, // index 0 (invoke)
1226+
},
1227+
},
1228+
}
1229+
opResults := []xdr.OperationResult{
1230+
{Code: xdr.OperationResultCodeOpInner, Tr: &xdr.OperationResultTr{Type: xdr.OperationTypeInvokeHostFunction}},
1231+
{Code: xdr.OperationResultCodeOpInner, Tr: &xdr.OperationResultTr{Type: xdr.OperationTypeExtendFootprintTtl}},
1232+
{Code: xdr.OperationResultCodeOpInner, Tr: &xdr.OperationResultTr{Type: xdr.OperationTypeRestoreFootprint}},
1233+
}
1234+
lcm := newSyntheticLedgerCloseMeta(105, opResults, applyMeta)
1235+
1236+
out, err := ExtractContractEventsForLedger(lcm)
1237+
require.NoError(t, err)
1238+
require.Len(t, out, 1, "only the InvokeHostFunction op yields events; footprint ops are skipped")
1239+
_, ok := out[ContractEventKey{TxIdx: 1, OpIdx: 0}]
1240+
require.True(t, ok)
1241+
}
1242+
1243+
// Guards that the corpus keeps real fixtures for both LedgerCloseMeta wrapper
1244+
// versions the lake serves with events: V1 (Protocol 20-22) and V2 (Protocol 23+).
1245+
// The walk switches on this version, so dropping either set would quietly lose
1246+
// coverage of an older history window.
1247+
//
1248+
// All lake fixtures are TransactionMetaV4; the V3 branch is covered by the
1249+
// synthetic tests above.
1250+
func TestExtractContractEventsForLedger_CorpusCoversWrapperVersions(t *testing.T) {
1251+
paths, err := filepath.Glob("testdata/*.xdr.gz")
1252+
require.NoError(t, err)
1253+
require.NotEmpty(t, paths)
1254+
1255+
wrapperWithEvents := map[int32]bool{} // LedgerCloseMeta wrapper version -> a ledger of it yielded events
1256+
for _, path := range paths {
1257+
lcm := loadLedgerFixture(t, path)
1258+
events, evErr := ExtractContractEventsForLedger(lcm)
1259+
require.NoError(t, evErr)
1260+
if len(events) > 0 {
1261+
wrapperWithEvents[lcm.V] = true
1262+
}
1263+
}
1264+
require.True(t, wrapperWithEvents[1], "corpus must include a wrapper-V1 (Protocol 20-22) ledger with extracted contract events")
1265+
require.True(t, wrapperWithEvents[2], "corpus must include a wrapper-V2 (Protocol 23+) ledger with extracted contract events")
1266+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
*.xdr.gz binary
288 KB
Binary file not shown.
145 KB
Binary file not shown.
142 KB
Binary file not shown.
155 KB
Binary file not shown.
164 KB
Binary file not shown.

internal/services/protocol_migrate.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ func (s *protocolMigrateEngine) processAllProtocols(ctx context.Context, protoco
261261
// Extract contract events once per ledger; all trackers below share the
262262
// same map. This is the migration-side analogue of the live-ingest path
263263
// where buffer.GetContractEvents() is computed once per ledger.
264-
ledgerEvents, eventsErr := indexer.ExtractContractEventsForLedger(ctx, s.networkPassphrase, ledgerMeta)
264+
ledgerEvents, eventsErr := indexer.ExtractContractEventsForLedger(ledgerMeta)
265265
if eventsErr != nil {
266266
return handedOffProtocolIDs(trackers), fmt.Errorf("extracting contract events for ledger %d: %w", seq, eventsErr)
267267
}

0 commit comments

Comments
 (0)