Support EIP-8141 frame transactions - #855
Conversation
The tx indexer rebuilds transactions from the execution client's JSON. A client that encodes a transaction differently from the canonical form makes everything derived from those fields wrong with it: the hash, the sender recovered from the signature, and whether the transaction is a contract creation. Verify the rebuilt transaction against the hash the client reported alongside it and reject the whole response when they disagree, so the block is fetched from another client instead of indexed from a transaction we mis-decoded. Match receipts to transactions by hash rather than by walking a shared cursor. The cursor left `receipt` pointing at the last receipt in the array once a lookup ran off the end, so a single unmatchable transaction was filed under a foreign transaction index with foreign gas and event data, and every transaction after it in the block was dropped. A transaction without its own receipt is now skipped on its own, and a block that ends up short says so in the log.
go-ethereum cannot represent EIP-8141 frame transactions: types.TxData declares
unexported methods, so type 0x06 cannot be implemented from outside core/types,
and go-ethereum has not shipped it. On a chain that carries them, every decode
path in dora failed and dropped the transaction:
- the beacon block payload, which is the primary source for the tx indexer,
returned ErrTxTypeNotSupported per transaction and skipped it,
- the EL fallback never ran, because the extraction returned a non-nil empty
slice that the caller read as success,
- the JSON path rejected any type outside its switch,
- and the receipts then matched nothing, so the block was committed short of
transactions with no row to show for them.
The same hole swallowed system-contract requests made from such a transaction,
whose sender lookup could not decode it either.
txtypes replaces the core representation. Types it has no decoder for degrade to
their generic fields rather than disappearing, so a transaction dora cannot
introspect still stays counted, indexed and aligned with its receipt - which is
what keeps the next new type from reopening this hole.
Receipts move to raw JSON decoding, both per block and per hash, so that
type-specific receipt content survives; a frame transaction reports its result
per frame and names the payer that settled it, neither of which fits a
go-ethereum receipt.
The round-trip hash check stays, but has to be re-applied rather than inherited:
txtypes adopts the hash the node reported, so a client whose encoding disagrees
with the canonical one - the zero-address contract creation seen from nimbus -
is only caught by re-encoding the decoded fields and comparing. A type that
cannot be re-encoded is taken at its word, since it carries nothing to check.
Two changes fall out of transactions no longer having a single recipient:
- a beacon-block payload that fails to decode now discards the whole payload
and falls back to the EL client, instead of indexing the block without those
transactions,
- txRecipient treats a frame transaction like a contract creation. Its To() is
the first SENDER frame's target, which is one of several and not the
transaction's own recipient, so the emitting contract stays the effective
target.
No behaviour changes for transaction types 0-4.
txtypes gained JSON marshalling for transactions and receipts, so the detour
through go-ethereum can go: it could only encode the types go-ethereum can
represent, which left the copy-JSON button on the transaction page and the
/slot/{slot}/download endpoint with no output for a frame transaction.
FrameTx renders the shape the type actually has - no top-level "to" or "nonce",
a frames list, and the keyed-nonce fields - matching what ethrex reports.
This also completes the EL fallback path for frame transactions. The fallback
reads transactions as JSON, and the hash check re-encodes whatever the decoder
produced, so a lossy JSON representation would have been rejected as a mismatch
rather than indexed. It round-trips.
A frame transaction is an ordered list of calls rather than one, so the fields
el_transactions holds per transaction - a recipient, a value, a status - exist
once per frame. The frames are resolved here and carried on the processing
result; the table that persists them follows.
Four things the single-call shape got wrong for them:
- To() reports the first SENDER frame's target, which is not the transaction's
recipient, and is nil when there is no SENDER frame at all. That nil was read
as a contract creation, which invented a contract address from the sender and
nonce and marked it as code. Frame transactions now skip that path and keep
to_id 0, the id no account carries; their targets are reachable through the
per-account rows instead.
- EIP-8250 gives a frame transaction one nonce sequence per key it names. Only
the zero key aliases the sender's account nonce, so any other key is no longer
recorded as one - it would corrupt el_accounts.last_nonce.
- The fee is charged to the payer the receipt names rather than to the sender.
For a sponsored transaction, the case the field exists for, those differ.
- Value moves per frame, so each frame that carried value contributes its own
transfer instead of one transfer of their sum to a recipient that does not
exist.
A frame that succeeded inside an atomic batch that later rolled back keeps its
status and execution gas, but its state changes are gone. Such a frame is marked
so its value is not recorded as having moved; the gas it spent still counts.
Per-account rows come from the frames rather than from the call trace, and are
written whether or not tracing is enabled - they are receipt data, and without
them a frame transaction would appear on no address page but its sender's.
Call traces, measured against ethrex eip8141-v2-lenient rather than assumed:
- It does not decompose frame transactions. A four-frame transaction traces to
one self-addressed root with no calls at all, which says nothing about the
frames and is discarded rather than shown as a call that was never made.
- Nothing specifies what a per-frame trace would look like: the callTracer is
absent from execution-apis, and EIP-8141's trace rules are about mempool
validation. So a transaction's roots are decoded from either a lone object or
a list, and a frame mapping is claimed only once it verifies - one root per
executed frame, each addressing that frame's target. A client that starts
emitting per-frame roots needs no change here.
- Each frame records how much of the trace belongs to it, so a reader can show
each frame's calls under the frame that made them.
- The root of a frame transaction's trace reports gasUsed as zero while
carrying the real cost in EIP-8037's regularGasUsed, so gas now falls back to
the sum of the two dimensions.
el_tx_frames holds what el_transactions cannot: an EIP-8141 transaction is an ordered list of calls, so the recipient, value, gas budget and status a transaction row holds once exist once per frame. Its transaction row keeps to_id 0, the id no account has, and IsMultiTarget tells that apart from a contract creation - the other reason a row has no recipient. Two counts make the rest of the transaction's data attributable to the frame it came from. Logs are the per-frame lists concatenated in frame order, so log_count partitions the flat event index. The call trace, when a client decomposes one, is stored in the same order, so trace_count partitions that; it is zero while no client does. A frame the client reported no result for gets a status sentinel rather than zero, which would say it failed. rolled_back marks a frame whose atomic batch was undone after it ran: it may report success, but only the gas it spent remains. The columns are sized for the values the protocol permits rather than for the ones a client could report, so the frame list is capped at EIP-8141's limit before it reaches them. A transaction claiming more frames than that cannot be in a valid block, and letting the count through would fail the block's insert on every retry. log_count is clamped the same way event_count already is. Both migrations were applied to a real engine before committing.
A frame transaction's own fields come back from the transaction in the beacon block, but who paid it and what each frame did are only on the receipt. Without them a frame transaction stops being legible once its relational rows are pruned, which is the point at which blockdb is meant to take over. They go into the receipt metadata section rather than a section of their own. The per-transaction index entry in an exec data object is a fixed 100 bytes of hash, bitmap and four section pointers, and the parser refuses any format version but its own - so a fifth pointer would not extend the format, it would make every object already written unreadable. Frame receipt content is receipt metadata in any case. The section's version field decides whether the fixed metadata is followed by frame content. It has carried a version since it was written but nothing ever read it; now it means something, and a version 1 section is byte for byte what it always was. Both readers went through the section codec: the metadata unmarshaller rejects trailing data, so a receipt carrying frames would have failed to decode on the transaction page and in the reconstructed receipts the slot download serves. Those receipts now report the payer and a result per frame, with the logs partitioned back to the frames that emitted them.
A frame transaction had no rendering of its own, so every surface fell back on
the one-recipient shape and got it wrong. The transaction page, the transaction
list, the address page and the slot page all read a missing recipient as a
contract creation, and would have shown a deployment at an address derived from
the sender and a nonce for a transaction that deployed nothing. They now say the
transaction addresses several targets and how many.
The transaction page gains the four things worth knowing about one:
- what it is, named by its validation prefix - self-relayed, sponsored, an
account deployment - which is what makes a frame transaction legible at a
glance. It is read off the same species rules the mempool applies rather
than restated, and left unnamed when the prefix is not a recognized shape.
- who paid, whenever that is not the sender. That is the whole point of a
sponsored transaction. The payer is per-transaction and comes from blockdb;
giving el_transactions a column for it would charge every ordinary
transaction the space.
- the frames themselves, as a table of target, value, calldata size, both gas
dimensions and a result. Atomic batches are marked, and the four possible
results are told apart: a skipped frame never ran because an earlier frame in
its batch failed, and a frame whose batch rolled back afterwards is shown as
such rather than as the success it still reports - its logs were discarded
and its state gas zeroed, so nothing it did survived.
- the deadline of an expiry check, and whether the nonce is the sender's
account nonce at all. Under EIP-8250 it is only that when the transaction
names the zero nonce key; otherwise it is a sequence in a domain of its own
and the page says which.
The frames come from their relational rows where those exist and from the
transaction envelope where they do not, so a transaction still reads correctly
once its rows have been pruned. The envelope also carries the two things no row
holds - the nonce keys and the expiry deadline - which are shown only while the
block is retained rather than guessed at.
The transaction page inferred a contract creation from the absence of a recipient, which is the very sentinel a frame transaction's row carries. Every frame transaction was therefore labelled "Created Contract", against a frame count rather than an address. The creation flag on the type byte is what says a transaction deployed something; a transaction that addresses several recipients simply has none of its own. Caught by rendering a real frame transaction from the frames devnet.
A frame's logs are reported inside the receipt that contains them, so they carry
none of the position fields go-ethereum's Log type requires. Block receipts are
decoded as one response, so a receipt that fails takes every receipt beside it:
one frame transaction that emitted a log cost its whole block an EL index.
failed to process EL block slot=442
all retries failed: fetch receipts from el-2-ethrex-lighthouse:
unmarshal block receipts: failed decoding type 0x06 receipt:
missing required field 'transactionHash' for Log
Fixed upstream in txtypes, which now decodes logs permissively and gives a
nested one the position of the receipt around it. The test here pins the
behaviour dora depends on rather than trusting the pin to hold it.
Two ways the same frame transaction could be described differently depending on which store answered. A frame the client reported no result for was written to el_tx_frames as the unknown sentinel but to blockdb as status zero, which is how a failure is spelled. The page reads whichever store still holds the transaction, so the same frame showed "Unknown" while its rows existed and turned into a red "Failed" once they were pruned. Both stores now go through one encoder, so they cannot drift apart again. Account activity disagreed the same way. A frame transaction's frames were aggregated from its receipt, attributing each to the sender, except when a client's trace decomposed the transaction - then the trace's roots were aggregated instead, and DEFAULT and VERIFY frames are entered by the ENTRY_POINT predeploy rather than by the sender. That recorded ENTRY_POINT as a participant and would have collected every frame transaction on the chain onto that one address, which is what attributing to the sender exists to avoid. The frames now always come from the receipt, so a transaction's account activity reads the same whether or not tracing runs and whichever client served the trace. A trace contributes only the calls made from within the frames; its roots are the frames again and are left out of it, so nothing is counted twice.
…tions # Conflicts: # go.mod # go.sum
The el_tx_frames table held nothing that was not already in two other places: a frame's target, value and gas budgets come from the transaction, and its result from the receipt. Both outlive it - detailsRetention is clamped up to the relational retention - so the table was a shorter-lived copy of longer-lived data, and the only page that read it had already built the same frames from the transaction before overwriting them with the rows. The frames now come from the transaction and the receipt everywhere, which is what the blockdb path already did. Where neither is retained the page says so rather than showing a frame transaction with no frames.
The frame list sat at the bottom of the overview as a flat table, which said what each frame was but not how the frames relate: that the leading ones decide whether the transaction runs, that most of them are entered by a predeploy rather than by the sender, or which frame's failure undid a batch. It now has its own tab, split into the validation prefix and the execution that follows it, with each frame showing its caller, its target, both gas budgets and its calldata. Atomic batches are bracketed and name what rolled them back, and the predeploys are named rather than shown as bare addresses.
There was a problem hiding this comment.
Summary
This PR adds EIP-8141 frame-transaction support to dora by moving the tx core to spamoor/txtypes: indexing (per-frame results, payer fee settlement, keyed-nonce handling), a new blockdb receipt-meta frame tail (versioned, backward-compatible), trace-root-list decoding for frame decomposition, and a new Frames display tab. The work is exceptionally well tested (round-trip JSON/hash validation, receipt interop, rollback/batch semantics, template rendering) and the core logic — receipt-by-hash matching replacing positional pairing, the to_id=0 vs contract-creation disambiguation, and the nonce-domain guard — is sound. The only concrete problem I found is a template gating mismatch that leaves the Frames tab broken exactly in the 'declared only / results missing' state the PR itself builds a view for.
Issues
- 🟡
templates/transaction/transaction.html:391— Frames tab pane is gated on InternalTxCount while its nav pill is gated on IsFrameTx — see the thread on that line
Reviewed @ 4a013865
"Better to ask forgiveness than permission." — Grace Hopper
Three ways a tab could open onto nothing. A pane was rendered behind a guard of its own, which had to agree with the guard on the nav item above it for the tab to have anything to show. On the transaction page they disagreed - the frames and internal-tx panes were behind the internal-tx count while their nav items were behind the transaction's type and the block's trace - so the tab appeared, was clickable, and revealed a missing element. Panes are now unconditional: an inactive one is an empty div, and nothing can point at one that was never written. Bootstrap's Tab is constructed from the nav item, not from the pane it reveals. Given a pane it finds no parent tablist and show() throws, which skipped the rest of the handler - the address bar never followed the tab. The builder page read the clicked node rather than the nav item its handler is bound to, so a click that landed on a tab's icon read the attributes off the icon and got none.
A frame transaction's logs and calls were listed flat, so nothing said which of the transaction's frames produced them. The logs are the per-frame lists concatenated in frame order, so the per-frame counts partition them - and they are only claimed once they account for every log, since a partial set would shift each one after the gap onto the wrong frame. A client that decomposes the transaction traces one top-level call per executed frame, so each root and everything below it is that frame's; the shape is checked rather than assumed, because nothing specifies it. The client that ships the type today does neither for calls: it reports one placeholder for the whole transaction. The internal-tx tab said the data was unavailable, which reads as pruned - it now says the client did not break the transaction into its frames, and points at the frames themselves. Its tab no longer carries a count taken from the per-account rows either, which are built from the frames and are not a count of calls.
A token transfer is a decoded log, so it belongs to whichever frame emitted that log. The transfers are only the subset of logs that decoded as one, so they are keyed on the flat event index rather than on their own position - and several can share one, since an ERC1155 batch is a single log. The partition is now shared with the event attribution rather than repeated, so both surfaces agree on which frame owns which log by construction. A frame transaction that failed also showed "unknown" beside the status, as though a revert reason existed and could not be decoded. It has none: the transaction did not revert, one of its frames did, and the status now names it. The only-verify species was called "Verify", which read as "Verify Verify" beside the mode badge it shares a name with. It approves execution, so it says that.
Its transaction-level status was taken from the client's derived one, which is failed when any frame failed. That reads as a revert: as though the transaction came to nothing. It did not. A frame transaction only reaches the chain once its validation frames succeed, so one that is on chain ran and paid its fee, and what its other frames did stands. It now reads Complete when some frame did not succeed, and Success when they all did. Which frames did not, and whether they failed, were undone with their atomic batch or never ran at all, is on the status itself - the three counts are disjoint, so a frame that failed inside a batch is not also counted among the successes it took down. The transaction list says the same thing, from the type and the failure flag alone.
The detail panel listed the target first, while the row above it and every other address pair on the page read from the caller to the target.
…ts blobs The expiry verifier frame checks the deadline when the transaction executes, so once the transaction is on chain the deadline only says how much room it had left. Counting down to it from the present says nothing: a transaction included an hour ago with a thirty minute deadline was never late, and "expired 30 min. ago" suggested it was. It now reads as the distance from the inclusion time, and a deadline already gone by then - which a conforming client would not have included - is called out rather than shown as ordinary slack. The payer moved from a badge into a field beside the sender, so the address can be followed like any other on the page. Blobs were loaded only for type 3, but EIP-8141 gives a frame transaction blob hashes and a blob fee cap of its own, and its own EIP-7594 sidecar wrapper. The indexer already counted them whatever the type, so such a transaction offered a blob tab with nothing in it. What decides it now is whether the transaction has blobs, which is what the loader was reading anyway.
Three things a frame transaction carries that the page said nothing about. Its signature list is not a formality: the sender is an explicit field rather than something recovered from a signature, so the list is a set of authorisations the protocol checks before any frame runs - and an entry signed by another account is exactly how a paymaster agrees to be charged. A sponsored transaction showed a payer with nothing to explain it. Its EIP-8272 recent roots, which are what let a frame read a root while it runs, had no rendering at all. And a DEFAULT frame was labelled a deployment wherever it sat. That species name comes from the mempool's prefix-matching rules, which decide nothing outside the validation prefix; after it the same frame is a settlement. The state-change tab now names the accounts that had a part in the transaction - the sender, the fee recipient, and a payer that is not the sender - because a balance moving says nothing about why on its own. That applies to every transaction type, not only frame ones.
The state-change header spaced every item with a margin of its own, and the badges saying what changed ran on directly from the ones saying what the account was, so the two readings of the row blurred together. One gap rule spaces the row, and what changed sits against the right edge - which needed the chevron's own automatic margin pinned down, or it would have claimed the space and left the badges floating mid-row. The signature and recent-root lists abutted the frame list with only a rule between them and were set in a smaller type at the table defaults' padding, so they read as more frames in a different size. They now open on a tinted header like the phase and batch rows, after a gap, with the frame rows' padding.
The species badge is the first thing a reader looks at and the tooltip said only that the name was derived from the frame's mode and flags, which is a fact about where the label came from rather than about what the frame does. Each kind now explains itself: that a paymaster frame's approval is what moves the charge off the sender and that its signature entry authorises it, that an expiry frame reverting invalidates the whole transaction, that only SENDER frames may carry value, that a settlement frame runs after the operations once the real cost is known.
Hovering an address highlighted its other occurrences only inside an EL data table or the internal-tx tree. Everywhere else - the frames of a transaction, its state changes, the row it came from - the same account appeared several times with nothing tying the mentions together. The highlight still prefers the nearest list and now falls back to the page, because an address that appears twice on a page is the same account wherever it appears. The ENS swap reads a hook that only the address formatters emit, and the transaction page hand-rolled its anchors in nine places - the overview's from, to and payer rows among them. So an account resolved to a name in one part of the page and stayed hex in another. Every address on the page now goes through the formatter, which is also what gave those anchors the href the highlight matches on. The payer, the block's fee recipient and the frames' callers were also missing from the names resolved for the page, so they could not have been swapped even where the hook was present.
What signed a transaction was not shown at all, and a frame transaction's list had been sitting under its frames, where it read as one more thing about them. It is not: it is what authenticated the transaction, which every type has. So it is a tab of its own, last, and it covers all of them. An ordinary transaction carries one ECDSA signature and its sender is not stated anywhere - the address is recovered from it, and a signature that does not verify does not name the wrong sender, it makes the transaction invalid. A frame transaction names its sender outright and carries a list instead, where an account other than the sender agrees to be charged. Each entry expands to its parts. EIP-8141 orders a secp256k1 entry v || r || s, with v first, which is the opposite of the r || s || v an ordinary transaction is encoded with - so the raw bytes cannot be read by eye either way without being split. Bytes that are not the length their scheme expects are left whole rather than carved into fields that would be wrong.
Five findings from the automated review, confirmed against the code. A frame that fails on its own was marked rolled back, because a frame with no batch flag is a batch of one and any batch containing a failure rolls back. The page then showed it amber with a tooltip naming the frame itself as the cause. Only a frame that succeeded has anything to lose, so only those are marked now; the frame that failed and the ones that never ran are told by their own status. A frame transaction's status was stated from its frames and then overwritten two lines later by the client's derived one, so the same transaction read Complete once indexed and Failed when served from a client or rebuilt from blockdb. The inclusion-list decoder still read To() as the recipient and its absence as a creation - the assumption removed everywhere else on the page. Registering the target of a frame that never ran put an account in the index whose first sighting is a call that did not happen, funded by this block. Both the aggregates and the value transfers already require the frame to have run, so nothing needed it. And a frame participant showed a bare TYPE_6 on the address page, the call-type names having stopped at the last traced type.
This comment has been minimized.
This comment has been minimized.
…fferently A transaction whose decoded fields did not re-encode to the hash the client reported was rejected, and with it the client's whole response for that block: the indexer retried against another client and, when every client agreed, failed the block outright. On a chain where the disagreement is systematic - one client naming a frame field differently from this build - that costs every block rather than one transaction. The check now reports rather than decides. The transaction is indexed under the hash the chain knows it by, which is the right identity regardless: receipts, traces and every link into the transaction are keyed by it. What a disagreement puts in doubt is only what the decoder derives - the sender recovered from the signature, and whether the transaction creates a contract - and the warning names both hashes so the divergence can be chased.
A payload-available event asserts the node holds the envelope, but a client can fire it a moment before it will serve one - measured at under 50ms against prysm, consistently. The load returned 404, which the API layer maps to "no payload", which EnsureExecutionPayload reads as nothing to fetch. No error, no log, and no second announcement: the payload stayed missing for the whole unfinalized range, taking with it everything read from it, the block's transactions above all. Two layers now cover it. The announced load is retried across a window wide enough for the race, since a 404 immediately after the event contradicts the event rather than answering it. And when a payload does arrive, the block whose committed hash is that payload's parent hash is loaded if it is still missing - a payload naming a parent proves the payload behind it was revealed and can be served, where a block on top of it proves nothing, because post-EIP-7732 a block may extend a parent whose payload was never revealed.
EIP-8141's envelope is composable: EIP-8250 replaces the nonce with a keyed sequence and EIP-8272 appends recent root references, independently, so four shapes occur and which one a payload used is a property of the transaction rather than of the chain. txtypes now decodes all four and says which it found, so the shape is named on the page instead of assumed. Frame durability comes from txtypes too rather than being restated here. That brings EIP-7906's POST_TX with it: an assertion frame that fails reverts the entire execution body, which is not an atomic batch unwind, and computing durability from batch structure alone reported the frames before it as successes when nothing survived. What the envelope carries is now shown as well. The frames tab names the nonce keys a transaction is sequenced in, where it is sequenced in domains of its own rather than against the sender's account nonce, and lists the recent roots it declared. Storage on the protocol's own accounts is labelled for what it is: NONCE_MANAGER and RECENT_ROOTS are written by validation, not by any frame, and read as anonymous addresses otherwise.
Nonce keys were rendered in full decimal, inline, in the frames summary row. A key is a 256-bit value and EIP-8250 expects applications to derive it from something like a nullifier, so the ordinary case is the full width: two of them came to about 155 digits and wrapped the summary onto a second line. Sixteen are allowed in one transaction. Keys are hex now, which is both shorter and structured - the keys a generator produces turn out to be a sender address in the high bits and a counter in the low bits, which decimal hides completely. The summary row carries the count and the sequence, the keys themselves are listed below it alongside the recent roots with the full value and a copy button, and the details row abbreviates to both ends of the key and stops after four.
Signature entries are held to one line each, which an expanded panel inherited: a P256 signature is 128 bytes, so its raw value ran 258 characters off the side of the page. The cost was not confined to the panel. The list scrolls horizontally as a whole, so one open entry pushed the verify gas column of every collapsed entry out of view. The panel's own styling already asked for the value to break; the rule holding entries to one line is more specific and won. The panel cell now overrides it, so the raw value wraps within the box it is in and the list keeps its columns.
This comment has been minimized.
This comment has been minimized.
The s and qx values carried a note explaining what they are. Neither said anything the reader of a signature panel needs at that moment, and both lengthened rows that are already at the width of the page.
This comment has been minimized.
This comment has been minimized.
The state changes tab was gated on the call-traces bit of the block's data status rather than the state-changes bit. The two are stored independently, and they come apart exactly where it matters: a call trace that cannot be reconciled with a frame transaction's frames is discarded, while the state diff fetched alongside it is kept. Every such transaction lost its state changes tab while the data sat in the block store - reachable only by asking for the view by name, which is not something a reader would think to do. The condition is a named field now rather than a bit test spelled out in the template, so the two cannot be confused again.
isAtomicBatch lost its only caller when durability moved to txtypes, which computes it from the transaction itself. staticcheck fails the build on unused unexported functions, and it was the only thing failing.
SummaryAdds EIP-8141 frame transaction support to dora by moving the transaction core to ethpandaops/spamoor/txtypes (whose DecodeTx/UnmarshalJSONTx handle 0x06 and degrade unknown types to generic fields instead of dropping them), then threading frame-aware indexing, blockdb serialization, and display through the tx indexer, system-contract indexers and transaction-page handlers. The core logic — frame/result pairing, durability (atomic batch / POST_TX rollback) attribution, per-frame gas/values, payer-based fee charging, multi-target to_id 0 handling, and receipt-meta v2 with backward compatibility — is carefully implemented and extensively unit-tested; I traced the pinned spamoor dependency's actual semantics and found no broken assumptions or exit-path issues. The only concrete defect I found is a cross-page status inconsistency on the slot/block page. Issues
Reviewed @ |
Adds EIP-8141 frame transaction support to dora: indexing, persistence and display.
A frame transaction (type
0x06) is an ordered list of up to 64 calls rather than one. It has no recipient, no single value, no single gas limit and no transaction-level status of its own, and its fee may be settled by an account that is not the sender. Every one of those breaks an assumption dora was built on.Why this was urgent
Before this branch, a frames devnet produced an invisible hole in the index rather than degraded output:
ErrTxTypeNotSupportedfor0x06, so each one was logged and dropped.if txs != nil, so the fallback was unreachable on exactly the blocks that needed it.The same hole swallowed system-contract requests (deposits, withdrawals, consolidations) made from a frame transaction, whose sender lookup could not decode one either.
Approach
go-ethereum cannot be extended here —
types.TxDatadeclares unexported methods, so0x06cannot be implemented from outsidecore/types. The transaction core moves togithub.com/ethpandaops/spamoor/txtypes, which represents the type and degrades unknown future types to their generic fields instead of dropping them.Log,AccessListandSetCodeAuthorizationare aliases of the go-ethereum types, so the log pipeline and the six system-contract indexers were unaffected.Commits are ordered so each is reviewable on its own; the first is behaviour-neutral for types 0–4.

