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.
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.
| Name | Value |
|---|---|
FRAME_TX_TYPE |
0x06 |
FRAME_TX_INTRINSIC_COST |
15000 |
ENTRY_POINT |
address(0xaa) |
MAX_FRAMES |
10^3 |
| Name | Value |
|---|---|
APPROVE |
0xaa |
TXPARAM |
0xb0 |
FRAMEDATALOAD |
0xb1 |
FRAMEDATACOPY |
0xb2 |
A new 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.
The mode of each frame sets the context of execution. It allows the protocol to identify
the purpose of the frame within the execution loop.
The execution mode of a frame is identified by the lower bits (<= 8) of the mode field.
The modes are explained in detail below.
mode & 0xFF |
Name | Summary |
|---|---|---|
| 0 | DEFAULT mode |
Execute frame as ENTRY_POINT |
| 1 | VERIFY mode |
Frame identifies as transaction validation |
| 2 | SENDER mode |
Execute frame as sender |
| 3..255 | reserved |
DEFAULT ModeFrame executes as regular call where the caller address is ENTRY_POINT.
VERIFY ModeIdentifies the frame as a validation frame. Its purpose is to verify that a sender and/or payer authorized the transaction. It must call APPROVE during execution. Failure to do so will result in the whole transaction being 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 ModeFrame 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.
The upper bits (> 8) of mode configure the execution environment.
| Mode bit | Meaning |
|---|---|
| 9 | APPROVE of execution allowed |
| 10 | APPROVE of payment allowed |
Some validity constraints can be determined statically. They are outlined below:
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
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.
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:
def compute_sig_hash(tx: FrameTx) -> Hash:
for i, frame in enumerate(tx.frames):
if frame.mode == VERIFY:
tx.frames[i].data = Bytes()
return keccak(rlp(tx))
APPROVE opcode (0xaa)The APPROVE opcode is like RETURN (0xf3). It exits the current context successfully and updates the transaction-scoped approval context based on the scope operand.
If the currently executing account is not frame.target (i.e. if ADDRESS != frame.target), APPROVE reverts.
| Stack | Value |
|---|---|
top - 0 |
offset |
top - 1 |
length |
top - 2 |
scope |
The scope operand must be one of the following values:
0x0: Approval of execution - the sender contract approves future frames calling on its behalf.frame.target equals tx.sender.0x1: Approval of payment - the contract approves paying the total gas cost for the transaction.0x2: Approval of execution and payment - combines both 0x0 and 0x1.Any other value results in an exceptional halt.
Usabe scope operands are constrained by bits 9 and 10 of the frame.mode, and using
a non-allowed scope also results in an exceptional halt.
(frame.mode>>8) & 3 == 1, only scope 0x0 can be used.(frame.mode>>8) & 3 == 2, only scope 0x1 can be used.(frame.mode>>8) & 3 == 3, scope 0x0, 0x1 or 0x2 can be used.The behavior of APPROVE is defined as follows:
ADDRESS != frame.target, revert.0,1, and 2, execute the following:0x0: Set sender_approved = true.sender_approved was already set, revert the frame.frame.target != tx.sender, revert the frame.0x1: Increment the sender's nonce, collect the total gas cost of the transaction from the account, and set payer_approved = true.payer_approved was already set, revert the frame.frame.target has insufficient balance, revert the frame.sender_approved == false, revert the frame.0x2: Set sender_approved = true, increment the sender's nonce, collect the total gas cost of the transaction from frame.target, and set payer_approved = true.sender_approved or payer_approved was already set, revert the frame.frame.target != tx.sender, revert the frame.frame.target has insufficient balance, revert the frame.TXPARAM opcodeThis opcode gives access to information from the transaction header and/or frames. The gas
cost of this operation is 2.
It takes two values from the stack, param and in2 (in this order). The param is the
field to be extracted from the transaction. in2 names a frame index.
param |
in2 |
Return value |
|---|---|---|
| 0x00 | must be 0 | current transaction type |
| 0x01 | must be 0 | nonce |
| 0x02 | must be 0 | sender |
| 0x03 | must be 0 | max_priority_fee_per_gas |
| 0x04 | must be 0 | max_fee_per_gas |
| 0x05 | must be 0 | max_fee_per_blob_gas |
| 0x06 | must be 0 | max cost (basefee=max, all gas used, includes blob cost and intrinsic cost) |
| 0x07 | must be 0 | len(blob_versioned_hashes) |
| 0x08 | must be 0 | compute_sig_hash(tx) |
| 0x09 | must be 0 | len(frames) (can be zero) |
| 0x10 | must be 0 | currently executing frame index |
| 0x11 | frame index | target |
| 0x12 | frame index | gas_limit |
| 0x13 | frame index | mode |
| 0x14 | frame index | len(data) |
| 0x15 | frame index | status (exceptional halt if current/future) |
Notes:
0x01 has a possible future extension to allow indices for multidimensional nonces.0x03 and 0x04 have a possible future extension to allow indices for multidimensional gas.status field (0x15) returns 0 for failure or 1 for success.param values (not defined in the table above) result in an exceptional halt.>= len(frames)) results in an exceptional halt.status of the current frame or a subsequent frame results in an exceptional halt.len(data) field (0x14) returns size 0 value when called on a frame with VERIFY set.FRAMEDATALOAD opcodeThis opcode loads one 32-byte word of data from frame input. Gas cost: 3 (matches CALLDATALOAD).
It takes two values from the stack, an offset and frameIndex.
It places the retrieved data on the stack.
When the frameIndex is out-of-bounds, an exceptional halt occurs.
The operation sematics match CALLDATALOAD, returning a word of data from the chosen
frame's data, starting at the given byte offset. When targeting a frame in VERIFY
mode, the returned data is always zero.
FRAMEDATACOPY opcodeThis opcode copies data frame input into the contract's memory.The gas cost matches CALLDATACOPY, i.e. the operation has a fixed cost of 3 and a variable cost that accounts for the memory expansion and copying.
It takes four values from the stack: memOffset, dataOffset, length and frameIndex.
No stack output value is produced.
When the frameIndex is out-of-bounds, an exceptional halt occurs.
The operation sematics match CALLDATACOPY, copying length bytes from the chosen frame's
data, starting at the given byte dataOffset, into a memory region starting at
memOffset. When targeting a frame in VERIFY mode, no data is copied.
When processing a frame transaction, perform the following steps.
Perform stateful validation check:
tx.nonce == state[tx.sender].nonceInitialize with transaction-scoped variables:
payer_approved = falsesender_approved = falseThen for each call frame:
mode, target, gas_limit, and data.target is null, set the call target to tx.sender.SENDER:sender_approved must be true. If not, the transaction is invalid.caller as tx.sender.DEFAULT or VERIFY:caller to ENTRY_POINT.frame.target has no code, execute the logic described in default code.ORIGIN opcode returns frame caller throughout all call depths.VERIFY and the frame did not successfully call APPROVE, the transaction is invalid.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:
sender_approved or payer_approved become true they cannot be re-approved or reverted.When using frame transactions with EOAs (accounts with no code), they are treated as if they have a "default code." This spec describes only the behavior of the default code; clients are free to implement the default code however they want, so long as they correspond to the behavior specified here.
mode with TXPARAMLOAD.mode is VERIFY:frame.target != tx.sender, revert.frame.data as two nibbles:scope.signature_type.signature_type is:0x0:frame.data as (v, r, s).frame.target != ecrecover(hash, v, r, s), where hash = keccak(sighash, data_with_out_signature), revert.0x1:frame.data as (r, s, qx, qy).frame.target != keccak(qx|qy)[12:], revert.P256VERIFY(hash, r, s, qx, qy) != true, where hash = keccak(sighash, data_with_out_signature), revert.APPROVE(scope).mode is SENDER:0x0, revert.frame.target != tx.sender, revert.frame.data as RLP encoding of calls = [[target, value, data]].calls, execute the call with msg.sender = tx.sender.mode is DEFAULT:Notes:
data_without_signature means the portion of frame.data without the signature. Note that since signature is always at the very end of frame.data, data_without_signature is always a prefix of frame.data.keccak(qx|qy)[12:].Here's the logic above implemented in Python:
DEFAULT = 0
VERIFY = 1
SENDER = 2
SECP256K1 = 0x0
P256 = 0x1
def default_code(frame, tx):
mode = frame.mode # equivalent to TXPARAMLOAD(0x14, TXPARAMLOAD(0x10))
if mode == VERIFY:
if frame.target != tx.sender:
revert()
first_byte = frame.data[0]
scope = (first_byte >> 4) & 0xF # high nibble: APPROVE scope
signature_type = first_byte & 0xF # low nibble: signature type
sig_hash = compute_sig_hash(tx) # equivalent to TXPARAMLOAD(0x08)
if signature_type == SECP256K1:
# frame.data layout: [byte0, v (1 byte), r (32 bytes), s (32 bytes)]
if len(frame.data) != 66: # 1 header + 65 signature bytes
revert()
v = frame.data[1]
r = frame.data[2:34]
s = frame.data[34:66]
data_without_sig = frame.data[:-65] # prefix before (v, r, s)
h = keccak256(sig_hash + data_without_sig)
if frame.target != ecrecover(h, v, r, s):
revert()
elif signature_type == P256:
# frame.data layout: [byte0, r (32 bytes), s (32 bytes), qx (32 bytes), qy (32 bytes)]
if len(frame.data) != 129: # 1 header + 128 signature bytes
revert()
r = frame.data[1:33]
s = frame.data[33:65]
qx = frame.data[65:97]
qy = frame.data[97:129]
data_without_sig = frame.data[:-128] # prefix before (r, s, qx, qy)
h = keccak256(sig_hash + data_without_sig)
if frame.target != keccak256(qx + qy)[12:]:
revert()
if not P256VERIFY(h, r, s, qx, qy):
revert()
else:
revert()
APPROVE(scope)
elif mode == SENDER:
if frame.target != tx.sender:
revert()
# frame.data layout: [byte0, RLP-encoded [[target, value, data], ...]]
calls = rlp_decode(frame.data[1:])
for call_target, call_value, call_data in calls:
result = evm_call(caller=tx.sender, to=call_target, value=call_value, data=call_data)
if result.reverted:
revert()
elif mode == DEFAULT:
revert()
else:
revert()
A few cross-frame interactions to note:
TSTORE and TLOAD transient storage between frames.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.
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:
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.APPROVE calling conventionOriginally APPROVE was meant to extend the space of return statuses from 0 and 1 today to 0 to 4. However, this would mean smart accounts deployed today would not be able to modify their contract code to return with a different value at the top level. For this reason, we've chosen behavior above: APPROVE terminates the executing frame successfully like RETURN, but it actually updates the transaction scoped values sender_approved and payer_approved during execution. It is still required that only the sender can toggle the sender_approved to true. Only the frame.target can call APPROVE generally, because it can allow the transaction pool and other frames to better reason about VERIFY mode frames.
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.
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.
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.
It is not required because the account code can send value.
While we expect EOA users to migrate to smart accounts eventually, we recognize that most Ethereum users today are using EOAs, so we want to improve UX for them where we can.
With frame transactions, EOA wallets today can reap the key benefit of AA -- gas abstraction, including sending sponsored transactions, paying gas in ERC-20 tokens, and more.
| Frame | Caller | Target | Data | Mode |
|---|---|---|---|---|
| 0 | ENTRY_POINT | Null (sender) | Signature | VERIFY |
| 1 | Sender | Target | Call data | SENDER |
Frame 0 verifies the signature and calls 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 frame.| 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.
| 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.
| 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 |
APPROVE(0x0) to authorize execution from sender.APPROVE(0x1) to authorize payment.| Frame | Caller | Target | Data | Mode |
|---|---|---|---|---|
| 0 | ENTRY_POINT | Null(sender) | (0, v, r, s) | VERIFY |
| 1 | ENTRY_POINT | Sponsor | Sponsor signature | VERIFY |
| 2 | Sender | ERC-20 | transfer(Sponsor,fees) | SENDER |
| 3 | Sender | Target addr | Call data | SENDER |
APPROVE(0x0) to authorize execution.APPROVE(0x1) to authorize payment.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.
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.
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.
A simple example is transactions that check block.timestamp:
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.
Node implementations should consider restricting which opcodes and storage slots validation frames can access, similar to ERC-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 calls 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 and related rights waived via CC0.