EIP-8141: Frame Transaction

Hello! I have a quick question in the context of the early feedback for EIP-7906.
One of the main reasons that EIP had to explicitly introduce a new POST_TX frame is to allow a “validation failed, state changes reverted, gas fee still paid” behaviour, which appears somewhat similar to what has been proposed as gas “Guarantor” mode in PR #11555 and later PR #11681, allowing the sender’s VERIFY frame to fail at any time.

Is there still any interest in introducing this or some comparable mechanism in EIP-8141?
It seems that even without EIP-7906, there could be a benefit in having a frame that can confidently revert all transaction changes across all atomic batches.
It might also allow EIP-7906 to focus on exposing the state writes journal, without trying to extend EIP-8141 with the functionality that it needs from it.

Thank you.

This draft looks great, and I’m glad to see Ethereum moving off of hard-wired ECDSA signatures. One thing I’d like to add to the discussion.

The Security Considerations section already flags Arbitrary Signature Malleability:

When an ARBITRARY signature has empty msg, its raw bytes are elided from the canonical signature hash and can therefore be changed without changing TXPARAM(0x08). […] Custom verifiers should enforce canonical encodings and reject unused bytes.

— EIP-8141, Security Considerations

That section frames the risk as transaction-hash malleability. I want to point out a second consequence it doesn’t discuss: those same elided bytes are still billed, so the malleability can be used to grief the payer. This is related to the malleability of VERIFY frame data that @thegaram33 raised earlier:

the input data to the sponsor is intentionally left malleable […] this malleability seems quite risky

— @thegaram33, post #21

The billing gap. In existing transaction types the signer commits to a transaction-level gas limit, so a signer bounds their own gas exposure. Frame transactions have per-frame gas_limits but no signed transaction-level gas limit, and gas is computed over the whole transaction. In particular, standard_gas_limit includes:

signature_data_cost = sum(
    calldata_cost(sig.signer) + calldata_cost(sig.msg) + calldata_cost(sig.signature)
    for sig in tx.signatures
)

so the raw signature bytes are billed as calldata, but compute_sig_hash elides those bytes whenever msg is empty, as @nlordell noted:

the current spec only seems to omit signature bytes from signature frames where msg = 0

— @nlordell, post #163

(the specific rule is if len(sig.msg) == 0: sig.signature = Bytes() before hashing). So part of what determines the payer’s charge (the elided bytes of an empty-msg entry) is not covered by the signature hash and can be changed without invalidating the signature.

The attack. Given a frame transaction that carries an empty-msg ARBITRARY entry, a party in the transaction’s path can pad that entry’s signature bytes. The protocol performs no cryptographic check on ARBITRARY signatures, so this passes protocol validation and leaves compute_sig_hash unchanged, but it inflates signature_data_cost, and the extra gas is collected from the payer (this could be the sender, or the sponsor). Fixed-length schemes (secp256k1, P256) are length-validated, so their elided bytes can’t be padded, but the ARBITRARY scheme has no length bound, so an attacker can pad these bytes.

Although the protocol allows this attack, it is possible to mitigate it at the contract level. If the transaction’s verifier (specified in the VERIFY frame) follows the spec’s advice and rejects unused bytes (checking SIGPARAM(0x03) = len(signature)), the padded transaction is simply invalid.

But the attack works if the verifier simply looks at a fixed-length prefix and authenticates without asserting the total length. It doesn’t seem realistic to think that every user-deployed verifier will remember to check the signature length in the contract.

A realistic attacker is a malicious block builder or a relay. A malicious builder would actually collect priority fee on the inflated gas_used.

I see a few simple ways to fix this.

  • (a) Change the recommendation to “reject unused bytes” from advice to a hard requirement. Even if it’s a “requirement” I assume some people will get it wrong.
  • (b) Commit to len(sig.signature) in compute_sig_hash even when the bytes themselves are elided, so padding changes the sig hash.
  • (c) Add a signed transaction-level gas_limit (as in every other tx type), with validity gas_limit >= computed total. This is the most like traditional Ethereum transactions, but it might cause issues with multi-party flows where a sponsor/paymaster signature is appended after the sender signs. If the sender can’t predict the added calldata size the sender would need to set a very high gas_limit.

