Background
안녕하세요, Klaytn 팀의 Jared 입니다. Klaytn’s Gas Price Adjustment Plan and Future Direction 글의 ’ We want a dynamic and deterministic fee policy in the long term’ 섹션에서 간략히 설명 드렸던 바와 같이, Klaytn team은 현재의 고정 가격 가스비를 네트워크 상황에 따라 동적으로 조절할 수 있는 메커니즘을 개발 중에 있습니다. 아직까지 많은 사안이 확정된 것은 아니지만, 생태계에 미치는 영향이 큰 사안인 만큼 개발 이전 논의 과정을 투명하게 공개하고 커뮤니티와 같이 논의하기 위해 이 글을 작성합니다. 동적 가스비 메커니즘에 대해 좋은 의견이나, 아이디어가 있다면 언제나 환영합니다.
Motivation
현재까지 Klaytn의 가스비 메커니즘은 ‘고정 가격제’로, 가스비(gas price)는 특정 값(e.g. 25 ston, 750 ston)으로 고정되는 형태를 가집니다. 개발 초기 이 메커니즘은 사용자가 직접 가스비를 입력할 필요 없이 쉽게 거래를 보내고, 거래 비용(transaction fee)의 변동성을 최소화하기 위해 채택되었습니다. 하지만 가스비 인상 전 최근 Klaytn에 많은 거래가 일어나며, 다음과 같은 문제점들이 발견되었습니다:
- 거래(transaction) 처리 지연 : 많은 거래가 동시에 발생하여, 사용자가 자신의 거래를 처리하는데 이전보다 많은 시간이 걸리는 사례들이 빈번하게 발생하고 있습니다. 이는 순간적인 거래량 급증이 근본적인 원인입니다. 하지만, Klaytn 팀에서는 자신의 거래를 처리하기 위해 수천건 이상의 거래를 한번에 발생시키는 사례가 빈번함을 확인하였습니다. 그리고 해당 행위가 지속적으로 발생하는 원인은 Klaytn에 거래의 우선순위를 결정하는 알고리즘이 부재한 동시에, 적절한 가스비 가격 정책을 가지고 있지 않았기 때문입니다.
- 스토리지 부하 : 앞서 언급한 수천건의 폭발적인 거래들은 대부분 봇에 의해 이뤄지는데, 대부분이 처리 되지 않는(revert) 형태로 발생합니다. 이러한 거래들은 Klaytn의 스토리지에도 장기적으로 부담을 주기 때문에, Klaytn이 빠른 거래 확정과 안정적 네트워크를 지속적으로 제공하는데 큰 장애가 될 수 있습니다.
- 합리적 가격 산정의 어려움으로 인한 비효율적 자원 배분 : 가스비는 무조건적으로 높아도, 반대로 낮아도 문제입니다. 너무 낮을 경우 DDOS 공격 등에 취약할 수 있으며, 많은 사용자들의 거래가 지연될 수 있습니다. 반대로 너무 높을 경우 거래 비용이 지나치게 상승해 사용자들이 원하는 만큼 네트워크를 사용하기 힘듭니다. 하지만 현재의 고정 가스비 정책으로는 어느 정도의 가격이 적절한 가격인지 발견하기 쉽지 않습니다. Klaytn의 네트워크 상황이 시시각각 변하기 때문입니다.
- 빠르고 유연한 대처 불가 : 현재와 같이 거래가 급증하는 경우에는 가스비를 높히는 방향으로, 반대로 네트워크가 원활할때는 낮추는 방향으로 조절하는 것이 필요합니다. 하지만 현재의 고정 가스비 정책은 적절한 가격대에 대해 같이 논의하고, 실제 적용하기 까지 많은 시간이 걸립니다.
위의 문제들을 해결하는 동시에, Klaytn에 소각 매커니즘을 도입하여 생태계의 성장과 KLAY의 가치를 연동시키고자 합니다.
(다만 이 제안에서는 Dynamic fee policy에 대해서만 다루며, 거래의 우선순위 알고리즘의 경우 이미 FIFO(First In First Out) 방식의 우선순위 알고리즘이 개발되어 GitHub - klaytn/klaytn: Official Go implementation of the Klaytn protocol 에서 직접 확인하실 수 있습니다.)
Dynamic Fee Policy
동적 가스비 정책에서의 거래(transaction)는 네트워크의 포화 상태에 따라 동적으로 조절되는 base_fee
로 구성됩니다. 네트워크의 포화도는 Klaytn에서 생성되는 블록(Block)의 사용량인 gas_used
를 통해 측정됩니다. base_fee
는 매 블록 변경됩니다.
합의 노드가 블록을 생성할 때, 이전 블록의 gas_used
가 gas_target
보다 많다면, base_fee
를 증가시킵니다. 반대로, gas_used
가 gas_target
보다 낮다면 base_fee
를 감소시킵니다. 이와 같은 과정은 base_fee
가 lower_bound
혹은 upper_bound
를 넘지 않는 선까지 반복적으로 수행됩니다. 블록 생성자는 블록에 담긴 거래 수수료중 일부를 수취하며, 나머지는 소각됩니다.
만약 다음 테이블과 같은 블록들이 있고, 최신 블록에서 base_fee
를 결정하고자 할 때의 예시는 다음 설명과 같습니다. 하지만, 밑의 예시는 실제 구현되는 파라메터 값이 변함에 따라 변경될 수 있으므로, 밑에 작성한 reference implementation을 참고하는 것을 부탁드립니다. 이 예시에서, gas_target
값은 30 이라 가정합니다.
Block number | 100 | 101 |
---|---|---|
Gas used | 50 | 70 |
Base fee | 100 | 101.04 |
- 100번째 블록의 gas used를 계산 = 50
-
gas_target
과 해당 값의 차이를 계산 = (50-30) = 20 -
base_fee
를 기존 대비 얼마나 올릴 것인지 계산 - 기존 base fee * (
gas_target
- 이전 블록의gas_used
차이) /gas_target
값 / 상수(여기에서는 64라 가정) - (100 * 20 / 30) / 64 = 1.04
-
base_fee
증가 = 100 + 1.04 = 101.04
*구체적인 방법은, 이글 하단에 reference implementation을 참조하시기 바랍니다.
Expected Effect
제안된 동적 가스비 조절 메커니즘은 다음과 같은 문제를 해결할 것으로 예상합니다.
- 거래 처리 지연 및 스토리지 부하 정도 경감
- 네트워크 상황에 따른 유연한 가스비 조절
- 소각 메커니즘 도입
하지만, 다음과 같은 변화들이 일어날 수 있습니다.
- 생태계 지갑 및 기타 툴들의 가스비 관련 코드 수정
- 가스비 예측 가능성 저하
위와 같은 메커니즘을 실제 클레이튼 데이터를 통해 검증해 보았을 때, 다음과 같은 base_fee
변동이 예상되었습니다.
일반적인 상황일 경우
Congestion시
Reference Implementation: EIP-1559 Specification을 참조하였습니다. 또한, 구체적인 구현 내용 등은 변경될 수 있습니다.
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
- 변경 후
base_fee
는 얼마나 될 것으로 예측하나요?- 아직 이를 미리 예측하기는 매우 힘듭니다.
base_fee
자체의 변동은 네트워크의 활용도에 따라 변하기 때문입니다. 다만, Klaytn 팀은 일 평균base_fee
는 약 50~500 ston 내외가 될 것으로 예상합니다. 각 블록별로는 일시적으로 이보다 더 낮거나(e.g. 20 ston) 높은(e.g. 2000 ston) 가격이 될 수 있습니다.
- 아직 이를 미리 예측하기는 매우 힘듭니다.
- 개발자 입장에서 뭘 바꿔야 하나요?
- 기존 가스비 관련 로직이 변경되기 때문에, Metamask 와 같이 가스비 관련 로직을 추가해야 할 수 있습니다.
- Klaytn 사용성이 나빠지진 않을까요?
- 거래 처리 지연 측면에서는 오히려 기존보다 나아질 가능성이 높습니다. 다만, Kaikas 등 월렛 측면에서 기존과 다르게 가스비를 예상해 거래를 보내야 하는 측면이 있을 수 있습니다. 하지만 해당 가스비 관련 로직이 추가된다면, (현재 메타마스크 등의 지갑을 참고했을때) 단순 사용성 측면에서 큰 허들이 생긴다고 보기는 힘듭니다.
- 도입한다면, 언제쯤 도입을 예상하나요?
- 현재 구체적인 날짜는 정해지지 않았습니다. 다만, 2022년 하반기 초입에 새로운 변동 가스비 정책을 도입하는 것을 목표로 하고 있습니다.
- EIP 1559와 다른점이 뭔가요?
- EIP 1559와 달리, Tip이 존재하지 않습니다.
- base_fee 에 대한 최저값과 최대값이 존재합니다.
- 왜 Tip이 없나요?
- 기존 토큰 전송 등의 UX 저하를 최소화 하면서도, 가스비의 예측 가능성을 일부분 담보 하고자 했습니다. 다만, 추후 네트워크의 상황 등을 고려해 Tip의 도입이 고려될 수 있습니다.