Abstract
We propose the introduction of a time-based transaction expiration mechanism in Ethereum. The mechanism would allow users to set an expiration block number for their transactions, after which the transaction would expire and be removed from the mempool. This would give users more control over their transactions and reduce unnecessary network traffic caused by failed transactions.
The expiration block number would be a parameter that users could set while creating their transactions. If the transaction is not included in a block before the expiration block number, it would expire, and the gas used for the transaction would be refunded to the sender’s account. This would ensure that users are not charged for transactions that are not processed and also reduce the load on the network.
This EIP proposes the introduction of a new transaction type, based on EIP-1559, with a new format: 0x02 || rlp([chain_id, nonce, expiration_block_number, max_priority_fee_per_gas, max_fee_per_gas, gas_limit, destination, amount, data, access_list, signature_y_parity, signature_r, signature_s]).
This new transaction type includes an expiration block number, which can be set by the sender. A transaction will only be valid if the current block number is less than or equal to the expiration block number. The transaction specifies the expiration block number as the deadline for when the transaction is valid. If the expiration block number is reached, the transaction will fail, and no gas fee will be paid.
Motivation
Ethereum has had a significant number of failed transactions, leaving users with no option but to replace transactions with a higher gas fee and 21000 gas limit transaction (ETH native transaction). This process is inefficient, and there is no way for a transaction to timeout and not be mined once it is sent to the mempool.
- Lack of flexbility for user: It can be incredibly frustrating to be unable to cancel an unnecessary transaction, especially when using platforms like Uniswap to swap tokens or when buying NFTs.
- Network traffic with useless transaction meant to fail: In the absence of a transaction expiration mechanism, these failed transactions can remain in the mempool for extended periods of time, leading to unnecessary network traffic. Furthermore, these failed transactions can create network congestion and make it more difficult for other transactions to be processed in a timely manner.
This proposal aims to provide a solution to this inefficiency. With the introduction of an expiration block number, transactions can expire if they are not mined within a specific period, providing users with more flexibility to cancel
Specification
Transaction is only valid if the expiration_block_number is greater than or equal to the current block number, the transaction is considered expired and cannot be included in the block otherwise.
Block validity is defined in the reference implementation below.
The NUMBER
(0x43
) opcode REPRESENT the block.number
as defined in the reference implementation below.
As of FORK_BLOCK_NUMBER
, a new EIP-2718 transaction is introduced with TransactionType
2.
The EIP-2718 TransactionPayload
for this transaction is rlp([chain_id, nonce, expiration_block_number, max_priority_fee_per_gas, max_fee_per_gas, gas_limit, destination, amount, data, access_list, signature_y_parity, signature_r, signature_s])
.
The signature_y_parity, signature_r, signature_s
elements of this transaction represent a secp256k1 signature over keccak256(0x02 || rlp([chain_id, nonce, expiration_block_number, max_priority_fee_per_gas, max_fee_per_gas, gas_limit, destination, amount, data, access_list]))
.
The EIP-2718 ReceiptPayload
for this transaction is rlp([status, cumulative_transaction_gas_used, logs_bloom, logs])
.
Note: //
is integer division, round down.
from typing import Union, Dict, Sequence, List, Tuple, Literal
from dataclasses import dataclass, field
from abc import ABC, abstractmethod
@dataclass
class TransactionLegacy:
signer_nonce: int = 0
gas_price: int = 0
gas_limit: int = 0
destination: int = 0
amount: int = 0
payload: bytes = bytes()
v: int = 0
r: int = 0
s: int = 0
@dataclass
class Transaction2930Payload:
chain_id: int = 0
signer_nonce: int = 0
gas_price: int = 0
gas_limit: int = 0
destination: int = 0
amount: int = 0
payload: bytes = bytes()
access_list: List[Tuple[int, List[int]]] = field(default_factory=list)
signature_y_parity: bool = False
signature_r: int = 0
signature_s: int = 0
@dataclass
class Transaction2930Envelope:
type: Literal[1] = 1
payload: Transaction2930Payload = Transaction2930Payload()
@dataclass
class Transaction8000Payload:
chain_id: int = 0
signer_nonce: int = 0
expiration_block_number: int = 0
max_priority_fee_per_gas: int = 0
max_fee_per_gas: int = 0
gas_limit: int = 0
destination: int = 0
amount: int = 0
payload: bytes = bytes()
access_list: List[Tuple[int, List[int]]] = field(default_factory=list)
signature_y_parity: bool = False
signature_r: int = 0
signature_s: int = 0
@dataclass
class Transaction8000Envelope:
type: Literal[2] = 2
payload: Transaction8000Payload = Transaction8000Payload()
Transaction2718 = Union[Transaction8000Envelope, Transaction2930Envelope]
Transaction = Union[TransactionLegacy, Transaction2718]
@dataclass
class NormalizedTransaction:
signer_address: int = 0
signer_nonce: int = 0
expiration_block_number: int = 0
max_priority_fee_per_gas: int = 0
max_fee_per_gas: int = 0
gas_limit: int = 0
destination: int = 0
amount: int = 0
payload: bytes = bytes()
access_list: List[Tuple[int, List[int]]] = field(default_factory=list)
@dataclass
class Block:
parent_hash: int = 0
uncle_hashes: Sequence[int] = field(default_factory=list)
author: int = 0
state_root: int = 0
transaction_root: int = 0
transaction_receipt_root: int = 0
logs_bloom: int = 0
difficulty: int = 0
number: int = 0
gas_limit: int = 0 # note the gas_limit is the gas_target * ELASTICITY_MULTIPLIER
gas_used: int = 0
timestamp: int = 0
extra_data: bytes = bytes()
proof_of_work: int = 0
nonce: int = 0
base_fee_per_gas: int = 0
@dataclass
class Account:
address: int = 0
nonce: int = 0
balance: int = 0
storage_root: int = 0
code_hash: int = 0
INITIAL_BASE_FEE = 1000000000
INITIAL_FORK_BLOCK_NUMBER = 10 # TBD
BASE_FEE_MAX_CHANGE_DENOMINATOR = 8
ELASTICITY_MULTIPLIER = 2
class World(ABC):
def validate_block(self, block: Block) -> None:
parent_gas_target = self.parent(block).gas_limit // ELASTICITY_MULTIPLIER
parent_gas_limit = self.parent(block).gas_limit
# on the fork block, don't account for the ELASTICITY_MULTIPLIER to avoid
# unduly halving the gas target.
if INITIAL_FORK_BLOCK_NUMBER == block.number:
parent_gas_target = self.parent(block).gas_limit
parent_gas_limit = self.parent(block).gas_limit * ELASTICITY_MULTIPLIER
parent_base_fee_per_gas = self.parent(block).base_fee_per_gas
parent_gas_used = self.parent(block).gas_used
transactions = self.transactions(block)
# check if the block used too much gas
assert block.gas_used <= block.gas_limit, 'invalid block: too much gas used'
# check if the block changed the gas limit too much
assert block.gas_limit < parent_gas_limit + parent_gas_limit // 1024, 'invalid block: gas limit increased too much'
assert block.gas_limit > parent_gas_limit - parent_gas_limit // 1024, 'invalid block: gas limit decreased too much'
# check if the gas limit is at least the minimum gas limit
assert block.gas_limit >= 5000
# check if the base fee is correct
if INITIAL_FORK_BLOCK_NUMBER == block.number:
expected_base_fee_per_gas = INITIAL_BASE_FEE
elif parent_gas_used == parent_gas_target:
expected_base_fee_per_gas = parent_base_fee_per_gas
elif parent_gas_used > parent_gas_target:
gas_used_delta = parent_gas_used - parent_gas_target
base_fee_per_gas_delta = max(parent_base_fee_per_gas * gas_used_delta // parent_gas_target // BASE_FEE_MAX_CHANGE_DENOMINATOR, 1)
expected_base_fee_per_gas = parent_base_fee_per_gas + base_fee_per_gas_delta
else:
gas_used_delta = parent_gas_target - parent_gas_used
base_fee_per_gas_delta = parent_base_fee_per_gas * gas_used_delta // parent_gas_target // BASE_FEE_MAX_CHANGE_DENOMINATOR
expected_base_fee_per_gas = parent_base_fee_per_gas - base_fee_per_gas_delta
assert expected_base_fee_per_gas == block.base_fee_per_gas, 'invalid block: base fee not correct'
# execute transactions and do gas accounting
cumulative_transaction_gas_used = 0
for unnormalized_transaction in transactions:
# Note: this validates transaction signature and chain ID which must happen before we normalize below since normalized transactions don't include signature or chain ID
signer_address = self.validate_and_recover_signer_address(unnormalized_transaction)
transaction = self.normalize_transaction(unnormalized_transaction, signer_address)
signer = self.account(signer_address)
signer.balance -= transaction.amount
assert signer.balance >= 0, 'invalid transaction: signer does not have enough ETH to cover attached value'
# the signer must be able to afford the transaction
assert signer.balance >= transaction.gas_limit * transaction.max_fee_per_gas
# ensure that the transaction has not expired
assert transaction.expiration_block_number >= block.number
# ensure that the user was willing to at least pay the base fee
assert transaction.max_fee_per_gas >= block.base_fee_per_gas
# Prevent impossibly large numbers
assert transaction.max_fee_per_gas < 2**256
# Prevent impossibly large numbers
assert transaction.max_priority_fee_per_gas < 2**256
# The total must be the larger of the two
assert transaction.max_fee_per_gas >= transaction.max_priority_fee_per_gas
# priority fee is capped because the base fee is filled first
priority_fee_per_gas = min(transaction.max_priority_fee_per_gas, transaction.max_fee_per_gas - block.base_fee_per_gas)
# signer pays both the priority fee and the base fee
effective_gas_price = priority_fee_per_gas + block.base_fee_per_gas
signer.balance -= transaction.gas_limit * effective_gas_price
assert signer.balance >= 0, 'invalid transaction: signer does not have enough ETH to cover gas'
gas_used = self.execute_transaction(transaction, effective_gas_price)
gas_refund = transaction.gas_limit - gas_used
cumulative_transaction_gas_used += gas_used
# signer gets refunded for unused gas
signer.balance += gas_refund * effective_gas_price
# miner only receives the priority fee; note that the base fee is not given to anyone (it is burned)
self.account(block.author).balance += gas_used * priority_fee_per_gas
# check if the block spent too much gas transactions
assert cumulative_transaction_gas_used == block.gas_used, 'invalid block: gas_used does not equal total gas used in all transactions'
# TODO: verify account balances match block's account balances (via state root comparison)
# TODO: validate the rest of the block
def normalize_transaction(self, transaction: Transaction, signer_address: int) -> NormalizedTransaction:
# legacy transactions
if isinstance(transaction, TransactionLegacy):
return NormalizedTransaction(
signer_address = signer_address,
signer_nonce = transaction.signer_nonce,
expiration_block_number = transaction.expiration_block_number,
gas_limit = transaction.gas_limit,
max_priority_fee_per_gas = transaction.gas_price,
max_fee_per_gas = transaction.gas_price,
destination = transaction.destination,
amount = transaction.amount,
payload = transaction.payload,
access_list = [],
)
# 2930 transactions
elif isinstance(transaction, Transaction2930Envelope):
return NormalizedTransaction(
signer_address = signer_address,
signer_nonce = transaction.payload.signer_nonce,
expiration_block_number = transaction.expiration_block_number,
gas_limit = transaction.payload.gas_limit,
max_priority_fee_per_gas = transaction.payload.gas_price,
max_fee_per_gas = transaction.payload.gas_price,
destination = transaction.payload.destination,
amount = transaction.payload.amount,
payload = transaction.payload.payload,
access_list = transaction.payload.access_list,
)
# 8000 transactions
elif isinstance(transaction, Transaction8000Envelope):
return NormalizedTransaction(
signer_address = signer_address,
signer_nonce = transaction.payload.signer_nonce,
expiration_block_number = transaction.expiration_block_number,
gas_limit = transaction.payload.gas_limit,
max_priority_fee_per_gas = transaction.payload.max_priority_fee_per_gas,
max_fee_per_gas = transaction.payload.max_fee_per_gas,
destination = transaction.payload.destination,
amount = transaction.payload.amount,
payload = transaction.payload.payload,
access_list = transaction.payload.access_list,
)
else:
raise Exception('invalid transaction: unexpected number of items')
@abstractmethod
def parent(self, block: Block) -> Block: pass
@abstractmethod
def block_hash(self, block: Block) -> int: pass
@abstractmethod
def transactions(self, block: Block) -> Sequence[Transaction]: pass
@abstractmethod
def execute_transaction(self, transaction: NormalizedTransaction, effective_gas_price: int) -> int: pass
@abstractmethod
def validate_and_recover_signer_address(self, transaction: Transaction) -> int: pass
@abstractmethod
def account(self, address: int) -> Account: pass
Backwards Compatibility
This proposal is backwards compatible with existing Ethereum transactions to EIP-1559 . It is an optional feature that is only included in transactions that utilize the new format specified in this EIP.
Security Considerations
Potentially be exploited by malicious actors
The introduction of an expiration block number feature could potentially be exploited by malicious actors to attempt to invalidate transactions that are still valid. However, this risk is mitigated by setting a minimum block number limit on the expiration block number, which will ensure that transactions remain valid for a reasonable amount of time.
Nodes gatekeeping
Additionally, nodes on the Ethereum network can enforce a maximum block number limit for expiration block numbers to further reduce the risk of exploitation, the mempool could also drop all transactions that have expired.
Copyright
Copyright and related rights waived via [CC0].