变更记录

  • 2021 年 12 月 1 日:初稿

状态

已拒绝

摘要

本 ADR 描述了一种灵活的机制,用于在共识层维护 gas price,可通过配置选择多层级 gas price 系统或类似 EIP-1559 的机制。

背景

当前,每个验证者都会在 app.yaml 中配置各自的 minimal-gas-prices。但设置合适的最小 gas price 对于保护网络免受 DDoS 攻击至关重要,而且很难让所有验证者都选出合理的数值,因此我们提议在共识层维护 gas price。 由于 Tendermint 0.34.20 已支持 mempool 优先级排序,我们可以利用这一能力来实现更复杂的 gas fee 系统。

多层级价格系统

我们提议在共识层采用多层级价格系统,以提供最大的灵活性:
  • 第 1 层:固定 gas price,只能通过治理提案偶尔修改。
  • 第 2 层:动态 gas price,根据前一个区块的负载进行调整。
  • 第 3 层:动态 gas price,根据前一个区块的负载以更高速度进行调整。
高层级的 gas price 应高于低层级。 交易手续费按共识计算出的精确 gas price 收取。 参数模式如下:
message TierParams {
  uint32 priority = 1           // Tendermint mempool 中的优先级
  Coin initial_gas_price = 2    //
  uint32 parent_gas_target = 3  // 区块目标饱和度
  uint32 change_denominator = 4 // 决定变化速度
  Coin min_gas_price = 5        // 价格调整的可选下界
  Coin max_gas_price = 6        // 价格调整的可选上界
}

message Params {
  repeated TierParams tiers = 1;
}

扩展选项

我们需要允许用户为交易指定服务层级。为了以可扩展的方式支持这一点,我们在 AuthInfo 中添加一个扩展选项:
message ExtensionOptionsTieredTx {
  uint32 fee_tier = 1
}
fee_tier 的值就是 tiers 参数列表中的索引。 我们还会改变 Tx 现有 fee 字段的语义:不再向用户收取精确的 fee 金额,而是将其视为 fee cap,实际收取的手续费金额由系统动态决定。如果 fee 小于动态计算出的数值,该交易将不会被纳入当前区块;理想情况下,它应保留在 mempool 中,直到共识 gas price 下降。mempool 最终可以修剪旧交易。

交易优先级

交易会基于层级进行优先级排序,层级越高,优先级越高。 在同一层级内,遵循 Tendermint 的默认顺序(当前为 FIFO)。需要注意,mempool 的交易排序逻辑不是共识的一部分,因此可能被恶意验证者修改。 该机制可以很容易地与其他优先级机制组合:
  • 我们可以增加一些用户无法直接控制的额外层级:
    • 示例 1:用户可以设置 tier 0、10 或 20,但协议会创建 tier 0、1、2 … 29。例如,IBC 交易会进入 user_tier + 5:如果用户选择了 tier 1,那么该交易会进入 tier 15。
    • 示例 2:我们可以将 tier 4、5、… 仅保留给特殊交易类型。例如,tier 5 保留给 evidence tx。因此,如果提交一个 bank.Send 交易并将 tier 设为 5,它会被映射到 tier 3(任何交易可用的最高 tier level)。
    • 示例 3:我们可以强制某一特定类型的所有交易都进入特定层级。例如,tier 100 将保留给 evidence transactions,并且所有 evidence transactions 都始终进入该层级。

min-gas-prices

废弃当前按验证者配置的 min-gas-prices,因为它与共识 gas price 一起使用会让行为变得混乱。

按区块负载调整

