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

Base OP Stack 提现:通过 RPC 证明并最终完成 L2 到 L1 转账

一份完整的 RPC 驱动指南,介绍 Base 上 OP Stack 提现生命周期:发起、证明构建、挑战窗口以及 L1 最终确认。

TL;DR

在 Base 上,OP Stack 的 L2 到 L1 提现是一个两阶段过程:首先在 L2 上调用 L2ToL1MessagePasser 预部署合约的 initiateWithdrawal 发起提现,这会在 sentMessages 映射中记录一个提现哈希;然后,在故障证明挑战窗口结束后,通过 OptimismPortal 合约在 L1 上证明并最终完成提现。证明需要锚定到已最终确认的 L2 输出的输出根证明,以及针对 L2ToL1MessagePasser 存储根的存储证明(eth_getProof),这意味着你查询的 L2 节点必须仍保留包含该提现的区块状态。最终确认受挑战窗口限制,这是一个协议参数,在 OP Stack 各版本中已发生变化,并因链和升级而异。本指南通过 JSON-RPC 逐步介绍每个阶段,展示如何读取 sentMessages 和 portal 状态,并提供可复现的状态表,以便你独立验证每一步。

Base 上 OP Stack 的 L2 到 L1 提现生命周期

Base 是一个 OP Stack L2,其提现机制遵循 OP Stack 规范:用户在 L2 上发起提现,等待故障证明挑战窗口过去,然后在 L1 上证明并最终完成提现。OP Stack 提现规范定义了合约和消息格式,而 Optimism 文档提现指南描述了操作流程。与由 L1 事件驱动并可在 L2 上重放的存款不同,提现需要明确的 L1 证明,证明 L2 状态转换已最终确认。

该生命周期有四个可观察阶段:L2 发起、L1 输出根提议、L1 证明和 L1 最终确认。每个阶段都可以通过 RPC 独立验证,这在索引器或支持工作流中核对提现时非常有用。如果你不熟悉 Base RPC 访问,Base 网络页面和 Base RPC 端点指南涵盖了端点选择和链 ID。

  • 发起:在 L2ToL1MessagePasser 上调用 initiateWithdrawal;提现哈希被追加到 sentMessages。
  • 输出根提议:无需许可的提议者将 L2 输出根发布到 L1;索引可用性可能滞后。
  • 证明:使用输出根证明和存储证明在 OptimismPortal 上调用 proveWithdrawalTransaction。
  • 最终确认:挑战窗口结束后,调用 finalizeWithdrawalTransaction 在 L1 上释放资金。

L2ToL1MessagePasser 预部署合约与 initiateWithdrawal

L2ToL1MessagePasser 是每个 OP Stack 链(包括 Base)上位于 0x4200000000000000000000000000000000000016 的预部署合约。其 initiateWithdrawal 函数接受提现参数(target、gasLimit、data),并通过计算哈希并设置 sentMessages[hash] = true 来记录提现。调用时发送的值在 L2 上由合约托管;严格来说它并未被销毁,而是被锁定,直到相应的 L1 最终确认从 OptimismPortal 释放它。

提现哈希基于元组 (nonce, sender, target, value, gasLimit, data) 计算。在 OP Stack 参考实现中,这是 Hashing.hashWithdrawal,它对 ABI 编码的提现结构体进行哈希。nonce 是调用时 L2ToL1MessagePasser 的 nonce,sender 是调用 initiateWithdrawal 的 L2 账户。由于哈希依赖于所有这些字段,你必须精确捕获它们以便之后复现。

  • 预部署地址:0x4200000000000000000000000000000000000016
  • 函数:initiateWithdrawal(address _target, uint256 _gasLimit, bytes _data) payable
  • 存储映射:sentMessages[bytes32 withdrawalHash] => bool
  • 哈希输入:nonce、sender、target、value、gasLimit、data

通过 L2 RPC 计算提现哈希并读取 sentMessages

要确认提现已发起,你需要在本地计算提现哈希,然后从 L2ToL1MessagePasser 存储中读取 sentMessages[hash]。该映射位于已知的存储槽;映射值的槽位是 keccak256(abi.encode(key, slot))。在参考实现中,L2ToL1MessagePasser 将 sentMessages 存储在槽 0,但你应该根据你所在链和升级版本的已部署合约验证该槽位。

