views: zero-copy XDR view extractors — events, tx-hashes, tx-details, tx-pages (#764) - #778
views: zero-copy XDR view extractors — events, tx-hashes, tx-details, tx-pages (#764)#778chowbao wants to merge 4 commits into
Conversation
e36fe74 to
257ec3d
Compare
450942f to
3c842d0
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3c842d0525
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
Four zero-copy XDR view extractors over xdr.LedgerCloseMetaView (events, tx-hashes, tx-details-by-hash, tx-pages); outputs alias the view buffer. Transactions paired to TxSet envelopes by hash (mirroring ingest.LedgerTransactionReader); V3 contract events gated on IsSorobanTx. Differential-tested vs the parsed / db.ParseTransaction path across LCM V0/V1/V2, meta V1-V4, V0Components + ParallelTxs, order mismatch, diagnostic events, empty, sponsorship, large-tx, and protocol-transition fixtures; aliasing + negative-path coverage; per-extractor benches. Leaf package (no internal/db dep). Closes #764.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c7250ad5e7
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // Transient decode purely to compute the hash; the returned raw slice is | ||
| // the original zero-copy .Raw() view buffer, not this decoded value. | ||
| var decoded xdr.TransactionEnvelope | ||
| if uerr := decoded.UnmarshalBinary(raw); uerr != nil { | ||
| return envPart{}, fmt.Errorf("views: envelope decode for hashing: %w", uerr) | ||
| } | ||
| hash, err := network.HashTransactionInEnvelope(decoded, passphrase) |
There was a problem hiding this comment.
Doesn't this more-or-less just re-marshal it anyway? It's a little more involved than that and obviously the code would be really gross but the round trip here does feelsbadman a bit.
Alternatively, since you're already unmarshaling the envelope, you can remove the code above that fetches type via views and just do it the "normal" way for brevity.
There was a problem hiding this comment.
Closing the loop: the decode is gone entirely now — the hash preimage is built from the wire bytes directly (da9ee15), so neither the decode nor the round-trip exists anymore.
| // Supported shapes: | ||
| // | ||
| // - LCM V1, V2 (apply order = TxProcessing array order) | ||
| // - TransactionMeta V1, V2 (no events, skipped) |
There was a problem hiding this comment.
There's also TransactionMeta"V0" which is just the Operations *[]OperationMeta field being filled out which doesn't appear to be considered here
There was a problem hiding this comment.
I had that question too but it seems like current RPC doesn't support V0
- db.ParseTransaction → ingestTx.GetTransactionEvents() → SDK ledger_transaction.go:308 has a default:
that returns "unsupported TransactionMeta version: 0".
Hmm but I guess so with this implementation of rpc v2 we should?
There was a problem hiding this comment.
Oh I see. I remember one other thing. Full history rpc v2 would realistically never have V0 txmeta because it would ingest with the unified events and backfill flags set to true cause otherwise there wouldn't be any events for classic
Anyhoo I'm forcing claude to add it in
There was a problem hiding this comment.
Yeah current RPC doesn't need to support it because it'd never come up, but with full history it's possible. You're 100% right about the flags, though - but better to be defensive!
| if err != nil { | ||
| return 0, 0, fmt.Errorf("views: CloseTime value: %w", err) | ||
| } | ||
| return seqVal, int64(ctVal), nil //nolint:gosec // TimePoint is uint64 |
There was a problem hiding this comment.
There's gooooootta be a better way to do this than per-property error inspection.... It'd be nice if you could do:
ctVal := header.HeaderW().LedgerSeqW().ValueW().ScpValueW().CloseTimeW().ValueW()
if ctVal.hasError {
// ... has the first error that occurred during unwrapping
}where the *W() variant returns a "wrapped" version that tracks whether or not there's an error, e.g.
struct LedgerHeaderW {
LedgerHeader
hasError error
}
func (*hdr LedgerHeaderHistoryEntryView) HeaderW() LedgerHeaderW {
inner, err := header.Header()
return LedgerHeaderW{LedgerHeader: inner, hasError: err}
}
// and so on
where each child method just propagates the error until the end if it's present. I could be making up Go semantics here and maybe this isn't possible, but it'd be a way nicer api ux if we could chain it this way. @tamirms is this even tenable in Go or am I making up syntax?
There was a problem hiding this comment.
It seems possible but that wrapped types would have to go into the go-stellar-sdk and can't be done here.
I'm not sure that's worth it at this time just to make the error handling a little nicer
There was a problem hiding this comment.
Oh yeah 100% this is just a side comment from the actual PR here
There was a problem hiding this comment.
Turns out the SDK already generates this: Must* accessors panic with *ViewError and xdr.Try recovers them. da9ee15 switches to that style and deletes the local helper.
There was a problem hiding this comment.
Bruh I keep forgetting that that exists
| // payloadsFromV4Meta extracts V4 events: | ||
| // | ||
| // 1. Top-level TransactionEvents first, dispatched on Stage: | ||
| // BeforeAllTxs -> uses ledger-wide state.beforeIndex | ||
| // AfterAllTxs -> uses ledger-wide state.afterIndex | ||
| // AfterTx -> uses per-tx counter (reset here) | ||
| // 2. Then per-operation contract events in (op, event) order. | ||
| // | ||
| // Order matches LCMToPayloads / db.InsertEvents and the (TxIdx, OpIdx, | ||
| // EventIdx) sentinels match the struct path's encoding. |
There was a problem hiding this comment.
Maybe I'm misunderstanding here: this order from current RPC operates on a per-ledger basis. Are you suggesting that this creates a list of orders that are then correctly interleaved later?
There was a problem hiding this comment.
It doesn't interleave later. I forgot about the event ordering
Edit: It passes off the interleaving to the ingest system downstream. This is just the extraction of all events with the correct IDs to be used to order later
It follows the same part of getting the payload out as the code you linked where ops happen after all tx events.
And to get the correctly ordered events you'd just iterate through the event indexes
…, error-chain helper - ExtractEvents/ExtractTransactions/ExtractTxDetailsByHash now treat legacy TransactionMeta V0 (pre-Soroban, Operations only) as event-free instead of erroring, so full-history backfill from genesis can read those ledgers. The SDK reference path (GetTransactionEvents / LCMToPayloads / db.ParseTransaction) rejects V0, so this is deliberately more permissive and documented as such; the two tests that asserted the V0 error now assert V0 success. - envPartFromView reads the envelope type from the decode it already performs for hashing, dropping a redundant view .Type() traversal. - Add a generic short-circuiting step()/viewChain helper and use it in readLedgerHeader and readTxHash to collapse the per-accessor error ladder.
The decode is not 'purely for the hash' — it also feeds the envelope type and the soroban flag (which must inspect Tx.Ext), so hashing piggybacks on a decode that is required regardless. Fix the now-stale comments that still described it as transient/hash-only.
tamirms
left a comment
There was a problem hiding this comment.
I have two main suggestions; the details are in the inline comments.
1. Error handling. The package handles errors with an explicit check after every accessor call, plus a hand-rolled viewChain/step helper. The Go SDK already provides a better option: every view has generated Must* accessors that chain fluently, and xdr.Try/TryVoid convert their failures back into errors (see the "Try / TryVoid" section of xdrgen/views_api.md). The viewChain doc comment says that a fluent API would need SDK-generated wrapper types, but those already exist. If each exported extractor wraps its body in a single recover boundary, the package shrinks to roughly half its size and the //nolint:cyclop,funlen suppressions become unnecessary. Error quality is preserved because ViewError already carries the kind, byte offset, and detail. The views documentation says Must methods are intended for trusted input, which is what we have here.
2. Envelope pairing should not need to decode. envPartFromView calls UnmarshalBinary and network.HashTransactionInEnvelope on every envelope in the TxSet, and this dominates the cost of the read path. The decode is avoidable, because all three of its outputs can be derived from the view directly: the envelope type comes from the discriminant, isSoroban comes from the Ext discriminant, and the hash can be computed from the wire bytes. Details in the inline comment.
On the SDK-split question from #764, I would revisit the conclusion that nothing belongs in the SDK. View-side envelope hashing, IsSorobanTx, and the version-agnostic LCM helpers that this package hand-rolls all mirror existing struct-side SDK APIs: readLedgerHeader mirrors lcm.LedgerSequence() and lcm.LedgerCloseTime(), and the TxSet walkers mirror lcm.TransactionEnvelopes(). Keeping copies of that logic here means we have to track SDK behavior changes by hand. The envelopeIsSoroban comment already describes itself as "a known drift point to update in lockstep".
| // which hashes every TxSet envelope (via network.HashTransactionInEnvelope, | ||
| // which handles the fee-bump case) because the TxSet is in agreed-set / | ||
| // hash-sorted order, NOT TxProcessing apply order. | ||
| func envPartFromView(env xdr.TransactionEnvelopeView, passphrase string) (envPart, error) { |
There was a problem hiding this comment.
should-fix: The decode can be removed entirely. The transaction hash can be computed directly from the wire bytes as SHA256(networkID ‖ envType-tag ‖ innerTx.Raw()). If the network ID is computed once per call and the SHA256 state is reused, hashing allocates nothing per envelope. The TX_V0 case hashes as its V1 conversion, which on the wire only adds the 4-byte muxed key-type prefix before the source key; the V0 optional-TimeBounds encoding and the V1 Preconditions encoding are identical, as are all the fields after them. The isSoroban flag can be read from V1().Tx().Ext(), or the fee-bump inner equivalent. Regarding aad6776: once the envelope type and the soroban flag are derived from the view, nothing needs the decode anymore. Separately, it would be worth adding TX_V0-envelope fixtures — no current fixture exercises one.
There was a problem hiding this comment.
Done in da9ee15. envPartFromView no longer decodes the envelope: the hash is computed straight from the wire as SHA256(networkID ‖ envType tag ‖ innerTx.Raw()), with the network ID derived once per extraction and a reused SHA-256 state, so per-envelope hashing allocates nothing. TX_V0 hashes as its V1 conversion (4-byte ed25519 key-type prefix + the unchanged V0 bytes — the optional-TimeBounds/Preconditions equivalence is documented on the function), and isSoroban now reads the inner Tx.Ext view discriminant. Added TX_V0 fixtures with TimeBounds present and absent, paired against the reference path.
| // opening is delegated to openHeaderAndTxProc; only the version-specific | ||
| // TxSet open (TransactionSetView vs GeneralizedTransactionSetView) and its | ||
| // envelope enumerator are handled here. | ||
| func dispatchLCM(lcm xdr.LedgerCloseMetaView) (lcmDispatch, error) { |
There was a problem hiding this comment.
should-fix: This switches on the same discriminant twice: dispatchLCM calls openHeaderAndTxProc and then re-opens lcm.V0()/V1()/V2() to get the TxSet. The comment saying the TxSet open "cannot share this path" doesn't quite hold, because the enumerateEnvs closure already absorbs the type differences. Could this be a single dispatch that returns the header, the TxProcessing iterable, and the envelope enumerator, with the events path simply ignoring the enumerator?
There was a problem hiding this comment.
Done — dispatchLCM (now in dispatch.go) is the single switch, returning the header, the TxProcessing sequence, and a lazily-opened envelope enumerator. openHeaderAndTxProc is gone; ExtractEvents and ExtractTxHashes use the same dispatch and just never touch the enumerator.
| // apply order), so a TxProcessing entry's TransactionHash can locate its OWN | ||
| // envelope rather than the one that happens to sit at the same array index. | ||
| // The returned envPart raw bytes remain zero-copy aliases of the view buffer. | ||
| func envelopesByHash(d lcmDispatch, passphrase string) (map[[32]byte]envPart, error) { |
There was a problem hiding this comment.
suggestion: This hashes every TxSet envelope even when limit is 1. Since the page's hashes are collected before the envelopes are enumerated, the enumeration could stop as soon as every page hash has been resolved. For small pages that is most of the cost of the call.
There was a problem hiding this comment.
Done — ExtractTransactions collects the page's hashes first and envelopesForHashes stops the TxSet enumeration as soon as every wanted hash resolves.
| // check. This is the package-local stand-in for the fluent `.HeaderW().…W()` | ||
| // chaining wished for in review; a true fluent API would need SDK-generated | ||
| // wrapper types. | ||
| type viewChain struct{ err error } |
There was a problem hiding this comment.
should-fix: Two things here. First, the doc block attached to viewChain opens with readLedgerHeader's documentation, so the wrong declaration is documented and readLedgerHeader has none. Second, the comment says a fluent API "would need SDK-generated wrapper types", but the Go SDK already generates them: the Must* accessors chain fluently and Try recovers their failures as errors. With that style this helper disappears entirely.
There was a problem hiding this comment.
Done — viewChain/step are deleted; readLedgerHeader and readTxHash use the generated Must* chains under xdr.Try, and the doc comment is back on the right declaration.
| // IsSorobanTx the way the struct path does. This is why the differential | ||
| // test's V3 fixtures, which always pair a present SorobanMeta with a | ||
| // soroban envelope, agree on both paths. | ||
| func payloadsFromV3SorobanMeta(metaView xdr.TransactionMetaView, state *txWalkState, dst []events.Payload) ([]events.Payload, error) { |
There was a problem hiding this comment.
question: ExtractEvents emits V3 events whenever SorobanMeta is present, relying on the core invariant, while the read path additionally gates them on the envelope (gateV3ContractEvents). On an inconsistent LCM, the events index and getTransaction would therefore disagree about the same transaction. That is fine under the trusted-input assumption, but since this is a package-level contract, would you move the invariant statement into doc.go so that both halves reference one place?
There was a problem hiding this comment.
Moved into doc.go as the package's trusted-input invariant; payloadsFromV3SorobanMeta and gateV3ContractEvents both reference it now.
| if herr != nil { | ||
| return Transaction{}, false, herr | ||
| } | ||
| if bytes.Equal(h[:], hash[:]) { |
There was a problem hiding this comment.
nit: Both sides are [32]byte, so h == xdr.Hash(hash) works instead of bytes.Equal.
| return xdr.Hash{}, c.err | ||
| } | ||
| if len(hb) != 32 { | ||
| return xdr.Hash{}, fmt.Errorf("views: tx hash length %d != 32", len(hb)) |
There was a problem hiding this comment.
nit: This check can't fire: HashView is a fixed opaque[32], so Value() always returns exactly 32 bytes on success.
There was a problem hiding this comment.
Removed — readTxHash converts with xdr.Hash(hb) directly.
| // struct path purely on the nil-vs-empty axis. | ||
| // | ||
| //nolint:cyclop,funlen // linear dispatch on meta version | ||
| func extractEventRawsFromMeta(mv xdr.TransactionMetaView) (metaEvents, error) { |
There was a problem hiding this comment.
nit: gofmt has reflowed the V0/V3/V4 list in this doc comment into stray indented blocks, which will render as code in godoc. It needs reformatting as a proper list.
There was a problem hiding this comment.
Reformatted as a proper bullet list.
| // that buf[off:off+len(sub)] shares backing storage with sub, or -1. Since | ||
| // the view fields are sub-slices of buf, a content match at a unique offset | ||
| // confirms aliasing for our fixtures. | ||
| func indexOfSubslice(buf, sub []byte) int { |
There was a problem hiding this comment.
nit: A content match would also pass for a copy that happens to match at a unique offset. events_test.go proves aliasing correctly with unsafe.SliceData pointer containment; could we reuse that helper here?
There was a problem hiding this comment.
Done — assertAliasesRaw now proves aliasing by unsafe.SliceData pointer containment; the content scanner is gone.
| // pairing returns each tx's OWN envelope. We assert each returned Envelope | ||
| // equals the envelope wire-bytes for that tx's OWN hash. | ||
| // | ||
| // This test FAILS on the old positional code and passes after the |
There was a problem hiding this comment.
nit: Several comments narrate review and benchmarking history: "wished for in review", "previously-0%-coverage", "FAILS on the old positional code", and the rpc-hack provenance notes. The PR description already preserves that history. Could the code comments keep just the forward-looking contracts?
There was a problem hiding this comment.
Swept — all of them are gone (the "wished for in review" one went with viewChain itself).
…ispatch - envelopes.go: drop the per-envelope TransactionEnvelope decode. The tx hash is computed straight from wire bytes as SHA256(networkID | envType tag | innerTx.Raw()) with the network ID derived once per extraction and a reused SHA-256 state (r3386543211). TX_V0 hashes as its V1 conversion (4-byte ed25519 key-type prefix); isSoroban now reads the inner Tx.Ext view discriminant. - dispatch.go: single V0/V1/V2 dispatch returning header, TxProcessing sequence, and a lazy TxSet envelope enumerator; openHeaderAndTxProc and the second discriminant switch in dispatchLCM are gone (r3386543217). - ExtractTransactions resolves only the page's envelope hashes and stops TxSet enumeration once all are found (r3386543224). - readLedgerHeader/readTxHash use the SDK's Must*/xdr.Try chains; the viewChain/step helper is deleted (r3386543231). - doc.go now owns the SorobanMeta-presence trusted-input invariant, referenced from ExtractEvents and gateV3ContractEvents (r3386543237). - nits: generic widen() replaces the txProcIter adapter; xdr.Hash == comparison instead of bytes.Equal; dead 32-byte length check removed; extractEventRawsFromMeta doc list reformatted; assertAliasesRaw proves aliasing via unsafe.SliceData pointer containment; review-history narration comments dropped (r3386543242/50/52/56/64/71). - pairing_test.go: TX_V0 envelope fixtures (TimeBounds present + absent) paired against the reference path.
|
Closing in favor of stellar/go-stellar-sdk#5949 + #779. The conclusion of this PR ("nothing in this task belongs in the SDK") was reversed: the zero-copy XDR view extractors are general-purpose XDR navigation — the view twins of the parsed
validated wire-identical against the parsed path across LCM V0/V1/V2 and The remaining RPC-specific glue (the thin |
Implements #764 — the four zero-copy XDR view extractors, promoted from the
rpc-hackbench harness into a production packagecmd/stellar-rpc/internal/fullhistory/views/. Each turns a rawLedgerCloseMeta(wrapped asxdr.LedgerCloseMetaView) into the shape RPC v2 needs, withoutUnmarshalBinary— outputs alias the view buffer (callers copy what they retain).The four extractors (all of #764)
ExtractEvents(lcm) ([]events.Payload, error)events.goExtractTxHashes(lcm) ([]txhash.Entry, error)txhash.gogetTransaction)ExtractTxDetailsByHash(lcm, hash, passphrase) (Transaction, bool, error)txdetails.gogetTransactions)ExtractTransactions(lcm, startIdx, limit, passphrase) ([]Transaction, error)txdetails.goTxProcessing(which carries the hash + meta), so they take no passphrase;ExtractEventsreturnsErrV0Unsupportedfor V0 (V0 has no contract events).TxSet, which is in agreed-set/hash-sorted order, not apply order), so they pair envelope→tx by hash (network.HashTransactionInEnvelope) and take the network passphrase.startIdx/limitare thegetTransactionscursor. They return a package-localviews.Transaction.envelopes.go(version/phase-aware envelope enumeration + hashing, used by the two read-path extractors).Tests
ExtractEvents/ExtractTxHashesvs the structLCMToPayloads/lcm.TransactionHash;ExtractTxDetailsByHash/ExtractTransactionsvsdb.ParseTransaction(driven by the SDKLedgerTransactionReader) — field-by-field, wire-identical bytes, across LCM V0/V1/V2 and meta V1–V4 (incl. V0Components + ParallelTxs phases, staged top-level + per-op events, diagnostic events).pairing_test.go— reversed-order regression (TxSet order ≠ apply order) guarding the by-hash pairing.startIdx<0/limit<0), a zero-copy aliasing assertion, and per-extractorgo test -benchbenchmarks.SDK vs stellar-rpc split (issue #764 acceptance item)
Rule: low-level XDR navigation →
go-stellar-sdk; RPC-specific data shapes → stellar-rpc.Determination: nothing in this task belongs in the SDK — the boundary already sat where the rule wants it. The SDK ships all the low-level navigation, so this package is a pure consumer that composes those primitives into RPC shapes.
SDK-side (consumed, nothing added) — production imports:
xdr.LedgerCloseMetaView+ the generated*Viewaccessors (.V()/.V0..V2(),.Iter(),.At(),.Count(),.Raw(),.Value()) — zero-copy, schema-generated XDR navigation.network.HashTransactionInEnvelope— canonical protocol-level tx-hash (envelope↔tx pairing).toid— protocol-level transaction/operation-ID (apply order / cursor).stellar-rpc-side (this package) — the four extractors plus the RPC-specific shapes (
events.Payload,txhash.Entry, the package-localviews.Transaction) and semantics (event-ID/term ordering,startIdx/limitpaging, envelope-by-hash pairing). Documented inviews/doc.go.The differential tests pull in the SDK's
ingest.LedgerTransactionReaderonly as the reference oracle to validate the view path against — it is deliberately not a production dependency.Related layering choice (within stellar-rpc): the read-path extractors return a package-local
views.Transactionrather thaninternal/db.Transaction(even thoughdbdoesn't importviews, so there'd be no cycle) — keepingviewsa clean leaf and avoiding an inversion of thedb → viewslayering.Differences from
rpc-hackA faithful promotion of rpc-hack's view-extraction logic (the V1–V4 event walk and XDR-view navigation are essentially line-for-line), with the bench-only scaffolding dropped (roundtrip-comparison harness, CLI/CSV/cache) and a few deliberate changes:
envelopesByHash, mirroring the SDK'sLedgerTransactionReader), withpairing_test.goas a reversed-order regression.package events+ the benchpackage maininto one leafpackage views; read-path extractors returnviews.Transaction(flatLedgerSequence/LedgerCloseTime, raw[32]bytehash) rather thaninternal/db.Transaction.events.TermsForBytes. The (previously ignored) passphrase param was dropped fromExtractEvents; it's now a real, used input on the read-path extractors.IsSorobanTx(matchesdb.ParseTransaction; rpc-hack over-emitted for a classic-envelope V3 tx),startIdx<0/limit<0rejection, an explicit missing-hash error, and differential tests vsdb.ParseTransactionacross LCM V0/V1/V2.Follow-up (per #764 acceptance, not in this PR)
Test plan
go test ./cmd/stellar-rpc/internal/fullhistory/views/(+-bench),go vet,gofmt— green.