I’d be curious to hear what everyone else thinks.

Thanks, this is a good point. I think we could also just commit to the hash of the data in the sig hash?

Which data do you mean?

You can’t commit to the hash of the signature data in the sig_hash because then there would be a circularity. Since you may be signing the sig_hash, the signature itself (or a hash of the signature itself) cannot contribute to the sig_hash. The sig_hash must be computed before the signature if that’s what you’re signing. This is why including the signature length is a reasonable fix, because you can figure out the signature length before computing the sig_hash, but you can’t compute the signature itself before computing the sig_hash.

If you commit to the hash of other data, you don’t really close the gap of being able to pad the signature bytes.

Ah sorry you’re right. The canonical sig hash is tricky :sweat_smile: in that case you’re right, committing to the length is probably best we can do.

Are contracts intentionally allowed to reason about the structure of frames that have not been executed yet?

As I read EIP-8141, structural information for future frames things like mode, flags, target, value, gas limits and calldata appears to be introspectable, while execution results such as status and gas_used are only available once a frame has executed.

Is that distinction intentional, and can applications safely rely on forward structural introspection?

My reading is that it may be deliberate: the structural fields are already fixed as part of the transaction, while execution results simply do not exist yet.

Curious if that matches the intended design.

Great work. I think it would be useful to coordinate the resource and fee fields of EIP-8141 with the direction explored in EIP-7999, particularly given the ambition for frame transactions to provide an enduring transaction format. This could make the transition to a multidimensional fee market more seamless. Some preparation is already in place in EIP-8141, e.g., in the form of the explicit per-frame state dimension with limits and fees carried as nested lists, and some can still be developed further. Two adjustments are described below, followed by a question/discussion about how the frame design relates to multidimensional EVM gas accounting.

Use byte limits instead of gas limits

The transaction should ideally specify its state limit in bytes. The same would go for a future data limit: more generally, capacity is best specified in native resource units.

The first benefit is that we can protect users across fork boundaries when the gas cost of a resource is adjusted. If the limit is denominated in gas and the gas cost rises, a previously sufficient limit may become insufficient, causing the transaction to fail. This kind of failure is already quantified for the Glamsterdam repricing candidates in the repricing analysis, which distinguishes transactions that fail at their original gas limit but succeed with a higher one.

It is not necessary to maintain the denomination in state bytes within the EVM. For simplicity, we can convert a specified state_bytes_limit to state_gas_limit immediately when processing the transaction:


state_gas_limit = CPSB * state_bytes_limit

The existing accounting can then proceed unchanged. The transaction would still need to satisfy its fee limit, but a change in CPSB would no longer reduce the amount of state creation permitted by its state limit. Wallets would also no longer need to read the current CPSB merely to express that limit correctly.

A second benefit is that byte limits can have a smaller encoding, taking up less space in the transaction. A third is that bytes are a more intuitive unit for state creation, both for users and developers. Looking further ahead, state creation may eventually interact with execution only through the transaction’s total fee. At that point, expressing state limits in gas would only be confusing.

Use a unified max fee

The transaction should ideally specify one unified max_fee, denominated in wei, as in EIP-7999. As explained in its economic rationale, separate max fees per resource can prevent inclusion when relative resource prices change, even though the user’s total budget is sufficient. Spare max fees assigned to one resource cannot compensate for an insufficient max fee on another.

A unified max_fee lets the transaction remain eligible for inclusion as long as its total budget covers the required costs. It also more directly expresses what users generally care about: how much they are willing to pay for the transaction. These benefits grow as the number of separately priced resources increases, but already apply to the separate max fees for regular gas and blob gas. A single transaction-wide max fee is also compatible with keeping each frame’s resource limits separate.

A unified max_fee would also be a convenient anchor for the mempool to reserve against the payer: the worst-case cost is then a signed transaction field rather than a quantity derived from limits and per-unit fees (and, for blob-carrying transactions, from the current blob base fee).

Further note that if state becomes separately priced, a max_fee_per_state_byte would match a state_bytes_limit more naturally than a max_fee_per_state_gas. Separate max fees would however retain the economic inefficiency described above, so I would favor a unified max_fee.

Implications for multidimensional EVM gas accounting

Finally, I think it would be useful to discuss how EIP-8141 gas accounting relates to the EVM gas-accounting designs discussed for EIP-7999.

As I understand the current design, each transaction frame has its own execution and state budgets. Within a frame, CALL(g) controls execution gas, while all nested calls share the frame’s state budget. Execution gas cannot cover state charges when that budget runs out, unlike under the EIP-8037 reservoir mechanism. Thus, a paymaster could for example reserve resources for its post-operation work in a later frame, without the user’s earlier frame being able to consume them.

Within an ordinary contract call, the situation is different. As Jochem noted, a successful subcall can consume the enclosing frame’s remaining state budget, leaving its caller unable to create state afterward even if it retained sufficient execution gas. This matters for existing contracts that reserve gas for that purpose, and it also gives an untrusted callee a way to block state creation by its caller. Moving the later work into another transaction frame can provide the necessary reservation where the application supports that structure.

I would be interested in hearing how these considerations were weighed when developing EIP-8141. It would be useful to discuss which resource-reservation needs are met by separate transaction frames, which remain within ordinary calls, and what this suggests for the accounting model we ultimately choose for Ethereum.

These choices have implications for several of the models being considered for EIP-7999, including S and M+O_S. Applying subfee accounting (S) would mean that each transaction frame executes against a scalar gas budget usable for execution, state, and data (data here referring to data metered during execution, rather than calldata, which EIP-8141 prices at the intrinsic level outside the frame budgets).

Multidimensional accounting with subfee overflow (M+O_S) would instead retain dedicated resource budgets and add an overflow accounted for using subfee conversions. In that design, the legacy scalar gas interface would control the overflow. Both approaches would therefore require changes to EIP-8141’s currently specified accounting, including what GAS reports and what CALL(g) controls.

1 Like

a caller can already reserve state budget inside a frame: make the untrusted call through a self call that checks TXPARAM 0x0C after it and reverts if too little is left, the revert refills what the callee used. what this doesn’t cover is deployed contracts that reserve by passing less gas. in a legacy tx under 8037 the kept gas still pays for state once the reservoir is empty, inside a frame it can’t. so the same contract behaves differently depending on the envelope, was that intended?

TXPARAM(0x00) halts in any context other than a frame transaction. The only way in the EVM (without resorting to a subcall) to check whether it’s in a frame transaction is ORIGIN == ENTRY_POINT, and it only works in VERIFY and DEFAULT modes. Is this by design? If so, should the Specification state that ORIGIN == ENTRY_POINT never holds in any other context, and should the Rationale explain it?

Or should TXPARAM(0x00) halting be relaxed instead?

Tangent: ENTRY_POINT at 0xaa falls into the implicit L1 precompile range, is this intended? (EDIT: the range is implicit and not strictly precompile-only so this seems a non-issue)

1 Like

i believe this discussion is also necessary. channelling some resource-reservation needs to frames will alleviate the uncertainty of block capacity that the overflow and reservoir models cause during block processing, since the limit for each resource is always kept separate in frames. and it is also possible that frames can serve some existing reservation needs better as a protocol-enforced boundary, as other sorts of callers apart from paymasters wouldn’t have to bother about correctly structuring their calls into an untrusted callee.

one approach that might be useful is identifying contracts that exhibit some reservation behaviour, and then checking how well they fit the structural constraints of frames. for example: since frames are fully declared pre-signing and TSTORE is cleared between frames, the calls of a contract with a reservation need that can be satisfied by frames should have operands that can be derived only from its bytecode or the call data.

1 Like

eth_call with from set to 0xaa already gives ORIGIN == ENTRY_POINT outside a frame tx (checked on mainnet geth), so a “never holds” sentence could only cover included txs.
if the halt stays, is the intended way a staticcall with a small fixed gas to a helper that runs TXPARAM(0x00) and returns it? the caller would check returndatasize, not the success flag, since a call to an empty address also succeeds.
@matt the halt came in Update EIP-8141: 8141 nits by lightclient · Pull Request #11870 · ethereum/EIPs · GitHub without a stated reason, was that to keep these ops inert outside frame txs?
which contract needs this in a SENDER frame? the frame accounts i looked at pick the path by CALLER (ENTRY_POINT or self).

1 Like

I’m looking at “Example 3: Sponsored Transaction (Fee Payment in ERC-20)” and wondering if it’s missing a frame between frame 2 and 3.

If “transfer(Sponsor, fees)” fails (e.g. the sender front-ran it by emptying their balance), frame 3 still runs. The user gets their call executed for free at the sponsor’s expense. That’s worse than the gas loss the frontrunning note mentions. Making frames 2+3 an atomic batch doesn’t fix it: a revert in frame 3 would then also undo the fee transfer.

By adding a transfer-success-check frame between frames 2 and 3 and setting the atomic batch flag on the new frame, we can achieve the desired “don’t execute call to target if sponsor has not been paid”. If the transfer failed, the new frame reverts and the batch skips the call to the target addr (formerly frame 3).

I’m new to frame txs. Does this make sense? Has it been intentionally omitted to keep the example simple?

A mempool dependency that EIP-8151 adds to ecrecover. It came up as Q6 of a pre-ERC on adopting code via SETCODEFROM (Pre-ERC: Adoption Profile and Reference Kernel for EOAs Adopting Code via SETCODEFROM), and @colinlyguo agreed it needs the EIP-8141 authors (Pre-ERC: Adoption Profile and Reference Kernel for EOAs Adopting Code via SETCODEFROM - #4 by colinlyguo), so I’m bringing it here.

The dependency. Under EIP-8151 (CFI for Hegotá), ecrecover reads the raw code of the recovered address and returns empty output when that code is regular code, i.e. neither empty nor a 7702 delegation indicator. So a validation prefix that calls ecrecover now depends on the code of a third-party account, the recovered address. With EIP-8298, that account can adopt regular code, after which the same call returns empty output and the validation result flips.

Where the current text stands. The policy summary allows dependencies on transaction fields, the expiry timestamp, the sender’s nonce, code and storage, the factory, the paymaster, and the code of contracts reached via CALL*/EXTCODE*, and says any other dependency on third-party mutable state must be rejected. The ecrecover read is none of those: it happens inside the precompile, and the recovered address is usually an EOA. Read literally, the rule rejects every prefix that recovers a non-sender address once EIP-8151 and EIP-8298 are both active. Read as intended, the dependency is simply untracked: Revalidation names code changes for the sender, the payer and canonical paymaster instances, and a recovered address is not among them.

Two properties that shape a fix.

  1. The address is known only when the precompile returns, so it can’t be derived from the frame structure or from the trace’s CALL*/EXTCODE* targets. It would have to be recorded from the trace, as sender storage slots are.
  2. The canonical paymaster authorizes with a single secp256k1 signer via ecrecover, and it is admitted by code match and exempt from the trace rules, so trace recording alone wouldn’t cover it. Its signer’s code would need to be added to the paymaster-specific revalidation list explicitly.

Bounding. One adoption at a signer address invalidates every pending transaction whose validation recovered that address, e.g. everything sponsored by a canonical paymaster instance whose signer migrates. EIP-8298’s rule bounds the adopter’s own pending transactions, not those of others that recovered it. The closest precedent seems to be EIP-8298’s rule for DELEGATECALL code (track the code hash as a dependency, cap the pending transactions that depend on it), with the recovered address in place of the code hash.

Question. Should the mempool section (a) record recovered addresses as a tracked dependency, revalidate when their code changes, and cap pending transactions per recovered address, and/or (b) say that signature verifiers used in validation must not depend on account state, which under EIP-8151 rules out ecrecover for anything but the sender? The pre-ERC takes (b) for its own verifiers (ERC-7913 only says SHOULD), but the canonical paymaster needs (a) either way.

One extra case for this check: ERC-20 transfer can return false without reverting, so a successful frame status alone could still allow unpaid work. The check needs evidence of successful token payment.

I added a token-behavior fixture covering false/revert and target failure. Seven ordinary-EVM tests pass; frame-client verification remains outstanding.