以下 Node.js 示例使用 viem 通过 Base RPC 端点计算提现哈希并读取存储槽。它假设你已从 L2 交易收据或自己的调用数据中获取提现参数。将 RPC URL 替换为你的提供商端点;API 服务页面描述了 OnFinality 如何提供 Base RPC。

import { createPublicClient, http, keccak256, encodeAbiParameters, parseAbiParameters } from 'viem';
import { base } from 'viem/chains';

const client = createPublicClient({ chain: base, transport: http('https://base.api.onfinality.io/public') });

const MESSAGE_PASSER = '0x4200000000000000000000000000000000000016';
const SENT_MESSAGES_SLOT = 0n;

// Withdrawal parameters captured from the L2 transaction
const withdrawal = {
  nonce: 0n,
  sender: '0xYourL2SenderAddress',
  target: '0xYourL1TargetAddress',
  value: 1000000000000000n,
  gasLimit: 100000n,
  data: '0x'
};

// Compute the withdrawal hash (matches Hashing.hashWithdrawal)
const encoded = encodeAbiParameters(
  parseAbiParameters('uint256, address, address, uint256, uint256, bytes'),
  [withdrawal.nonce, withdrawal.sender, withdrawal.target, withdrawal.value, withdrawal.gasLimit, withdrawal.data]
);
const withdrawalHash = keccak256(encoded);
console.log('withdrawalHash:', withdrawalHash);

// Compute the storage slot for sentMessages[withdrawalHash]
const slot = keccak256(
  encodeAbiParameters(parseAbiParameters('bytes32, uint256'), [withdrawalHash, SENT_MESSAGES_SLOT])
);

// Read the storage value over L2 RPC
const storage = await client.getStorageAt({
  address: MESSAGE_PASSER,
  slot,
  blockNumber: 'latest'
});
console.log('sentMessages[hash]:', storage);
// A non-zero value confirms the withdrawal was initiated.

为什么最终确认受故障证明挑战窗口限制

提现发起后无法立即在 L1 上最终确认,因为包含该提现的 L2 状态转换必须首先通过故障证明系统最终确认。在 OP Stack 链中,这就是挑战窗口:在此期间任何人都可以对提议的 L2 输出根提出争议。该窗口是一个协议参数,在 OP Stack 各版本中已发生变化,并因链和升级而异;常被引用的数字约为七天,但你应将其视为已文档化/因链和升级而异,而非固定常量。

实际后果是,你的 L1 资金释放时间取决于包含你提现的输出根何时最终确认,而不是你何时发起提现。输出根由无需许可的提议者提议,因此包含你提现的输出根索引可能滞后于 L2 头部。如果你更广泛地跟踪最终性语义,请参阅 Base OP Stack 最终性、安全区块和已最终确认区块。

  • 挑战窗口:对提议的 L2 输出根的争议期;在许多部署中约为七天。
  • 输出根提议:无需许可;索引可用性可能滞后于 L2 头部。
  • 最终确认门槛:包含你提现的输出根必须在 L1 上最终确认。
  • 参数漂移:窗口长度和合约接口在 OP Stack 各版本中已发生变化。

OptimismPortal 与 proveWithdrawalTransaction 调用

OptimismPortal 是持有托管资金并处理提现的 L1 合约。其 proveWithdrawalTransaction 函数接受提现参数、输出根证明和存储证明。输出根证明将你的提现锚定到已提议到 L1 的特定 L2 输出根;存储证明则证明在该输出根对应的 L2 区块上,L2ToL1MessagePasser 存储中 sentMessages[withdrawalHash] 已被设置。

输出根证明包括 L2 状态根、消息传递者存储根、L2 区块哈希和 L2 区块号。存储证明由 eth_getProof 针对同一 L2 区块上的 L2ToL1MessagePasser 地址生成。由于证明锚定到特定 L2 区块,你查询的 L2 节点必须仍保留该区块的状态。已修剪所需 L2 区块的节点无法生成证明,这就是为什么在发起后很久才证明的提现通常需要归档访问。

  • OptimismPortal:托管资金并处理证明/最终确认调用的 L1 合约。
  • proveWithdrawalTransaction:提交提现、输出根证明和存储证明。
  • 输出根证明:L2 状态根、消息传递者存储根、L2 区块哈希、L2 区块号。
  • 存储证明:针对锚定 L2 区块上的 L2ToL1MessagePasser 调用 eth_getProof。

使用 eth_getProof 从 L2 归档状态构建 L1 证明

