Logo
新用户订阅 RPC,首月享 6.5 折优惠查看优惠
OnFinality Learn
网络与协议指南阅读约 14 分钟

eth_feeHistory 奖励百分位:自校正费用估算器

构建一个闭环 EIP-1559 费用估算器,利用 eth_feeHistory 奖励百分位和观察到的打包延迟,自动调整优先费百分位。

TL;DR

eth_feeHistory 奖励百分位提供了每个区块中,按费用排序后位于指定百分位的交易实际支付的有效优先费视图。与基础费不同——基础费由 EIP-1559 协议规则根据上一区块的 gas 使用量固定——优先费是一个估计值,而百分位是一个控制输入。自校正估算器会提交交易、观察打包延迟,并带滞回地将百分位相对于目标上下调整。本文提供估算器和反馈回路的可运行 Node.js 代码、供你针对自己的端点填写的结结果表,以及针对提供商上限、零奖励数组和未最终化区块的故障排除。

eth_feeHistory 的请求契约

以太坊 JSON-RPC 规范定义了 eth_feeHistory 的三个参数:blockCount(要查询的区块数量)、newestBlock(最高区块号或 'latest' 等标签)以及 rewardPercentiles(0 到 100 之间的百分位值数组)。rewardPercentiles 数组必须单调递增;响应中的 reward 数组按相同顺序与这些百分位对齐。这就是你构建所依据的契约,记录在以太坊 JSON-RPC 规范中。

响应包含四个数组。oldestBlock 是窗口内第一个区块的区块号。baseFeePerGas 的长度为 blockCount + 1,因为它包含最后一个被查询区块之后那个区块的基础费,也就是下一个区块的基础费。gasUsedRatio 的长度为 blockCount,给出每个区块中使用的 gas 比例。reward 是一个数组的数组:对于每个区块,一个数组,包含在请求的百分位上的交易支付的有效优先费,单位为 wei。一个常见的陷阱是单位换算:所有值都以 wei 为单位,转换为 gwei 需要除以 1e9。如果你把 wei 值传入以 gwei 为单位的字段,你的交易价格将低至十亿分之一。

reward 数组是响应中唯一反映用户实际支付情况的部分。它不是预测;它是对所选百分位上有效优先费的历史观察。你选择的百分位决定了你在每个区块按费用排序的集合中采样哪笔交易。低百分位采样的是可能等待了许多区块的便宜交易;高百分位采样的是支付更多以快速打包的交易。

  • blockCount:要查询的区块数量;提供商可能会限制此值。
  • newestBlock:最高区块号或 'latest';必须是规范链上存在的区块。
  • rewardPercentiles:0 到 100 之间单调递增的数字数组。
  • oldestBlock:返回窗口中的第一个区块号。
  • baseFeePerGas:长度为 blockCount + 1 的数组;最后一个元素是下一个区块的基础费。
  • gasUsedRatio:长度为 blockCount 的数组;每个区块使用的 gas 比例。
  • reward:长度为 blockCount 的数组;每个元素是与 rewardPercentiles 对齐的有效优先费数组。

基础费由协议固定,不是预测问题

EIP-1559 定义了基础费更新规则:下一个区块的基础费是上一区块 gas 使用量相对于目标的函数。如果上一区块恰好使用了目标值,基础费保持不变。如果使用更多,基础费增加;如果更少,则减少。变化幅度受每区块最大因子限制。该规则是确定性的,记录在 EIP-1559 中。

由于基础费由协议固定,你的估算器应根据规则计算下一个基础费,而不是外推或平均历史基础费。平均会引入滞后,并可能在基础费上升期间定价过低。Base Fee Manipulation In Ethereum's EIP-1559 Transaction Fee Mechanism 中的独立分析表明,基础费行为是政策驱动的,并且可能跨区块受到矿工或验证者的影响,这进一步说明它不是需求预测问题。使用协议规则,而不是统计模型。

实际含义是 maxFeePerGas 应设置为 nextBaseFee * safetyMultiplier + maxPriorityFeePerGas。安全乘数考虑了在你的交易被打包之前基础费上涨的可能性。常见选择是 1.125 或 1.25,但正确的值取决于你愿意等待多少个区块以及你所在链上基础费的波动程度。

优先费百分位作为控制输入

优先费是费用中归区块生产者所有的部分。与基础费不同,它不由协议固定;它是市场结果。rewardPercentiles 参数让你采样每个区块内有效优先费的分布。例如,rewardPercentiles: [10, 50, 90] 会为每个区块返回该区块按费用排序的交易中位于第 10、50 和 90 百分位的交易支付的有效优先费。

