[EN] New transaction fee mechanism: Dynamic Gas Price Proposal

Background

Hi, Jared from Klaytn team. As we explained in the ’ We want a dynamic and deterministic fee policy in the long term’ section of the article Klaytn’s Gas Price Adjustment Plan and Future Direction | by Klaytn | Klaytn | Medium, the Klaytn Team is currently in the process of developing a mechanism to dynamically control fixed gas price depending on the network situation. Many things have yet to be decided, but since this issue concerns the whole ecosystem, we are writing this to make the discussion process transparent and open, so that our community can also be involved in the decision-making. If you have any good opinions or ideas regarding dynamic gas fee, feel free to share them.

Motivation

Klaytn has been following a policy of “fixed gas price“, with the gas price fixed at a certain level (e.g. 25 ston, 750 ston). This approach was adopted in the initial phase because it allows users to easily send transactions easily without having to enter the gas fee, while minimizing the volatility of transaction fees. But in the periods leading up to the gas price increase, we have seen a lot of transactions on Klaytn as well as the following problems:

  • Delay of transactions : A large number of transactions were being generated at the same time, leading to increased process time for users. The fundamental cause to this was the sudden surge in transactions. But the Klaytn Team determined that cases of thousands of transactions being fired to process certain transaction have been frequent. And the reason behind this repeated phenomenon was the absence of an algorithm that determines the priority of Klaytn transactions, as well as of an appropriate gas price policy.
  • Storage overload : The aforementioned bot transactions are created mostly by bots, and are reverted transactions. These transactions strain the Klaytn storage in the long-term, which can hinder Klaytn from providing quick transaction finality and stable network.
  • Inefficient resource allocation due to difficulty in rational price determination : Gas price should neither be too high or too low. A too low gas price increases vulnerability to DDOS attacks and and transaction delay for users. When it’s too high, the transaction cost is going to prevent users from using the network unreservedly. With the current fixed gas price policy, however, it is difficult to determine which price is appropriate, since the Klaytn network is constantly changing.
  • Inability to respond quickly and flexibly : Gas price needs to go up during transaction spikes and go down when the network is stable. But under the current fixed gas price scheme, it takes a lot of time until an appropriate price range is discussed and actually implemented.

We want to introduce a burn mechanism on Klaytn while solving the above problems, in order to link the growth of the ecosystem with KLAY value.