对于第 2 层和第 3 层交易,gas price 会根据前一个区块的负载进行调整,其逻辑可以类似于 EIP-1559:
def adjust_gas_price(gas_price, parent_gas_used, tier):
  if parent_gas_used == tier.parent_gas_target:
    return gas_price
  elif parent_gas_used > tier.parent_gas_target:
    gas_used_delta = parent_gas_used - tier.parent_gas_target
    gas_price_delta = max(gas_price * gas_used_delta // tier.parent_gas_target // tier.change_speed, 1)
    return gas_price + gas_price_delta
  else:
    gas_used_delta = parent_gas_target - parent_gas_used
    gas_price_delta = gas_price * gas_used_delta // parent_gas_target // tier.change_speed
    return gas_price - gas_price_delta

区块分段预留

理想情况下,我们应为每个层级预留区块分段,这样低层级交易就不会被高层级交易完全挤出。否则会迫使用户使用更高层级,系统也会退化为单层级。 我们需要 Tendermint 的支持来实现这一点。

实现

我们可以让每个层级的 gas price 策略在协议参数中完全可配置,同时提供一个合理的默认策略。 类 Python 语法的伪代码:
interface TieredTx:
  def tier(self) -> int:
    pass

def tx_tier(tx):
    if isinstance(tx, TieredTx):
      return tx.tier()
    else:
      # 自定义交易的默认层级
      return 0
    # 注意:我们可以按照“交易优先级”一节在这里添加更多规则 

class TierParams:
  '单个层级的 gas price 策略参数'
  priority: int           # Tendermint mempool 中的优先级
  initial_gas_price: Coin
  parent_gas_target: int
  change_speed: Decimal   # 0 表示不根据区块负载调整。

class Params:
    '协议参数'
    tiers: List[TierParams]

class State:
    '共识状态'
    # 上一个区块使用的总 gas;如果是第一个区块则为 None
    parent_gas_used: Optional[int]
    # 上一个区块中所有层级的 gas price
    gas_prices: List[Coin]

def begin_block():
    '调整 gas price'
    for i, tier in enumerate(Params.tiers):
        if State.parent_gas_used is None:
            # 为第一个区块初始化 gas price
	          State.gas_prices[i] = tier.initial_gas_price
        else:
            # 根据前一区块使用的 gas 调整 gas price
            State.gas_prices[i] = adjust_gas_price(State.gas_prices[i], State.parent_gas_used, tier)

def mempoolFeeTxHandler_checkTx(ctx, tx):
    # 验证者配置的 minimal-gas-price,在 deliver_tx 上下文中为 0
    validator_price = ctx.MinGasPrice()
    consensus_price = State.gas_prices[tx_tier(tx)]
    min_price = max(validator_price, consensus_price)

    # 0 表示 gas price cap 为无限大
    if tx.gas_price() > 0 and tx.gas_price() < min_price:
        return '手续费不足'
    return next_CheckTx(ctx, tx)

def txPriorityHandler_checkTx(ctx, tx):
    res, err := next_CheckTx(ctx, tx)
    # 将优先级传递给 Tendermint
    res.Priority = Params.tiers[tx_tier(tx)].priority
    return res, err

def end_block():
    '更新区块 gas 使用量'
    State.parent_gas_used = block_gas_meter.consumed()

DDoS 攻击防护

如果攻击者想要完全填满区块并阻止其他交易执行,就必须使用最高层级的交易,其成本会显著高于默认层级。 如果攻击者使用较低层级交易进行垃圾攻击,用户可以通过发送更高层级的交易来缓解。

影响

向后兼容性

  • 新的协议参数。
  • 新的共识状态。
  • 交易体中新增加或发生变更的字段。

积极影响

  • 默认层级可为客户端保持相同且可预测的 gas price 体验。
  • 更高层级的 gas price 可以适应区块负载。
  • 不会与基于交易类型的自定义优先级产生冲突,因为该提案只占用三个优先级层级。
  • 可以将不同的优先级规则与层级机制组合。

消极影响

  • 钱包和工具需要更新以支持新的 tier 参数,并且 fee 字段的语义已经改变。

中性

参考资料


Changelog

  • Dec 1, 2021: Initial Draft

Status

Rejected

Abstract

This ADR describes a flexible mechanism to maintain a consensus level gas prices, in which one can choose a multi-tier gas price system or EIP-1559 like one through configuration.

Context

Currently, each validator configures its own minimal-gas-prices in app.yaml. But setting a proper minimal gas price is critical to protect network from DDoS attack, and it’s hard for all the validators to pick a sensible value, so we propose to maintain a gas price in consensus level. Since tendermint 0.34.20 has supported mempool prioritization, we can take advantage of that to implement more sophisticated gas fee system.

Multi-Tier Price System

We propose a multi-tier price system on consensus to provide maximum flexibility:
  • Tier 1: a constant gas price, which could only be modified occasionally through governance proposal.
  • Tier 2: a dynamic gas price which is adjusted according to previous block load.
  • Tier 3: a dynamic gas price which is adjusted according to previous block load at a higher speed.
The gas price of higher tier should bigger than the lower tier. The transaction fees are charged with the exact gas price calculated on consensus. The parameter schema is like this:
message TierParams {
  uint32 priority = 1           // priority in tendermint mempool
  Coin initial_gas_price = 2    //
  uint32 parent_gas_target = 3  // the target saturation of block
  uint32 change_denominator = 4 // decides the change speed
  Coin min_gas_price = 5        // optional lower bound of the price adjustment
  Coin max_gas_price = 6        // optional upper bound of the price adjustment
}

message Params {
  repeated TierParams tiers = 1;
}

Extension Options

We need to allow user to specify the tier of service for the transaction, to support it in an extensible way, we add an extension option in AuthInfo:
message ExtensionOptionsTieredTx {
  uint32 fee_tier = 1
}
The value of fee_tier is just the index to the tiers parameter list. We also change the semantic of existing fee field of Tx, instead of charging user the exact fee amount, we treat it as a fee cap, while the actual amount of fee charged is decided dynamically. If the fee is smaller than dynamic one, the transaction won’t be included in current block and ideally should stay in the mempool until the consensus gas price drop. The mempool can eventually prune old transactions.

Tx Prioritization

Transactions are prioritized based on the tier, the higher the tier, the higher the priority. Within the same tier, follow the default Tendermint order (currently FIFO). Be aware of that the mempool tx ordering logic is not part of consensus and can be modified by malicious validator. This mechanism can be easily composed with prioritization mechanisms:
  • we can add extra tiers out of a user control:
    • Example 1: user can set tier 0, 10 or 20, but the protocol will create tiers 0, 1, 2 … 29. For example IBC transactions will go to tier user_tier + 5: if user selected tier 1, then the transaction will go to tier 15.
    • Example 2: we can reserve tier 4, 5, … only for special transaction types. For example, tier 5 is reserved for evidence tx. So if submits a bank.Send transaction and set tier 5, it will be delegated to tier 3 (the max tier level available for any transaction).
    • Example 3: we can enforce that all transactions of a sepecific type will go to specific tier. For example, tier 100 will be reserved for evidence transactions and all evidence transactions will always go to that tier.

min-gas-prices

Deprecate the current per-validator min-gas-prices configuration, since it would confusing for it to work together with the consensus gas price.

Adjust For Block Load

For tier 2 and tier 3 transactions, the gas price is adjusted according to previous block load, the logic could be similar to EIP-1559:
def adjust_gas_price(gas_price, parent_gas_used, tier):
  if parent_gas_used == tier.parent_gas_target:
    return gas_price
  elif parent_gas_used > tier.parent_gas_target:
    gas_used_delta = parent_gas_used - tier.parent_gas_target
    gas_price_delta = max(gas_price * gas_used_delta // tier.parent_gas_target // tier.change_speed, 1)
    return gas_price + gas_price_delta
  else:
    gas_used_delta = parent_gas_target - parent_gas_used
    gas_price_delta = gas_price * gas_used_delta // parent_gas_target // tier.change_speed
    return gas_price - gas_price_delta

Block Segment Reservation

Ideally we should reserve block segments for each tier, so the lower tiered transactions won’t be completely squeezed out by higher tier transactions, which will force user to use higher tier, and the system degraded to a single tier. We need help from tendermint to implement this.

Implementation

We can make each tier’s gas price strategy fully configurable in protocol parameters, while providing a sensible default one. Pseudocode in python-like syntax:
interface TieredTx:
  def tier(self) -> int:
    pass

def tx_tier(tx):
    if isinstance(tx, TieredTx):
      return tx.tier()
    else:
      # default tier for custom transactions
      return 0
    # NOTE: we can add more rules here per "Tx Prioritization" section 

class TierParams:
  'gas price strategy parameters of one tier'
  priority: int           # priority in tendermint mempool
  initial_gas_price: Coin
  parent_gas_target: int
  change_speed: Decimal   # 0 means don't adjust for block load.

class Params:
    'protocol parameters'
    tiers: List[TierParams]

class State:
    'consensus state'
    # total gas used in last block, None when it's the first block
    parent_gas_used: Optional[int]
    # gas prices of last block for all tiers
    gas_prices: List[Coin]

def begin_block():
    'Adjust gas prices'
    for i, tier in enumerate(Params.tiers):
        if State.parent_gas_used is None:
            # initialized gas price for the first block
	          State.gas_prices[i] = tier.initial_gas_price
        else:
            # adjust gas price according to gas used in previous block
            State.gas_prices[i] = adjust_gas_price(State.gas_prices[i], State.parent_gas_used, tier)

def mempoolFeeTxHandler_checkTx(ctx, tx):
    # the minimal-gas-price configured by validator, zero in deliver_tx context
    validator_price = ctx.MinGasPrice()
    consensus_price = State.gas_prices[tx_tier(tx)]
    min_price = max(validator_price, consensus_price)

    # zero means infinity for gas price cap
    if tx.gas_price() > 0 and tx.gas_price() < min_price:
        return 'insufficient fees'
    return next_CheckTx(ctx, tx)

def txPriorityHandler_checkTx(ctx, tx):
    res, err := next_CheckTx(ctx, tx)
    # pass priority to tendermint
    res.Priority = Params.tiers[tx_tier(tx)].priority
    return res, err

def end_block():
    'Update block gas used'
    State.parent_gas_used = block_gas_meter.consumed()

DDoS attack protection

To fully saturate the blocks and prevent other transactions from executing, attacker need to use transactions of highest tier, the cost would be significantly higher than the default tier. If attacker spam with lower tier transactions, user can mitigate by sending higher tier transactions.

Consequences

Backwards Compatibility

  • New protocol parameters.
  • New consensus states.
  • New/changed fields in transaction body.

Positive

  • The default tier keeps the same predictable gas price experience for client.
  • The higher tier’s gas price can adapt to block load.
  • No priority conflict with custom priority based on transaction types, since this proposal only occupy three priority levels.
  • Possibility to compose different priority rules with tiers

Negative

  • Wallets & tools need to update to support the new tier parameter, and semantic of fee field is changed.

Neutral

References