proveWithdrawalTransaction 的存储证明由 L2 节点上的 eth_getProof 生成。该调用接受 L2ToL1MessagePasser 地址、存储键数组(你的提现哈希对应的 sentMessages 槽位)以及与你正在证明的输出根对应的 L2 区块号。响应包括账户证明、存储证明和存储根,然后你将它们组装成输出根证明和存储证明参数。

以下示例展示了 eth_getProof 调用形式,以及如何读取 portal 状态以确定最终确认是否已解锁。它使用原始 JSON-RPC 进行证明调用,以便请求形式明确,并使用 viem 进行 portal 读取。portal 暴露的 params 或 dispute game 接口因 OP Stack 版本而异;你应该读取你所在链和升级版本的已部署 ABI。

import { createPublicClient, http, parseAbi } from 'viem';
import { mainnet } from 'viem/chains';

const l1 = createPublicClient({ chain: mainnet, transport: http('https://eth.api.onfinality.io/public') });
const l2Rpc = 'https://base.api.onfinality.io/public';

const MESSAGE_PASSER = '0x4200000000000000000000000000000000000016';
const withdrawalHash = '0xYourWithdrawalHash';
const l2BlockNumber = '0xYourL2BlockNumberHex';

// Compute the storage slot for sentMessages[withdrawalHash]
// (slot 0 in the reference implementation; verify against the deployed contract)
const slot = '0xYourComputedStorageSlot';

// eth_getProof against the L2ToL1MessagePasser at the anchored L2 block
const proof = await fetch(l2Rpc, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    jsonrpc: '2.0',
    id: 1,
    method: 'eth_getProof',
    params: [MESSAGE_PASSER, [slot], l2BlockNumber]
  })
}).then(r => r.json());

console.log('accountProof:', proof.result.accountProof.length, 'nodes');
console.log('storageProof:', JSON.stringify(proof.result.storageProof));

// Read the portal to check whether finalization is unlocked
const PORTAL = '0xYourOptimismPortalAddress';
const portalAbi = parseAbi([
  'function finalizedWithdrawals(bytes32) view returns (bool)',
  'function proveWithdrawalTransaction((uint256,address,address,uint256,uint256,bytes),uint256,(bytes32,bytes32,bytes32,bytes32),bytes[])'
]);

const isFinalized = await l1.readContract({
  address: PORTAL,
  abi: portalAbi,
  functionName: 'finalizedWithdrawals',
  args: [withdrawalHash]
});
console.log('finalizedWithdrawals[hash]:', isFinalized);

读取挑战窗口并轮询最终确认资格

要知道何时允许最终确认,你需要确定锚定你提现的输出根还剩多少挑战窗口。OptimismPortal 和 dispute game 合约暴露了可用于计算此状态的状态,但确切接口因 OP Stack 版本而异。在较旧的部署中,L2OutputOracle 暴露输出的时间戳和最终确认期;在较新的部署中,dispute game 暴露游戏的创建时间和解决状态。你应该读取你所在链和升级版本的已部署 ABI,而不是假设单一接口。

一种实用的轮询策略是在每次轮询时读取 portal 的 finalizedWithdrawals 映射以及相关的输出或游戏状态,并且仅当输出根已最终确认且挑战窗口已过时,才将最终确认视为已解锁。由于输出根由无需许可的提议者提议,你可能需要轮询包含你提现的输出根索引,然后才能证明它。Base OP Stack L1 派生与时间戳页面介绍了 L1 时间戳与 L2 区块的关系,这在推理窗口到期时很有用。

  • 读取 portal 的 finalizedWithdrawals 映射,检查最终确认是否已发生。
  • 读取输出预言机或 dispute game 状态,确定窗口到期时间。
  • 轮询包含你提现的输出根索引;提议者时间可能滞后。
  • 仅当输出根已最终确认且窗口已过时,才将最终确认视为已解锁。

可复现的提现状态表与独立验证

由于提现时间取决于协议参数和提议者行为,最可靠的方法是在状态表中记录每个阶段并独立验证。下表是一个模板,你按每次提现填写;每一列对应一个你可以重新运行的 RPC 调用或交易,以确认该阶段。这使工作流可审计,并帮助你在提现看似卡住时隔离出哪个阶段滞后。