百分位太低会导致交易不被打包,因为它被其他交易出价超过。百分位太高则会多付,因为你支付的超过了打包所需。正确的百分位不是固定数字;它取决于当前网络状况、你对打包延迟的重视程度,以及你正在采样的区块中其他交易的行为。

这是大多数提供商文档没有解释的部分:百分位是一个控制输入,其正确值取决于观察到的打包结果。你不能从博客文章中复制一个百分位,并期望它在不同链和市场条件下都有效。你必须测量自己的打包延迟并进行调整。

闭环:提交、观察、调整

自校正估算器将百分位视为反馈回路中的一个变量。该回路有四个阶段:估算、提交、观察、调整。在估算阶段,你用当前百分位集合调用 eth_feeHistory,并计算 maxFeePerGas 和 maxPriorityFeePerGas。在提交阶段,你签名并发送交易。在观察阶段,你记录提交时的区块号和打包时的区块号,并计算以区块为单位的打包延迟。在调整阶段,你将观察到的延迟与目标进行比较,并向上或向下移动百分位。

滞回对于避免振荡很重要。如果你对每笔交易都调整百分位,可能会过冲,并造成多付和少付的循环。一个简单的方法是仅在观察到的延迟偏离目标超过阈值时才调整,或者以小步长调整,并要求多次观察后才进行较大变化。例如,如果目标是两个区块,而观察到的延迟是五个区块,则将百分位提高 5 个点。如果观察到的延迟是一个区块,则降低 2 个点。如果观察到的延迟是两个或三个区块,则不做任何操作。

这个闭环是首页结果没有覆盖的部分。提供商文档描述了请求和响应,但没有说明如何使用响应来驱动控制决策。这个回路正是将 eth_feeHistory 从数据源转变为自校正估算器的关键。

  • 估算:用当前百分位集合调用 eth_feeHistory。
  • 提交:用计算出的费用签名并发送交易。
  • 观察:记录提交区块和打包区块;计算以区块为单位的延迟。
  • 调整:将延迟与目标比较;带滞回地向上或向下移动百分位。

使用 eth_feeHistory 的可运行 Node.js 估算器

以下 Node.js 脚本使用可配置的百分位集合调用 eth_feeHistory,根据 EIP-1559 规则计算下一个基础费,取窗口内所选百分位上的优先费(使用每个区块值的中位数,而不是单个区块),加上安全乘数,并输出 maxFeePerGas 和 maxPriorityFeePerGas。它使用内置的 fetch API,并假设环境变量 RPC_URL 中有一个 JSON-RPC 端点 URL。

该脚本通过将 EIP-1559 规则应用于窗口中的最后一个区块来计算下一个基础费。它使用最后一个区块的 gasUsedRatio 和最后一个区块的 baseFeePerGas 来计算下一个基础费。然后,它取窗口中所有区块在所选百分位索引上的 reward 值的中位数。这个中位数比单个区块的值更稳健,后者可能是异常值。

安全乘数仅应用于基础费部分。优先费在乘数之后加上。这符合 EIP-1559 语义:maxFeePerGas 是每 gas 的最大总费用,maxPriorityFeePerGas 是每 gas 的最大优先费。只有在打包时 maxFeePerGas >= baseFeePerGas + maxPriorityFeePerGas,交易才有效。

const RPC_URL = process.env.RPC_URL;
const PERCENTILES = [10, 25, 50, 75, 90];
const CHOSEN_PERCENTILE_INDEX = 2; // 50th percentile
const BLOCK_COUNT = 20;
const SAFETY_MULTIPLIER = 1.125;

async function rpc(method, params) {
  const res = await fetch(RPC_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params })
  });
  const json = await res.json();
  if (json.error) throw new Error(JSON.stringify(json.error));
  return json.result;
}

function median(values) {
  const sorted = [...values].sort((a, b) => a - b);
  const mid = Math.floor(sorted.length / 2);
  return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}

function nextBaseFee(lastBaseFee, lastGasUsedRatio) {
  const target = 0.5;
  const maxChange = 0.125;
  const delta = (lastGasUsedRatio - target) / target;
  const bounded = Math.max(-maxChange, Math.min(maxChange, delta));
  return Math.floor(lastBaseFee * (1 + bounded));
}