(This proposal only deals with dynamic fee policy, and a FIFO (First In First Out) transaction priority algorithm is already built. You can check it out at https://github.com/klaytn/klaytn.)

Dynamic Fee Policy

Transactions under a dynamic gas fee policy consist of base_fee, which are dynamically controlled according to the network congestion status. Network congestion is measured by gas_used which is the gas usage by blocks created on Klaytn. base_fee changes every block.

When a consensus node creates a block, if the gas_used of parent_block exceeds gas_target, base_fee would go up. On the other hand, if the gas_used is lower than gas_target, the base_fee would be reduced. This process would be repeated until the base_fee doesn’t exceed lower_bound or upper_bound. The block proposer would receive a part of the transaction feeds included in the block, and the rest would be burned.

Let’s say that there are blocks like the table below, and the latest block wants to determine the base_fee, the example is as follows. But the example below may be different according to the actual parameters, please take the reference implementation into account. Let’s say for this example, the gas_target is 30.

Block number 100 101(Latest)
Gas used 50
Base fee 100 101.04
  1. Calculate the gas used for parent block(block number 100) = 50
  2. Calculate the difference between gas_target and the gas used = (50-30) = 20
  3. Calculate how much the base_fee is going to increase
  4. Existing base fee * (Difference between the gas_target and the gas used) / gas_target / constant (here 65)
  5. (100 * 20 / 30) / 64 = 1.04
  6. base_fee increase = 100 + 1.04 = 101.04

*Please refer to reference implementation below.

Expected Effect

The proposed dynamic gas price mechanism is expected to solve the following problems.

  • Reduce transaction process delay and storage overcapacity
  • Flexible gas price control depending on the network status
  • Introduction of a burn mechanism

But it may give rise to the following changes:

  • Edit gas price-related code of the ecosystem wallet and other tools
  • Lower predictability of gas price

After applying the above mechanism to actual Klaytn data, we expect the following change in base_fee.

Under normal situation


During congestion


Derived from Reference Implementation: EIP-1559 Specification. The specifics may change.

from asyncio.windows_events import NULL
from typing import Union, Dict, Sequence, List, Tuple, Literal
from dataclasses import dataclass, field
from abc import ABC, abstractmethod


# Since Klaytn has multiple transaction types,
# only 3 transaction types are defined here for sake of simplicity.
@dataclass
class TxTypeLegacyTransaction:
	value: int = 0
    to: int = 0
    input: bytes = bytes()
	v: int = 0
	r: int = 0
	s: int = 0
    nonce: int = 0
	gas: int = 0
    gas_price: int = 0

@dataclass
class TxTypeFeeDelegatedSmartContractExecution:
    type: int = 0x09
	nonce: int = 0
	gas_price: int = 0
	gas: int = 0
	to: int = 0
	value: int = 0
	from: int = 0
	input: bytes = bytes()
	tx_signatures: List[int] = field(default_factory=list)
	fee_payer: int = 0 
	fee_payer_signatures: List[int] = field(default_factory=list)

@dataclass
class TxTypeFeeDelegatedSmartContractExecutionWithRatio:
    type: int = 0x32
	nonce: int = 0
	gas_price: int = 0
	gas: int = 0
	to: int = 0
	value: int = 0
	from: int = 0
	input: bytes = bytes()
	fee_ratio: int = 1
	tx_signatures: List[int] = field(default_factory=list)
	fee_payer: int = 0 
	fee_payer_signatures: List[int] = field(default_factory=list)


# In Klaytn, Ethereum transaction types are supported by adding EthereumTxTypeEnvelope(0x78).
# RawTransaction : EthereumTxTypeEnvelope || EthereumTransactionType || TransactionPayload
# || is the byte/byte-array concatenation operator.
# In this proposal, those concatenation is intentionally ommited for simplification.

# Transaction2930 in Ethereum. 
@dataclass
class TxTypeEthereumAccessList:
	type: int = 0x7801
	chain_id: int = 0
	nonce: int = 0
	gas_price: int = 0
	gas: int = 0
	to: int = 0
	value: int = 0
	data: bytes = bytes()
	access_list: List[Tuple[int, List[int]]] = field(default_factory=list)
	v: int = 0
	r: int = 0
	s: int = 0

# Transaction1559 in Ethereum
@dataclass
class TxTypeEthereumDynamicFee:
	type: int = 0x7802
	chain_id: int = 0
	nonce: int = 0
	gas_tip_cap: int = 0
	gas_fee_cap: int = 0
	gas: int = 0
	to: int = 0
	value: int = 0
	data: bytes = bytes()
	access_list: List[Tuple[int, List[int]]] = field(default_factory=list)
	v: int = 0
	r: int = 0
	s: int = 0


EthereumTransactions = Union[TxTypeEthereumDynamicFee, TxTypeEthereumAccessList]

Transaction = Union[TxTypeLegacyTransaction, TxTypeFeeDelegatedSmartContractExecution, TxTypeFeeDelegatedSmartContractExecutionWithRatio, EthereumTransactions]

# TODO: transaction accounting part
@dataclass
class NormalizedTransaction:
	signer_address: int = 0
	signer_nonce: int = 0
	max_fee_per_gas: int = 0
	gas_limit: int = 0
	to: int = 0
	value: int = 0
	data: bytes = bytes()
	fee_payer_address: int = 0
	fee_ratio: int = 1

@dataclass
class Block:
	hash: int = 0
	parent_hash: int = 0
	base_fee_per_gas: int = 0
	block_score: int = 0
	extra_data: bytes = bytes()
	gas_used: int = 0 
	governance_data: bytes = bytes()
	logs_bloom: int = 0
	number: int = 0
	transaction_receipt_root: int = 0
	reward: int = 0
	state_root: int = 0
	timestamp: int = 0
	timestamp_FoS: int = 0
	transaction_root: int = 0
	nonce: int = 0
	committee: List[int] = field(default_factory=list)
	proposer: int = 0 
	size: int = 0 


@dataclass
class Account:
	type: int = 0
	nonce: int = 0
	humanReadable: bool = False 
	address: int = 0
	key: int = 0 
	balance: int = 0
	storage_root: int = 0
	code_hash: int = 0
	code_format: int = 0
	vm_version: int = 0

INITIAL_FORK_BLOCK_NUMBER = 107806544 # TBD
BASE_FEE_DELTA_REDUCING_DENOMINATOR = 64 # TBD
LOWER_BOUND_BASE_FEE = 2000000000# TBD, 20ston
UPPER_BOUND_BASE_FEE = 1000000000000 # TBD, 2000ston
GAS_TARGET = 30000000 # TBD
BURN_RATIO = 0.5 # TBD

class World(ABC):
	def validate_block(self, block: Block) -> None:
		
		# check if the base fee is correct
		if INITIAL_FORK_BLOCK_NUMBER == block.number:
			expected_base_fee_per_gas = LOWER_BOUND_BASE_FEE

		else: 
			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 base fee is in the range
			if parent_base_fee_per_gas < LOWER_BOUND_BASE_FEE:
				expected_base_fee_per_gas = LOWER_BOUND_BASE_FEE

			elif parent_base_fee_per_gas > UPPER_BOUND_BASE_FEE:
				expected_base_fee_per_gas = UPPER_BOUND_BASE_FEE

			else: 
				# check if the base fee is correct
				if parent_gas_used == GAS_TARGET:
					expected_base_fee_per_gas = parent_base_fee_per_gas
				elif parent_gas_used > GAS_TARGET:
					gas_used_delta = parent_gas_used - GAS_TARGET
					base_fee_per_gas_delta = max(parent_base_fee_per_gas * gas_used_delta // GAS_TARGET // BASE_FEE_DELTA_REDUCING_DENOMINATOR, 1)
					expected_base_fee_per_gas = parent_base_fee_per_gas + base_fee_per_gas_delta
				else:
					gas_used_delta = GAS_TARGET - parent_gas_used
					base_fee_per_gas_delta = parent_base_fee_per_gas * gas_used_delta // GAS_TARGET // BASE_FEE_DELTA_REDUCING_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'
			
			# TODO: fee delegation transaction accounting
			# the signer must be able to afford the transaction
			assert signer.balance >= transaction.gas_limit * transaction.max_fee_per_gas

			# 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
			

			signer.balance -= transaction.gas_limit * block.base_fee_per_gas
			assert signer.balance >= 0, 'invalid transaction: signer does not have enough ETH to cover gas'

			gas_used = self.execute_transaction(transaction, block.base_fee_per_gas)
			gas_refund = transaction.gas_limit - gas_used
			cumulative_transaction_gas_used += gas_used
			# signer gets refunded for unused gas
			signer.balance += gas_refund * block.base_fee_per_gas

			# miner only receives some propotion of the base fee(basefee burned)
			self.account(block.proposer).balance += gas_used * block.base_fee_per_gas * BURN_RATIO

		# 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, TxTypeLegacyTransaction):
			return NormalizedTransaction(
				signer_address = signer_address,
				signer_nonce = transaction.nonce,
				max_fee_per_gas = transaction.gas_price,
				gas_limit = transaction.gas,
				to = transaction.to,
				value = transaction.value,
				data = transaction.input,
				fee_payer_address = None,
				fee_ratio = None
			)

		elif isinstance(transaction, TxTypeFeeDelegatedSmartContractExecution):
			return NormalizedTransaction(
				signer_address = signer_address,
				signer_nonce = transaction.nonce,
				max_fee_per_gas = transaction.gas_price,
				gas_limit = transaction.gas,
				to = transaction.to,
				value = transaction.value,
				data = transaction.input,
				fee_payer_address = transaction.fee_payer,
				fee_ratio = None
			)
		
		elif isinstance(transaction, TxTypeFeeDelegatedSmartContractExecutionWithRatio):
			return NormalizedTransaction(
				signer_address = signer_address,
				signer_nonce = transaction.nonce,
				max_fee_per_gas = transaction.gas_price,
				gas_limit = transaction.gas,
				to = transaction.to,
				value = transaction.value,
				data = transaction.input,
				fee_payer_address = transaction.fee_payer,
				fee_ratio = transaction.fee_ratio
			)
		elif isinstance(transaction, TxTypeEthereumAccessList):
			return NormalizedTransaction(
				signer_address = signer_address,
				signer_nonce = transaction.nonce,
				max_fee_per_gas = transaction.gas_price,
				gas_limit = transaction.gas,
				to = transaction.to,
				value = transaction.value,
				data = transaction.data,
				fee_payer_address = None,
				fee_ratio = None
			)
		elif isinstance(transaction, TxTypeEthereumDynamicFee):
			return NormalizedTransaction(
				signer_address = signer_address,
				signer_nonce = transaction.nonce,
				max_fee_per_gas = transaction.gas_fee_cap,
				gas_limit = transaction.gas,
				to = transaction.to,
				value = transaction.value,
				data = transaction.data,
				fee_payer_address = None,
				fee_ratio = None
			)
		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

	# effective_gas_price is the value returned by the GASPRICE (0x3a) opcode
	@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

FAQ

  • How much is the base_fee expected to be?
    • It’s still very difficult to predict, because base_fee changes depending on the network usability. But the Klaytn Team estimates that the base_fee us going to be around 50-500 ston. For each block, it may be lower (e.g. 20 ston) or higher (e.g. 2000 ston).
  • I am a developer. What are some changes that affect me?
    • Since the existing gas price logic is going to change, y likeou may have to add a gas price logic MetaMask.
  • Would the usability of Klaytn not be affected?
    • In terms of transaction process delay, we would actually see an improvement. But you may have to send out a transaction with estimated gas price for wallets like Kaikas. But you can’t really say that if affects usability just with the addition of this gas price logic, considering current wallets like MetaMask.
  • When would the new policy be implemented?
    • We don’t have a specific date yet. But we want to introduce the new gas price policy during the later part of 2022.
  • What is different from EIP 1559?
    • Unlike EIP 1559, there is no tip.
    • There is a minimum and maximum value for base_fee.
  • Why is there no tip?
    • We wanted to secure the predictability of the gas price while minimizing the deterioration of the existing UX like token transfer. But tips may be considered in the future depending on the network status.
7개의 좋아요

Here is the KIP proposal link: [KIP-71] Dynamic gas fee pricing mechanism by lwj010 · Pull Request #72 · klaytn/kips · GitHub

I think it’s a really good proposal because :

  • This dynamic gas policy doesn’t allow anyone to have the “priority” by putting more gas than the other. This means that when high load, users won’t have to make a crazy gas auction to be included in a block relatively quickly.

  • This policy coupled with FIFO order mecanism, it will 100% solve the problem of spamming bots without penalizing the real users of the network.

One question however, what would be happening in that exemple :

  1. Making a Transaction at Block #100, calculated the base_fee of that block to be 101.04
  2. This transaction is included at Block #103, where base_fee is now at 102
  3. The Transaction revert as the base_fee is not enough
  4. However, I was not able to predict what the base_fee will be at the block where my Transaction is included

Maybe allow users to put a higher base_fee in order to be sure that the base_fee is enough, but still executing the transaction at the real base_fee ? (because no tips can be included)

EDIT : Just saw on the code you provided that it’s gonna be that. Users will be able to put the max fee per gas at a high number (< 2**256) to be sure that the transaction will not revert, but the executed fee is the block base_fee !

2개의 좋아요

Yes, users will able to put the higher gas price than the base fee and the rest(basefee input - actual basefee) will be refunded. And Klaytn team is also considering developing relevant APIs to get current basefee so that client can submit a transaction with higher basefee(e.g. 2x of current basefee).

1개의 좋아요