在索引器中核对提现时,同样的原则适用:将 L2 发起、输出根提议、L1 证明交易和 L1 最终确认交易作为独立事件进行验证。逐块 EVM 索引器核对页面描述了一种通用核对方法,非常适合提现跟踪。

  • L2 交易哈希:调用 initiateWithdrawal 的交易;验证状态和日志。
  • 提现哈希:由 nonce、sender、target、value、gasLimit、data 计算;对照 sentMessages 验证。
  • 输出根索引:包含你提现的 L1 输出提议;验证其已最终确认。
  • 证明交易:调用 proveWithdrawalTransaction 的 L1 交易;验证证明已被接受。
  • 最终确认交易:调用 finalizeWithdrawalTransaction 的 L1 交易;验证 finalizedWithdrawals 为 true。
  • 窗口到期:允许最终确认之后的时间戳;对照已部署合约验证。

排查常见的提现证明和最终确认失败

大多数提现失败可归为几类:由于 L2 节点已修剪所需区块而无法生成证明、输出根索引尚不可用、挑战窗口尚未过去,或证明参数与锚定的输出根不匹配。每一种都会产生不同的错误或回滚原因,并且每一种都可以通过特定的 RPC 调用进行诊断。首先确认在你要证明的区块上,L2 的 sentMessages[withdrawalHash] 已设置。

如果 eth_getProof 失败或返回关于缺失状态的错误,你查询的节点很可能已修剪该 L2 区块。你需要归档节点或保留相关区块历史状态的提供商。如果 proveWithdrawalTransaction 回滚,请检查输出根证明是否与实际提议的输出根匹配,以及证明中的 L2 区块号是否对应包含你提现的区块。如果 finalizeWithdrawalTransaction 回滚,请检查挑战窗口和 finalizedWithdrawals 映射。

  • 状态缺失:L2 节点已修剪该区块;使用归档节点或历史状态提供商。
  • 输出根不匹配:证明锚定到与已提议输出根不同的输出根。
  • 窗口未过:最终确认受挑战窗口限制;轮询 portal 状态。
  • 已最终确认:finalizedWithdrawals[hash] 为 true;提现已释放。
  • 存储槽错误:对照你所在链的已部署合约验证 sentMessages 槽位。

限制、权衡与协议参数漂移

挑战窗口是一个协议参数,在 OP Stack 各版本中已发生变化,并因链和升级而异;你不应硬编码单一值。输出根由无需许可的提议者提议,因此你提现的索引可用性可能滞后于 L2 头部,并且无法保证提议者会以特定节奏发布。证明所需的 L2 状态必须仍由你查询的节点保留,这意味着在发起后很久才证明的提现通常需要归档访问。

已最终确认的提现在 L1 上是最终的,但如果 L1 证明错误,L2 销毁是不可逆的。换句话说,如果你针对错误的输出根或使用格式错误的证明参数进行证明,你可能无法最终确认,并且 L2 托管资金将保持锁定。这就是为什么如上文状态表所述,对每个阶段进行独立验证值得付出努力。在大规模运行这些查询时,关于端点和定价的考虑,请参阅 RPC 定价。

  • 挑战窗口:已文档化/因链和升级而异;不要硬编码。
  • 输出根提议:无需许可;索引可用性可能滞后。
  • L2 状态保留:你查询的节点必须仍持有所需区块。
  • 不可逆性:错误的 L1 证明可能导致 L2 托管资金锁定。
  • 接口漂移:portal 和 oracle ABI 在 OP Stack 各版本中有所不同。

生产环境提现跟踪的后续步骤

对于生产系统,最稳健的方法是将每次提现视为具有可独立验证阶段的状态机,并将提现哈希、输出根索引、证明交易和最终确认交易作为单独记录持久化。这让你可以重试证明或最终确认,而无需重新推导整个历史,并且更容易暴露哪个阶段滞后。如果你在 Base 上构建,Base 网络页面和 OnFinality Learn 中心是相关 RPC 主题的良好起点。

如果你需要比较流程的存款侧,Base OP Stack 存款事件与提现证明页面涵盖了 L1 到 L2 存款和存款哈希。关于端点选择和工具,Base RPC 端点指南和 API 服务页面描述了如何连接。一如既往,在自动化中依赖协议参数之前,请对照你所在链和升级版本的已部署合约进行验证。

  • 将每次提现建模为具有可独立验证阶段的状态机。
  • 分别持久化提现哈希、输出根索引、证明交易和最终确认交易。
  • 重试证明或最终确认,而无需重新推导整个历史。
  • 对照你所在链和升级版本的已部署合约验证协议参数。

永远不用担心基础设施

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

开始