EIP-7706: Create a separate basefee and gaslimit for calldata

The EIP describes three changes:

  1. Switch to a multidimensional fee structure
  2. Allowing priority fees for blob transaction
  3. Introduction of a separate calldata gas fee

I’m wondering about the encoding of the max_fees_per_gas and priority_fees_per_gas vectors on JSON-RPC. Natively, they would be represented as a list of integers (in the typical string representation), without any guidance on which of the fees corresponds to what type of fee. For example, there must be external knowledge that the second one is referring to blobs. A named tuple would improve clarity over the vector design, while still allowing efficient iteration for the generic cost computation via type reflection.

Another JSON-RPC question would be how existing clients could remain supported, e.g., would the max_fee_per_gas in there simply return max_fees_per_gas[0]? Or would the schema change to max_fees_per_gas and any client depending on the existing max_fee_per_gas would break? The one case that should be avoided is the one where JSON-RPC simply extends with a max_fee_per_calldata_gas / max_priority_fee_per_calldata_gas, the switch to multidimensional should be done on all layers.

In an SSZ world, the new transaction type could be modelled as an EIP-7495 profile of the EIP-6493 Transaction (specs still in flux regarding the details).

The EIP-6493 TransactionPayload would be extended with two new fields for holding the new fee tuple.

class FeePerGas(uint256):
    pass

class FeesPerGas(StableContainer[16]):
    regular: Optional[FeePerGas]
    blob: Optional[FeePerGas]
    calldata: Optional[FeePerGas]

class TransactionPayload(StableContainer[32]):
    type_: Optional[TransactionType]
    chain_id: Optional[ChainId]
    nonce: Optional[uint64]
    max_fee_per_gas: Optional[FeePerGas]
    gas: Optional[uint64]
    to: Optional[ExecutionAddress]
    value: Optional[uint256]
    input_: Optional[ByteList[MAX_CALLDATA_SIZE]]
    access_list: Optional[List[AccessTuple, MAX_ACCESS_LIST_SIZE]]
    max_priority_fee_per_gas: Optional[FeePerGas]
    max_fee_per_blob_gas: Optional[FeePerGas]
    blob_versioned_hashes: Optional[List[VersionedHash, MAX_BLOB_COMMITMENTS_PER_BLOCK]]
    max_fees_per_gas: Optional[FeesPerGas]
    max_priority_fees_per_gas: Optional[FeesPerGas]

Then, new versions of the Basic/Blob profile could be introduced.

class BasicFeesPerGas(Profile[FeesPerGas]):
    regular: FeesPerGas
    calldata: Optional[FeesPerGas]

class BasicTransactionPayloadV2(Profile[TransactionPayload]):
    chain_id: ChainId
    nonce: uint64
    gas: uint64
    to: Optional[ExecutionAddress]
    value: uint256
    input_: ByteList[MAX_CALLDATA_SIZE]
    access_list: List[AccessTuple, MAX_ACCESS_LIST_SIZE]
    max_fees_per_gas: BasicFeesPerGas
    max_priority_fees_per_gas: BasicFeesPerGas

class BlobFeesPerGas(Profile[FeesPerGas]):
    regular: FeesPerGas
    blob: FeesPerGas
    calldata: Optional[FeesPerGas]

class BlobTransactionPayloadV2(Profile[TransactionPayload]):
    chain_id: ChainId
    nonce: uint64
    gas: uint64
    to: ExecutionAddress
    value: uint256
    input_: ByteList[MAX_CALLDATA_SIZE]
    access_list: List[AccessTuple, MAX_ACCESS_LIST_SIZE]
    blob_versioned_hashes: List[VersionedHash, MAX_BLOB_COMMITMENTS_PER_BLOCK]
    max_fees_per_gas: BlobFeesPerGas
    max_priority_fees_per_gas: BlobFeesPerGas

We would then have the following transaction profiles available:

class AnyTransactionPayload(Union[
        # Imported from RLP
        ReplayableTransactionPayload,
        LegacyTransactionPayload,
        Eip2930TransactionPayload,
        Eip1559TransactionPayload,
        Eip4844TransactionPayload,

        # EIP-6493
        BasicTransactionPayload,
        BlobTransactionPayload,

        # EIP-7706
        BasicTransactionPayloadV2,
        BlobTransactionPayloadV2]:
    pass

And the helpers for obtaining fees would become:

def get_max_fees(tx: AnyTransactionPayload) -> [FeePerGas, FeePerGas, FeePerGas]:
    if hasattr(tx, 'max_fees_per_gas'):
        fees = tx.max_fees_per_gas
        return [
            fees.regular,
            fees.blob if hasattr(tx, 'blob_versioned_hashes') else FeePerGas(0),
            fees.calldata if fees.calldata is not None else fees.regular
        ]
    else:
        return [
            tx.max_fee_per_gas,
            tx.max_fee_per_blob_gas if hasattr(tx, 'blob_versioned_hashes') else FeePerGas(0),
            tx.max_fee_per_gas
        ]

def get_priority_fees(tx: AnyTransactionPayload) -> [FeePerGas, FeePerGas, FeePerGas]:
    if hasattr(tx, 'max_priority_fees_per_gas'):
        fees = tx.max_priority_fees_per_gas
        return [
            fees.regular,
            fees.blob if hasattr(tx, 'blob_versioned_hashes') else FeePerGas(0),
            fees.calldata if fees.calldata is not None else fees.regular
        ]
    elif hasattr(tx, 'max_priority_fee_per_gas'):
        return [
            tx.max_priority_fee_per_gas,
            FeePerGas(0),
            tx.max_priority_fee_per_gas
        ]
    else:
        return [
            tx.max_fee_per_gas,
            FeePerGas(0),
            tx.max_fee_per_gas
        ]

If we want, we could introduce these two aspects as part of EIP-6493:

  1. Switch to a multidimensional fee structure
  2. Allowing priority fees for blob transaction

by not even introducing the max_fee_per_gas / max_priority_fee_per_gas / max_blob_fee_per_gas into SSZ transactions in first place. As part of the RLP → SSZ conversion, the normalization to multidimensional gas could also be processed, in the same manner how v/r/s is already normalized to bytevector[65] + chain_id during the conversion process.

Then, with EIP-7706 we would simply extend FeesPerGas/BasicFeesPerGas/BlobFeesPerGas with the calldata: Optional[FeePerGas] entry, and could avoid the need for a V2 version of Basic/BlobTransaction.