Skip to content

Support EIP-8141 frame transactions - #855

Open
pk910 wants to merge 35 commits into
masterfrom
pk910/frame-transactions
Open

Support EIP-8141 frame transactions#855
pk910 wants to merge 35 commits into
masterfrom
pk910/frame-transactions

Conversation

@pk910

@pk910 pk910 commented Aug 27, 2026

Copy link
Copy Markdown
Member

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:

  • The tx indexer reads transactions from the beacon block payload first. go-ethereum returns ErrTxTypeNotSupported for 0x06, so each one was logged and dropped.
  • The EL fallback never ran: the extraction returned a non-nil empty slice and the caller gated on if txs != nil, so the fallback was unreachable on exactly the blocks that needed it.
  • Receipts then matched nothing, and the block was committed short of transactions.

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.TxData declares unexported methods, so 0x06 cannot be implemented from outside core/types. The transaction core moves to github.com/ethpandaops/spamoor/txtypes, which represents the type and degrades unknown future types to their generic fields instead of dropping them. Log, AccessList and SetCodeAuthorization are 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.
image

02-tx-list 03-slot 04-address

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 keep to_id 0 — the id no account carries — and reach address pages through their per-account rows.
  • Under EIP-8250 only the zero nonce key aliases the sender's account nonce. Any other key is no longer written to el_accounts.last_nonce, which it would have corrupted.
  • The fee is charged to the payer the receipt names, not the sender. For a sponsored transaction — the case the field exists for — those differ.
  • Value moves per frame, so each value-bearing frame contributes its own transfer rather than 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 gas, but its state changes are gone. Those are marked so their value is not recorded as having moved.

Persistence. el_tx_frames holds one row per frame, with two counts that make the rest of the transaction attributable: log_count partitions the flat event index (logs are the per-frame lists concatenated in frame order), and trace_count partitions 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-lenient rather than assumed: it does not decompose frame transactions. A four-frame transaction traces to a single self-addressed root with no calls at all. Nothing specifies what a per-frame trace would look like — the callTracer is absent from execution-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: 0 while carrying the real cost in EIP-8037's regularGasUsed, so gas now falls back to the sum of the two dimensions.

A receipt decode fix, upstream

txtypes could not decode a receipt whose frames emitted logs. A frame's logs are
reported inside the receipt that contains them, so they carry none of the position
fields go-ethereum's types.Log requires — and since a block receipts response is
decoded as one unit, a single log-emitting frame transaction 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 in txtypes (spamoor d35c83e), which now decodes logs permissively and gives
a 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-8141 branch and wants a bump once that lands on master.

pk910 added 15 commits August 26, 2026 15:47
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.
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.

@redpandabot redpandabot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:391Frames 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

Comment thread templates/transaction/transaction.html
pk910 added 12 commits August 28, 2026 20:53
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.
@redpandabot

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.
pk910 added 4 commits August 29, 2026 01:49
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.
@redpandabot

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.
@redpandabot

This comment has been minimized.

pk910 added 2 commits August 29, 2026 04:17
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.
@redpandabot

redpandabot Bot commented Aug 29, 2026

Copy link
Copy Markdown

Summary

Adds 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

  • 🟢 handlers/slot.go:1213slot/block page still labels a frame tx with a failed frame as "Failed" — getSlotPageTransactions marks frame txs as multi-target but still sets txData.Reverted = elTx.RevertID > 0 (line 1276) with no exception, and a frame tx with a failed frame is indexed with RevertID=RevertIDUnknown (1) because its derived receipt status is 0. So templates/slot/transactions.html shows the red "Failed / execution reverted" badge for a transaction the PR's own status model (addr and /transactions pages, applyFrameTxStatus) calls "Complete" — the transaction ran and paid, only a frame failed. Give the slot page the same IsMultiTarget status treatment the other lists got.

Reviewed @ 461820a7
"Better to ask forgiveness than permission." — Grace Hopper

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant