--- title: Frame Transaction description: Add frame abstraction for transaction validation, execution, and gas payment author: Vitalik Buterin (@vbuterin), lightclient (@lightclient), Felix Lange (@fjl), Yoav Weiss (@yoavw), Alex Forshtat (@forshtat), Dror Tirosh (@drortirosh), Shahaf Nacson (@shahafn) discussions-to: <URL> status: Draft type: Standards Track category: Core created: 2026-01-22 requires: 2718, 4844 --- ## Abstract Add a new transaction whose validity and gas payment can be defined abstractly. Instead of relying solely on a single ECDSA signature, accounts may freely define and interpret their signature scheme using any cryptographic system. ## Motivation This new transaction provides a native off-ramp from the elliptic curve based cryptographic system used to authenticate transactions today, to post-quantum (PQ) secure systems. In doing so, it realizes the original vision of account abstraction: unlinking accounts from a prescribed ECDSA key and support alternative fee payment schemes. The assumption of an account simply becomes an address with code. It leverages the EVM to support arbitrary *user-defined* definitions of validation and gas payment. ## Specification ### Constants | Name | Value | | ------------------------- | --------------------------------------- | | `FRAME_TX_TYPE` | `0x06` | | `FRAME_TX_INTRINSIC_COST` | `15000` | | `ENTRY_POINT` | `address(0xaa)` | | `MAX_FRAMES` | `10^3` | ### Opcodes | Name | Value | | -------------- | ------ | | `APPROVE` | `0xaa` | | `TXPARAMLOAD` | `0xb0` | | `TXPARAMSIZE` | `0xb1` | | `TXPARAMCOPY` | `0xb2` | ### New Transaction Type A new [EIP-2718](./eip-2718) transaction with type `FRAME_TX_TYPE` is introduced. Transactions of this type are referred to as "Frame transactions". The payload is defined as the RLP serialization of the following: ``` [chain_id, nonce, sender, frames, max_priority_fee_per_gas, max_fee_per_gas, max_fee_per_blob_gas, blob_versioned_hashes] frames = [[mode, target, gas_limit, data], ...] ``` If no blobs are included, `blob_versioned_hashes` must be an empty list and `max_fee_per_blob_gas` must be `0`. #### Modes There are three modes: | Mode | Name | Summary | | ---- | -------------- | --------------------------------------------------------- | | 0 | `DEFAULT` | Execute frame as `ENTRY_POINT` | | 1 | `VERIFY` | Frame identifies as transaction validation | | 2 | `SENDER` | Execute frame as `sender` | #### `DEFAULT` Mode Frame executes as regular call where the caller address is `ENTRY_POINT`. ##### `VERIFY` Mode Identifies the frame as a validation frame. Its purpose is to *verify* that a sender and/or payer authorized the transaction. It must terminate execution with `APPROVE`. Any other result will cause the whole transaction to be invalid. The execution behaves the same as `STATICCALL`, state cannot be modified. Frames in this mode will have their data elided from signature hash calculation and from introspection by other frames. ##### `SENDER` Mode Frame executes as regular call where the caller address is `sender`. This mode effectively acts on behalf of the transaction sender and can only be used after explicitly approved. #### Constraints Some validity constraints can be determined statically. They are outlined below: ```python assert tx.chain_id < 2**256 assert tx.nonce < 2**64 assert len(tx.frames) > 0 and len(tx.frames) <= MAX_FRAMES assert len(tx.sender) == 20 assert tx.frames[n].mode < 3 assert len(tx.frames[n].target) == 20 or tx.frames[n].target is None ``` #### Receipt The `ReceiptPayload` is defined as: ``` [cumulative_gas_used, payer, [frame_receipt, ...]] frame_receipt = [status, gas_used, logs] ``` `payer` is the address of the account that paid the fees for the transaction. `status` is the return code of the top-level call. #### Signature Hash With the frame transaction, the signature may be at an arbitrary location in the frame list. In the canonical signature hash any frame with mode `VERIFY` will have its data elided: ```python def compute_sig_hash(tx: FrameTx) -> Hash: for i, frame in enumerate(tx.frames): tx.frames[i].data = Bytes() return keccak(rlp(tx)) ``` ### New Opcodes #### `APPROVE` opcode (`0xaa`) The `APPROVE` opcode is like `RETURN (0xf3)`. It exits the current context successfully, but with a status code beyond the traditional `0` fail and `1` success via the `scope` operand. ##### Stack | Stack | Value | | ---------- | ------------ | | `top - 0` | `offset` | | `top - 1` | `length` | | `top - 2` | `scope` | ##### Scope Operand The scope operand must be one of the following values: 1. `0x0`: Approval of execution - the sender contract approves future frames calling on its behalf. Only valid when `frame.target` equals `tx.sender`. 2. `0x1`: Approval of payment - the contract approves paying the total gas cost for the transaction. 3. `0x2`: Approval of execution and payment - combines both `0x0` and `0x1`. Any other value results in an exceptional halt. ##### Status Code The status of a call returning with `APPROVE` has three new potential status codes. | Code | Result | Description | | ---- | -------------------- | ------------------------------------------------ | | 0 | `FAIL` | Call reverted | | 1 | `SUCCESS` | Call completed successfully | | 2 | `APPROVED_EXECUTION` | Call approved execution successfully | | 3 | `APPROVED_PAYMENT` | Call approved payment successfully | | 4 | `APPROVED_BOTH` | Call approved execution and payment successfully | *Note: codes `0` and `1` already exist today and are replicated here for completeness.* #### `TXPARAM*` opcodes The `TXPARAMLOAD` (`0xb0`), `TXPARAMSIZE` (`0xb1`), and `TXPARAMCOPY` (`0xb2`) opcodes follow the pattern of `CALLDATA*` / `RETURNDATA*` opcode families. Gas cost follows standard EVM memory expansion costs. Each `TXPARAM*` opcode takes two extra stack input values before the `CALLDATA*` equivalent inputs. The values of these inputs are as follows: | `in1` | `in2` | Return value | Size | | ----- | ----------- | ------------------------------------ | ------- | | 0x00 | must be 0 | current transaction type | 32 | | 0x01 | must be 0 | `nonce` | 32 | | 0x02 | must be 0 | `sender` | 32 | | 0x03 | must be 0 | `max_priority_fee_per_gas` | 32 | | 0x04 | must be 0 | `max_fee_per_gas` | 32 | | 0x05 | must be 0 | `max_fee_per_blob_gas` | 32 | | 0x06 | must be 0 | max cost (basefee=max, all gas used, includes blob cost and intrinsic cost) | 32 | | 0x07 | must be 0 | `len(blob_versioned_hashes)` | 32 | | 0x08 | must be 0 | `compute_sig_hash(tx)` | 32 | | 0x09 | must be 0 | `len(frames)` | 32 | | 0x10 | must be 0 | currently executing frame index | 32 | | 0x11 | frame index | `target` | 32 | | 0x12 | frame index | `data` | dynamic | | 0x13 | frame index | `gas_limit` | 32 | | 0x14 | frame index | `mode` | 32 | | 0x15 | frame index | `status` (exceptional halt if current/future) | 32 | Notes: - 0x03 and 0x04 have a possible future extension to allow indices for multidimensional gas. - The `status` field (0x16) returns `0` for failure or `1` for success. - Out-of-bounds access for frame index (`>= len(frames)`) and blob index results in an exceptional halt. - Invalid `in1` values (not defined in the table above) result in an exceptional halt. - The `data` field (0x12) returns size 0 value when called on a frame with `VERIFY` set. ### Behavior When processing a frame transaction, perform the following steps. Perform stateful validation check: - Ensure `tx.nonce == state[tx.sender].nonce` Initialize with transaction-scoped variables: - `payer_approved = false` - `sender_approved = false` Then for each call frame: 1. Execute a call with the specified `mode`, `target`, `gas_limit`, and `data`. - If `target` is null, set the call target to `tx.sender`. - If mode is `SENDER`: - `sender_approved` must be `true`. If not, the transaction is invalid. - Set `caller` as `tx.sender`. - If mode is `DEFAULT` or `VERIFY`: - Set the `caller` to `ENTRY_POINT`. - The `ORIGIN` opcode returns frame `caller` throughout all call depths. 2. If the frame exits with `1 < status < 5`, update approval state: - `2` (execution approval): If `target = tx.sender`, set `sender_approved = true`. - If `sender_approved` is already set, revert the frame. - `3` (payment approval): If `payer_approved` is `false`, increment the sender's nonce, collect the total gas cost of the transaction from `target`, and set `payer_approved = true`. - If `target` has insufficient balance, the transaction is invalid. - If `sender_approved == false` and status is `3`, revert the frame. - If `sender_approved == true` and status is `4`, revert the frame. - `4` (both): Apply rule for status `2` then for status `3`. 3. If frame has mode `VERIFY` and the frame did not terminate with status `1 < status < 5`, the transaction is not valid. After executing all frames, verify that `payer_approved == true`. If it is, refund any unpaid gas to the gas payer. If it is not, the whole transaction is invalid. Note: it is implied by the handling that the sender must approve the transaction *before* the payer and that once `sender_approved` or `payer_approved` become `true` they cannot be re-approved or reverted. #### Frame interactions A few cross-frame interactions to note: - For the purposes of gas accounting of warm / cold state status, the journal of such touches is shared across frames. - Discard the `TSTORE` and `TLOAD` transient storage between frames. #### Gas Accounting The total gas limit of the transaction is: ``` tx_gas_limit = FRAME_TX_INTRINSIC_COST + calldata_cost(rlp(tx.frames)) + sum(frame.gas_limit for all frames) ``` Where `calldata_cost` is calculated per standard EVM rules (4 gas per zero byte, 16 gas per non-zero byte). The total fee is defined as: ``` tx_fee = tx_gas_limit * effective_gas_price + blob_fees blob_fees = len(blob_versioned_hashes) * GAS_PER_BLOB * blob_base_fee ``` The `effective_gas_price` is calculated per EIP-1559 and `blob_fees` is calculated as per EIP-4844. Each frame has its own `gas_limit` allocation. Unused gas from a frame is **not** available to subsequent frames. After all frames execute, the gas refund is calculated as: ``` refund = sum(frame.gas_limit for all frames) - total_gas_used ``` This refund is returned to the gas payer (the `target` that called `APPROVE(0x1)` or `APPROVE(0x2)`) and added back to the block gas pool. *Note: This refund mechanism is separate from EIP-3529 storage refunds.* ## Rationale ### Canonical signature hash The canonical signature hash is provided in `TXPARAMLOAD` to simplify the development of smart accounts. Computing the signature hash in EVM is complicated and expensive. While using the canonical signature hash is not mandatory, it is strongly recommended. Creating a bespoke signature requires precise commitment to the underlying transaction data. Without this, it's possible that some elements can be manipulated in-the-air while the transaction is pending and have unexpected effects. This is known as transaction malleability. Using the canonical signature hash avoids malleability of the frames other than `VERIFY`. The `frame.data` of `VERIFY` frames is elided from the signature hash. This is done for two reasons: 1. It contains the signature so by definition it cannot be part of the signature hash. 2. In the future it may be desired to aggregate the cryptographic operations for data and compute efficiency reasons. If the data was introspectable, it would not be possible to aggregate the verify frames in the future. 3. For gas sponsoring workflows, we also recommend using a`VERIFY` frame to approve the gas payment. Here, the input data to the sponsor is intentionally left malleable so it can be added onto the transaction after the `sender` has made its signature. Notably, the `frame.target` of `VERIFY` frames is covered by the signature hash, i.e. the `sender` chooses the sponsor address explicitly. ### Payer in receipt The payer cannot be determined statically from a frame transaction and is relevant to users. The only way to provide this information safely and efficiently over the JSON-RPC is to record this data in the receipt object. ### No authorization list The EIP-7702 authorization list heavily relies on ECDSA cryptography to determine the authority of accounts to delegate code. While delegations could be used in other manners later, it does not satisfy the PQ goals of the frame transaction. ### No access list The access list was introduced to address a particular backwards compatibility issue that was caused by EIP-2929. The risk-reward of using an access list successfully is high. A single miss, paying to warm a storage slot that does not end up getting used, causes the overall transaction cost to be greater than had it not been included at all. Future optimizations based on pre-announcing state elements a transaction will touch will be covered by block level access lists. ### No value in frame It is not required because the account code can send value. ### Examples #### Example 1: Simple Transaction | Frame | Caller | Target | Data | Mode | | ----- | -------------- | ------------- | --------- | ------------ | | 0 | ENTRY_POINT | Null (sender) | Signature | VERIFY | | 1 | Sender | Target | Call data | SENDER | Frame 0 verifies the signature and exits with `APPROVE(0x2)` to approve both execution and payment. Frame 1 executes and exits normally via `RETURN`. The mempool can process this transaction with the following static validation and call: - Verify that the first frame is a `VERIFY` frame. - Verify that the call of frame 0 succeeds, and does not violate the mempool rules (similar to [ERC-7562](https://eips.ethereum.org/EIPS/eip-7562)). #### Example 1a: Simple ETH transfer | Frame | Caller | Target | Data | Mode | | ----- | -------------- | ------------- | ------------------ | ------------ | | 0 | ENTRY_POINT | Null (sender) | Signature | VERIFY | | 1 | Sender | Null (sender) | Destination/Amount | SENDER | A simple transfer is performed by instructing the account to send ETH to the destination account. This requires two frames for mempool compatibility, since the validation phase of the transaction has to be static. This is listed here to illustrate why the transaction type has no built-in value field. #### Example 1b: Simple account deployment | Frame | Caller | Target | Data | Mode | | ----- | ------------ | ------------- | ------------------ | ------- | | 0 | ENTRY_POINT | Deployer | Initcode, Salt | DEFAULT | | 1 | ENTRY_POINT | Null (sender) | Signature | VERIFY | | 2 | Sender | Null (sender) | Destination/Amount | SENDER | This example illustrates the initial deployment flow for a smart account at the `sender` address. Since the address needs to have code in order to validate the transaction, the transaction must deploy the code before verification. The first frame would call a deployer contract, like EIP-7997. The deployer determines the address in a deterministic way, such as by hashing the initcode and salt. However, since the transaction sender is not authenticated at this point, the user must choose an initcode which is safe to deploy by anyone. #### Example 2: Sponsored Transaction (Fee Payment in ERC-20) | Frame | Caller | Target | Data | Mode | | ----- | ----------- | ------------- | ---------------------- | ------- | | 0 | ENTRY_POINT | Null (sender) | Signature | VERIFY | | 1 | ENTRY_POINT | Sponsor | Sponsor data | VERIFY | | 2 | Sender | ERC-20 | transfer(Sponsor,fees) | SENDER | | 3 | Sender | Target addr | Call data | SENDER | | 4 | ENTRY_POINT | Sponsor | Post op call | DEFAULT | - Frame 0: Verifies signature and exits with `APPROVE(0x0)` to authorize execution from sender. - Frame 1: Checks that the user has enough ERC-20 tokens, and that the next frame is an ERC-20 send of the right size to the sponsor. Exits with `APPROVE(0x1)` to authorize payment. - Frame 2: Sends tokens to sponsor. - Frame 3: User's intended call. - Frame 4 (optional): Check unpaid gas, refund tokens, possibly convert tokens to ETH on an AMM. ### Data Efficiency **Basic transaction sending ETH from a smart account:** | Field | Bytes | | --------------------------------- | ----- | | Tx wrapper | 1 | | Chain ID | 1 | | Nonce | 2 | | Sender | 20 | | Max priority fee | 5 | | Max fee | 5 | | Max fee per blob gas | 1 | | Blob versioned hashes (empty) | 1 | | Frames wrapper | 1 | | Sender validation frame: target | 1 | | Sender validation frame: gas | 2 | | Sender validation frame: data | 65 | | Sender validation frame: mode | 1 | | Execution frame: target | 1 | | Execution frame: gas | 1 | | Execution frame: data | 20+5 | | Execution frame: mode | 1 | | **Total** | 134 | Notes: Nonce assumes < 65536 prior sends. Fees assume < 1099 gwei. Validation frame target is 1 byte because target is `tx.sender`. Validation gas assumes <= 65,536 gas. Calldata is 65 bytes for ECDSA signature. Blob fields assume no blobs (empty list, zero max fee). This is not much larger than an EIP-1559 transaction; the extra overhead is the need to specify the sender and amount in calldata explicitly. **First transaction from an account (add deployment frame):** | Field | Bytes | | -------------------------- | ----- | | Deployment frame: target | 20 | | Deployment frame: gas | 3 | | Deployment frame: data | 100 | | Deployment frame: mode | 1 | | **Total additional** | 124 | Notes: Gas assumes cost < 2^24. Calldata assumes small proxy. **Trustless pay-with-ERC-20 sponsor (add these frames):** | Field | Bytes | | ------------------------------------ | ----- | | Sponsor validation frame: target | 20 | | Sponsor validation frame: gas | 3 | | Sponsor validation frame: calldata | 0 | | Sponsor validation frame: mode | 1 | | Send to sponsor frame: target | 20 | | Send to sponsor frame: gas | 3 | | Send to sponsor frame: calldata | 68 | | Send to sponsor frame: mode | 1 | | Sponsor post op frame: target | 20 | | Sponsor post op frame: gas | 3 | | Sponsor post op frame: calldata | 0 | | Sponsor post op frame: mode | 1 | | **Total additional** | 140 | Notes: Sponsor can read info from other fields. ERC-20 transfer call is 68 bytes. There is some inefficiency in the sponsor case, because the same sponsor address must appear in three places (sponsor validation, send to sponsor inside ERC-20 calldata, post op frame), and the ABI is inefficient (~12 + 24 bytes wasted on zeroes). This is difficult to mitigate in a "clean" way, because one of the duplicates is inside the ERC-20 call, "opaque" to the protocol. However, it is much less inefficient than ERC-4337, because not all of the data takes the hit of the 32-byte-per-field ABI overhead. ## Backwards Compatibility The `ORIGIN` opcode behavior changes for frame transactions, returning the frame's caller rather than the traditional transaction origin. This is consistent with the precedent set by EIP-7702, which already modified `ORIGIN` semantics. Contracts that rely on `ORIGIN = CALLER` for security checks (a discouraged pattern) may behave differently under frame transactions. ## Security Considerations ### Transaction Propagation Frame transactions introduce new denial-of-service vectors for transaction pools that node operators must mitigate. Because validation logic is arbitrary EVM code, attackers can craft transactions that appear valid during initial validation but become invalid later. Without any additional policies, an attacker could submit many transactions whose validity depends on some shared state, then submit one transaction that modifies that state, and cause all other transactions to become invalid simultaneously. This wastes the computational resources nodes spent validating and storing these transactions. #### Example Attack A simple example is transactions that check `block.timestamp`: ```solidity function validateTransaction() external { require(block.timestamp < SOME_DEADLINE, "expired"); // ... rest of validation APPROVE(0x2); } ``` Such transactions are valid when submitted but become invalid once the deadline passes, without any on-chain action required from the attacker. ##### Mitigations Node implementations should consider restricting which opcodes and storage slots validation frames can access, similar to [ERC-7562](https://eips.ethereum.org/EIPS/eip-7562). This isolates transactions from each other and limits mass invalidation vectors. It's recommended that to *validate* the transaction, a specific frame structure is enforced and the amount of gas that is expended executing the validation phase must be limited. Once the frame exits with `APPROVE(0x2)`, it can be included in the mempool and propagated to peers safely. For deployment of the sender account in the first frame, the mempool must only allow specific and known deployer factory contracts to be used as `frame.target`, to ensure deployment is deterministic and independent of chain state. In general, it can be assumed that handling of frame transactions imposes similar restrictions as EIP-7702 on mempool relay, i.e. only a single transaction can be pending for an account that uses frame transactions. ## Copyright Copyright and related rights waived via [CC0](../LICENSE.md).