async function estimateFees() {
  const history = await rpc('eth_feeHistory', [
    '0x' + BLOCK_COUNT.toString(16),
    'latest',
    PERCENTILES
  ]);

  const baseFees = history.baseFeePerGas.map((hex) => parseInt(hex, 16));
  const gasRatios = history.gasUsedRatio;
  const rewards = history.reward.map((blockRewards) =>
    blockRewards.map((hex) => parseInt(hex, 16))
  );

  const lastBaseFee = baseFees[baseFees.length - 2];
  const lastGasRatio = gasRatios[gasRatios.length - 1];
  const nextBase = nextBaseFee(lastBaseFee, lastGasRatio);

  const prioritySamples = rewards.map((block) => block[CHOSEN_PERCENTILE_INDEX]);
  const medianPriority = median(prioritySamples);

  const maxPriorityFeePerGas = Math.ceil(medianPriority);
  const maxFeePerGas = Math.ceil(nextBase * SAFETY_MULTIPLIER) + maxPriorityFeePerGas;

  return {
    nextBaseFee: nextBase,
    medianPriority,
    maxPriorityFeePerGas,
    maxFeePerGas,
    maxPriorityFeePerGasGwei: maxPriorityFeePerGas / 1e9,
    maxFeePerGasGwei: maxFeePerGas / 1e9
  };
}

estimateFees().then(console.log).catch(console.error);

测量打包率并更新百分位

第二个可运行代码片段根据观察到的打包数据测量打包率,并更新百分位。它假设你有一个最近交易的列表,包含它们的提交区块、打包区块和使用的百分位。它计算平均打包延迟,并带滞回地向上或向下调整百分位。调整逻辑有意保持简单:如果平均延迟超过目标一个区块以上,则将百分位提高一个步长;如果低于目标一个区块以上,则降低一个较小的步长;否则保持不变。

该代码片段还计算了一个多付指标:你支付的优先费与打包区块中所选百分位上的中位优先费之间的差值。这有助于区分“已打包”和“以低成本打包”。一笔在一个区块内打包但支付了第 90 百分位、而第 50 百分位本已足够的交易,是降低百分位的候选。

在生产环境中,你会将百分位和观察历史持久化到数据库或文件中。为清晰起见,该代码片段使用内存对象。关键点是百分位不是常量;它是一个随观察结果演变的状态变量。

const TARGET_LATENCY_BLOCKS = 2;
const HYSTERESIS_BLOCKS = 1;
const UP_STEP = 5;
const DOWN_STEP = 2;
const MIN_PERCENTILE = 5;
const MAX_PERCENTILE = 95;

function updatePercentile(currentPercentile, observations) {
  if (observations.length === 0) return currentPercentile;

  const avgLatency =
    observations.reduce((sum, o) => sum + (o.inclusionBlock - o.submissionBlock), 0) /
    observations.length;

  const deviation = avgLatency - TARGET_LATENCY_BLOCKS;

  if (deviation > HYSTERESIS_BLOCKS) {
    return Math.min(MAX_PERCENTILE, currentPercentile + UP_STEP);
  }
  if (deviation < -HYSTERESIS_BLOCKS) {
    return Math.max(MIN_PERCENTILE, currentPercentile - DOWN_STEP);
  }
  return currentPercentile;
}

function overpayment(paidPriorityFee, medianPriorityFeeAtInclusion) {
  return paidPriorityFee - medianPriorityFeeAtInclusion;
}

// Example usage
const observations = [
  { submissionBlock: 100, inclusionBlock: 105, percentile: 50, paidPriorityFee: 2e9 },
  { submissionBlock: 106, inclusionBlock: 108, percentile: 50, paidPriorityFee: 2e9 },
  { submissionBlock: 109, inclusionBlock: 110, percentile: 50, paidPriorityFee: 2e9 }
];

const currentPercentile = 50;
const newPercentile = updatePercentile(currentPercentile, observations);
console.log({ currentPercentile, newPercentile });

const over = overpayment(2e9, 1.5e9);
console.log({ overpaymentWei: over, overpaymentGwei: over / 1e9 });

针对你自己端点的结果表

使用下表记录针对你自己端点的测量结果。用不同的百分位运行估算器,提交交易,并记录所选百分位上的中位奖励、打包所需区块数,以及相对于打包时中位奖励的多付。目标是找到能持续满足你目标打包延迟的最低百分位。

在至少几个小时的时间内填写该表,以捕捉网络条件的变化。单个样本不足以得出结论。如果你在低活跃度链上测试,可能需要等待更长时间,或使用流量受控的测试网。

多付列是你支付的优先费与打包区块中同一百分位上的中位优先费之间的差值。正值表示你支付得高于中位数;负值表示你支付得低于中位数。持续为正的多付表明你可以降低百分位。

  • 百分位:用于估算的 rewardPercentiles 值。
  • 中位奖励(gwei):该百分位上每个区块奖励值的中位数。
  • 打包所需区块数:打包区块减去提交区块。
  • 多付(gwei):支付的优先费减去打包时的中位优先费。

故障模式与故障排除