What it does
Indexing. Frames are resolved from the transaction and paired with the per-frame results the receipt reports. Four things the one-recipient shape got wrong:
To()reports the first SENDER frame's target, and is nil when there is no SENDER frame. That nil was read as a contract creation, inventing a contract address from the sender and nonce. Frame transactions keepto_id0 — the id no account carries — and reach address pages through their per-account rows.el_accounts.last_nonce, which it would have corrupted.A frame that succeeded inside an atomic batch that later rolled back keeps its status and gas, but its state changes are gone. Those are marked so their value is not recorded as having moved.
Persistence.
el_tx_framesholds one row per frame, with two counts that make the rest of the transaction attributable:log_countpartitions the flat event index (logs are the per-frame lists concatenated in frame order), andtrace_countpartitions the call trace the same way. Frame receipt content also goes to blockdb so a frame transaction stays legible after its relational rows are pruned — appended behind the receipt metadata's version field rather than in a new section, because the per-transaction index entry is a fixed 100 bytes and a fifth section pointer would make every object already written unreadable.Display. The transaction page names the transaction by its validation prefix (self-relayed / sponsored / account deployment), shows the payer when it differs from the sender, renders the frames as a table with both gas dimensions, marks atomic batches, and distinguishes all four per-frame outcomes — a skipped frame never ran, and a rolled-back one is not the success it still reports. It also shows the expiry deadline and whether the nonce is an account nonce at all.
The transaction list, address page and slot page previously fell through to a contract-creation link whenever a recipient was absent, so a frame transaction would have rendered as a deployment at an invented address. They now say how many targets it has.
Call traces
Measured against
ethpandaops/ethrex:eip8141-v2-lenientrather than assumed: it does not decompose frame transactions. A four-frame transaction traces to a single self-addressed root with nocallsat all. Nothing specifies what a per-frame trace would look like — the callTracer is absent fromexecution-apis, and EIP-8141's trace language is about mempool validation.So a transaction's roots decode from either a lone object or a list, and a frame mapping is claimed only once it verifies: one root per executed frame, each addressing that frame's target. A client that starts emitting per-frame roots needs no change here. Until one does, the frames themselves are the decomposition, and the placeholder root is discarded rather than shown as a call that was never made.
Also found: the root of a frame transaction's trace reports
gasUsed: 0while carrying the real cost in EIP-8037'sregularGasUsed, so gas now falls back to the sum of the two dimensions.A receipt decode fix, upstream
txtypescould not decode a receipt whose frames emitted logs. A frame's logs arereported inside the receipt that contains them, so they carry none of the position
fields go-ethereum's
types.Logrequires — and since a block receipts response isdecoded as one unit, a single log-emitting frame transaction cost its whole block an
EL index:
Fixed in
txtypes(spamoord35c83e), which now decodes logs permissively and givesa nested one the position of the receipt around it; a log that reports its own position
keeps it. The pin here is bumped to that commit, and a test in this repo pins the
behaviour dora depends on rather than trusting the pin to hold it.
Verification
Run against a live kurtosis frames devnet: frame transactions index, render, filter and appear correctly on the slot and address pages. Screenshots below.
The dependency is pinned to a commit on spamoor's unmerged
pk910/eip-8141branch and wants a bump once that lands on master.