|
2 | 2 | package indexer |
3 | 3 |
|
4 | 4 | import ( |
| 5 | + "compress/gzip" |
5 | 6 | "context" |
6 | 7 | "errors" |
| 8 | + "fmt" |
| 9 | + "io" |
| 10 | + "os" |
| 11 | + "path/filepath" |
7 | 12 | "runtime" |
8 | 13 | "testing" |
9 | 14 |
|
@@ -975,3 +980,287 @@ func TestIndexer_GetLedgerTransactions(t *testing.T) { |
975 | 980 | }) |
976 | 981 | } |
977 | 982 | } |
| 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 | +} |
0 commit comments