提供商 blockCount 上限是一种常见的故障模式。许多提供商限制你在单次 eth_feeHistory 调用中可以查询的区块数量。当你超过上限时,提供商会返回一个 JSON-RPC 错误对象。JSON-RPC 2.0 规范定义了错误信封:一个 code、一个 message 和可选的 data。你必须解码此错误并处理它,而不是吞掉它。一个稳健的估算器会捕获错误、减少 blockCount 并重试。如果你忽略该错误,你的估算器可能会使用过时或缺失的数据。

全零 reward 数组可能出现在低活跃度链上,或没有交易支付优先费的时期。在这种情况下,中位优先费为零,如果基础费足够,你的交易可能会以零优先费被打包。然而,零优先费可能不被所有区块生产者接受。一个后备方案是使用 eth_maxPriorityFeePerGas,它返回建议的优先费,或设置最低优先费下限。

尚未最终化的 newestBlock 可能导致你的估算器使用一个可能被重组(reorg)的区块。如果该区块被重组,基础费和奖励数据可能会改变。对于大多数用例,使用 'latest' 是可以接受的,但对于高价值交易,你可能希望使用落后于链头几个区块的区块,以降低重组风险。代价是数据略微过时。

当历史窗口不具代表性时,maxPriorityFeePerGas 后备方案很有用。如果窗口只包含低活跃度区块,奖励百分位可能为零或接近零。在这种情况下,eth_maxPriorityFeePerGas 提供了一个提供商建议的值,可能更合适。文档化的行为因提供商而异;一些提供商根据最近区块计算此值,而另一些则使用固定启发式。

  • 提供商 blockCount 上限:解码 JSON-RPC 错误对象,并用更小的 blockCount 重试。
  • 全零 reward 数组:使用 eth_maxPriorityFeePerGas 或设置最低优先费下限。
  • 未最终化的 newestBlock:对于高价值交易,使用落后链头几个区块的区块。
  • 不具代表性的历史窗口:回退到 eth_maxPriorityFeePerGas 或扩大窗口。

局限性与权衡

Gas 估算流量是有成本的。每次 eth_feeHistory 调用都会消耗提供商资源,并可能计入你的速率限制。一个对每笔交易都调用 eth_feeHistory 的自校正估算器,可能比一个将结果缓存几个区块的简单估算器产生更多流量。这是在响应性和成本之间的权衡。对于大多数应用,将结果缓存一两个区块就足够了。

来自历史的百分位无法预测尖峰。如果一笔大额交易或一批交易进入内存池,及时打包所需的优先费可能会急剧上升。历史百分位反映的是过去的情况,而不是未来的需求。基础费上的安全乘数有助于应对基础费尖峰,但优先费部分不受保护。对于时间敏感的交易,考虑使用更高的百分位或动态乘数。

“已打包”和“以低成本打包”之间的区别很重要。一笔以第 90 百分位在一个区块内打包的交易,不一定比一笔以第 50 百分位在两个区块内打包的交易更好。前者多付;后者可能更具成本效益。你的目标延迟应反映打包速度对你的用例的价值。对于非紧急转账,两到三个区块可能可以接受;对于套利交易,一个区块可能至关重要。

闭环需要观察。你必须记录自己交易的提交和打包区块。如果你控制发送钱包,这很简单,但如果你为第三方估算,则更困难。在这种情况下,你可以使用公共数据集或区块浏览器 API 来观察具有类似费用参数的交易的打包延迟。

后续步骤与相关指南

要更深入地了解 eth_feeHistory 方法本身,包括响应结构和简单估算器,请参阅使用 eth_feeHistory 估算 gas 价格。该页面涵盖该方法以及基础费重复计算的陷阱;本页面涵盖调整百分位的闭环。

要处理卡住或定价过低的交易,请参阅使用 eth_sendRawTransaction 进行替换和低价交易。对于发送多笔交易时至关重要的 nonce 管理,请参阅使用 eth_getTransactionCount 进行 EVM nonce 管理。要检查内存池并了解其他交易支付了多少,请参阅以太坊 txpool 命名空间和内存池检查

关于端点选择和提供商比较,请参阅以太坊 RPC 列表和端点选择(RPC Assistant)。关于网络特定细节,请参阅以太坊网络页面。关于定价和速率限制,请参阅 RPC 定价API 服务。关于 OnFinality Learn 的更广泛概述,请参阅 OnFinality Learn 中心

如果你在 OP-Stack 链上构建,请注意 L1 费用部分与 EIP-1559 费用是分开的。详情请参阅 OP-Stack L1 费用计算和交易总成本

永远不用担心基础设施

OnFinality 消除了 DevOps 的繁重工作,让您能够更聪明、更快地构建。

开始