Conversation
Add a differential test that walks every fixture ledger with the generated accessors alone (Fields() per TxProcessing element, All() per event array, SorobanMeta().Unwrap() for the fee extension) and requires the exported extractors to return the same wire elements: the same hashes, the same Result and Meta views, every event at the same address and length, the same empty-versus-nil shapes, the same fee buckets, and the same diagnostics and per-operation arity on the read path. The fixtures cover LCM V0, V1 and V2, TransactionMeta V0 to V4 under every envelope shape the package builds, the fee classification matrix, V4 operations with ledger-entry spines, parallel and multi-phase TxSets, empty ledgers, and the pubnet ledger. The test uses only the exported API and compares elements through a helper generic over the element type, so it compiles and passes against the current [][]byte products as well as element-view ones. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Aom7h8eTvXNwM47wCNp7M
The events products returned each event as raw bytes: TxEvents held [][]byte and [][][]byte, and LedgerTransactionView held [][]byte and [][][]byte for its diagnostic, transaction and contract events. A consumer that reads a field re-wraps every element in its view (stellar-rpc's stage filter does exactly that), and the producer cannot hand out what the generated All() returns, because Go cannot retype a []E as [][]byte even though E is a []byte. Return the element views instead: TxEvents.TransactionEvents []xdr.TransactionEventView TxEvents.OperationEvents [][]xdr.ContractEventView LedgerTransactionView.DiagnosticEvents []xdr.DiagnosticEventView LedgerTransactionView.TransactionEvents []xdr.TransactionEventView LedgerTransactionView.ContractEvents [][]xdr.ContractEventView A view is its wire bytes, so []byte(ev) is free for a caller that wants bytes. This is a breaking change to API documented as experimental. The elements are the same bytes at the same addresses as before, which the generated-accessor differential test pins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Aom7h8eTvXNwM47wCNp7M
… sit The products re-walked every transaction's meta. The TxProcessing walk sized each element whole, then EventsFromTxParts located a V4 meta's fields again, iterated its operations, located each operation's events and sized every event twice, and FeesFromTxParts sized everything in front of SorobanMeta once more. The operation state changes, most of a ledger's bytes, were sized about five times per transaction, and the read path's range walk sized every meta twice. Replace the element iterators and metaEventRaws with one walk that sizes every field of every element exactly once, through the generated views, and records where the products' fields sit. For each element it reads the result pair and asks a predicate whether to keep the element and whether to stop there. A kept element's TransactionMeta V3 or V4 is walked field by field, and an entry is appended to a per-walk offset record: the meta's version and size, the SorobanMeta body offset, the DiagnosticEvents array offset, and the element boundaries of every contract-event and transaction-event array, as uint32 offsets from the meta's first byte. Other meta versions, and the metas of skipped elements, are sized whole. The record is pointer-free and presized from the TxProcessing count, and LedgerTxParts points at its entry. The products decode the entry and slice Meta; they size nothing. The events product carves every transaction's slices from one backing array per element type, and the fees product runs under one recover per ledger. Outputs are main's, empty-but-not-nil shapes and fee classification included. Parts built by hand have no entry, so the products locate their meta with the same walk. A walked part whose Meta no longer matches the entry's version and size is an error, never a panic. The read path uses the same walk: by-hash keeps only the match and stops there, range keeps its page and stops after it, LedgerTransactionView is assembled from the walked parts, and diagnostics are collected with the generated All() for the transactions returned. The walk reaches the first recorded array of each meta and operation with the generated accessors; it writes out only the field order that follows those arrays (in TransactionMetaV3's SorobanMeta, TransactionMetaV4 and OperationMetaV2) and the element order of TransactionResultMeta(V1). Every size, count and optional flag comes from the generated views. On the Xeon 8375C box, hashes, events and fees together per real pubnet ledger go from 3.68 to 1.34 ms (ledgers over 4 MiB), 16.1 to 5.63 ms (14 MiB) and 27.8 to 9.69 ms (24 MiB), with about 30 allocations per ledger instead of 2,000 to 10,000. On the 6,000-transaction dense benchmark all three products go from 14.9 to 4.6 ms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Aom7h8eTvXNwM47wCNp7M
A consumer that stores where each transaction sits in a ledger (to serve it later without walking the ledger again) needs the transaction's TxProcessing element as a byte range. The walk already sizes every element in order to advance, so the range is free. LedgerTxParts gains ElemStart and ElemEnd, the offsets of the whole element (a TransactionResultMeta, or a TransactionResultMetaV1 on an LCM V2 ledger) in the LedgerCloseMetaView passed to ExtractLedgerTxParts. The test checks, on LCM V0, V1 and V2, that each span is exactly the element the generated accessors find, that consecutive spans tile the array, that each element starts with its transaction hash, and that Result and Meta sit inside it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Aom7h8eTvXNwM47wCNp7M
The walk writes out part of the field order of seven XDR structs by hand, so pin it three ways. Every entry the walk records (the meta version and size, the SorobanMeta and DiagnosticEvents offsets, each event array's element boundaries) must equal what the generated accessors report for the same meta, with trailing bytes after it. The generated Fields structs of the mirrored types must keep the field names, types and order the walk assumes, and the TransactionMeta and TxProcessing unions their arm types. And each V4 operation's events must be the ones the generated random-access accessor finds at that index. Also cover: every returned slice has its final length as its capacity; parts built by hand, with a trimmed or an untrimmed Meta, and walked parts whose Meta was copied out of the ledger give the same products; a walked part whose Meta was shortened or replaced by another meta is an error; optional flag 2, hostile array counts, an unknown meta version and bad TxProcessing counts are errors on every entry point; every truncation of a small ledger is an error before the end of TxProcessing and the same parts after it, with nothing panicking; ExtractLedgerTxParts allocates three objects whatever the transaction count, on the pubnet ledger as well. A fuzz target checks the walk and the events product against the generated-accessor reference on any input the walk accepts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Aom7h8eTvXNwM47wCNp7M
Record the element-view return types as a breaking change to the experimental extractors, the new element spans on LedgerTxParts, and the speedup with its real-ledger numbers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Aom7h8eTvXNwM47wCNp7M
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Three moderate issues remain unresolved in result shape preservation, metadata validation, and diagnostic-event sizing.
Review effort: Lite
Findings: 1
What changed in this PR
This PR refactors ingest extraction to walk TxProcessing once, cache metadata offsets, and provide zero-copy typed event views.
Changes:
- Adds cached metadata offsets and transaction spans.
- Reworks event, fee, range, and hash extraction.
- Updates APIs, tests, benchmarks, and documentation.
- Review issues remain around empty-result shape, metadata validation, and diagnostic-event sizing.
| File | Summary |
|---|---|
ingest/tx_processing_walk.go |
Shared walk and offset records. |
ingest/tx_processing_walk_test.go |
Walk, malformed-input, span, allocation, and fuzz tests. |
ingest/transaction_view.go |
Cached range and hash views. |
ingest/transaction_view_test.go |
Updated typed event assertions. |
ingest/transaction_events_view.go |
Superseded event-walking logic removed. |
ingest/transaction_events_view_test.go |
Migrated event extractor tests. |
ingest/ledger_close_meta_view_nav.go |
TxProcessing navigation state. |
ingest/extract.go |
Cached parts, spans, fees, and typed events. |
ingest/extract_real_ledger_test.go |
Updated real-ledger comparisons. |
ingest/extract_equivalence_test.go |
Generated-accessor equivalence coverage. |
ingest/CHANGELOG.md |
API and performance documentation. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| e := p.walkedEntry() | ||
| v, err := p.Meta.V() | ||
| if err != nil || uint32(v) != e[entryVersion] || int(e[entrySize]) != len(p.Meta) { //nolint:gosec // a union discriminant | ||
| return nil, fmt.Errorf("ingest: tx %x: Meta is not the meta the walk located", p.Hash) |
There was a problem hiding this comment.
caller can replace a walked part's Meta with different bytes having the same version and length
That contradicts the LedgerTxParts.Meta contract that only an identical byte copy may replace the walked
this finding is invalid because your scenario requires you to replace Meta with a corrupted value

The per-ledger products re-walked every transaction's meta.
ExtractLedgerTxPartssized each TxProcessing element whole, thenEventsFromTxPartslocated a V4 meta's fields again, iterated its operations, located each operation's events and sized every event twice, andFeesFromTxPartssized everything in front of SorobanMeta once more. The operation state changes, most of a ledger's bytes, were sized about five times per transaction.This PR makes the walk sizing every byte once also record where each product's fields sit:
[]uint32record: the meta's version and size, the SorobanMeta body offset, the DiagnosticEvents array offset, and the element boundaries of every contract-event and transaction-event array, all relative to the meta's first byte.LedgerTxPartspoints at its entry. Other meta versions are sized whole.EventsFromTxPartsandFeesFromTxPartsdecode the entry and sliceMeta; they size nothing. The events product carves all transactions' slices from one backing array per element type.LedgerTransactionViewByHashandLedgerTransactionViewRangeuse the same walk: by-hash keeps only the match and stops there, range keeps its page and stops after it.Metano longer matches its entry's version and size is an error, never a panic.Outputs are unchanged: every event is the same bytes at the same address, the empty-but-not-nil shapes and the fee classification are the same.
The hand-written part. The walk reaches the first recorded array of each meta and operation with the generated accessors and sizes every field with its generated view, so no size, count or optional flag is computed by hand. What it writes out is the order of the fields that follow those arrays (in TransactionMetaV3's SorobanMeta, TransactionMetaV4 and OperationMetaV2) and the element layout of TransactionResultMeta and TransactionResultMetaV1. Three tests pin that against the generated code: every recorded offset equals what the generated accessors report, the generated
Fieldsstructs of those types keep the field names, types and order the walk assumes, and each V4 operation's events are the ones the generated random-access accessor finds. No generated code changes.API changes (the extractors are experimental):
A view is its wire bytes, so
[]byte(ev)gives them without a copy.ElemStart/ElemEnddelimit each transaction's TxProcessing element in the buffer passed toExtractLedgerTxParts.Measurements
Pubnet ledgers, Xeon 8375C. Each time is one ledger's
ExtractLedgerTxParts,EventsFromTxPartsandFeesFromTxParts: the median over the set's ledgers, each ledger the median of 11 runs. The two dense sets each merge 8 or 16 consecutive pubnet ledgers into one.On every ledger the products were checked equal to main's, run from main's own code.
The package's existing benchmarks, main against this PR,
benchstatover 10 runs each on the same machine. A row naming a product includes theExtractLedgerTxPartscall it reads from.Pubnet ledger 58752000 (1.28 MB, 249 transactions):
ExtractLedgerTxPartsEventsFromTxPartsFeesFromTxPartsLedgerTransactionViewRange, whole ledgerLedgerTransactionViewRange, first 10LedgerTransactionViewByHash, last transactionTests
Fields(),All(),SorobanMeta().Unwrap()) checks every exported extractor on every fixture ledger (LCM V0, V1 and V2, TransactionMeta V0 to V4 under every envelope shape, the fee matrix, V4 operations with ledger-entry changes, parallel and multi-phase TxSets, empty ledgers, the pubnet ledger): same elements at the same addresses, same empty-versus-nil shapes, same fee buckets, same read-path diagnostics and per-operation arity. It is the first commit and passes on main unchanged.ResultandMeta.Meta) and parts with a copiedMetagive the same products; a shortened or replacedMetais an error.ExtractLedgerTxPartsallocates three objects per ledger whatever its transaction count, as long as the offset record fits its presize of 14 words per transaction; that is pinned at 4 and 256 transactions and on the pubnet ledger.Supersedes #5972, #5996 and #6011, and takes
ElemStart/ElemEndfrom #6010.🤖 Generated with Claude Code