前序博客:
用户将资产从OP主网转移到以太坊主网时需要等待一周的时间。这段时间称为挑战期,有助于保护 OP 主网上存储的资产。
而OP测试网的挑战期仅为60秒,以简化开发过程。
L1(以太坊)上的合约,可通过“bridging”,与L2(OP主网)上合约,进行交互。
同一网络内的Solidity合约调用,类似为:
contract MyContract {
function doTheThing(address myContractAddress, uint256 myFunctionParam) public {
MyOtherContract(myContractAddress).doSomething(myFunctionParam);
}
}
MyContract.doTheThing
会触发调用MyOtherContract.doSomething
。在底层,Solidity通过向MyOtherContract
合约发送ABI encoded call来触发调用doSomething
函数。为简化开发者体验,此处抽象掉了很多这种复杂性。也可手工encoding(以更底层call
和abi.encodeCall
函数)来实现相同的功能,如:
contract MyContract {
function doTheThing(address myContractAddress, uint256 myFunctionParam) public {
myContractAddress.call(
abi.encodeCall(
MyOtherContract.doSomething,
(
myFunctionParam
)
)
);
}
}
以上两种调用方式等价。由于Solidity的限制,OP Stack的bridging接口被设计为看起来像第二个代码片段(即采用更底层的表达方式)。
从高层来看,L1与L2之间的数据发送流程,与以太坊上2个合约间的发送流程类似。L1与L2的通讯,可通过一组特殊的名为“messenger”的合约来实现。L1和L2有各自的messenger合约,用于抽象掉一些更底层的通讯细节,非常像HTTP库抽象掉物理网络连接。
每个messenger合约都有sendMessage
函数,来向另一层的合约发送一个消息:
function sendMessage(
address _target, //所调用的目标层上的合约
bytes memory _message, //所发送的内容
uint32 _minGasLimit //在目标层执行内容的最小gas limit
) public;
其等价为:
address(_target).call{gas: _gasLimit}(_message);
从而可调用个不同网络上的合约。
这掩盖了许多技术细节,这些细节使整件事在幕后运作,但这应该足以让你开始。想从以太坊上的合约调用OP主网上的合约吗?非常简单:
// Pretend this is on L2
contract MyOptimisticContract {
function doSomething(uint256 myFunctionParam) public {
// ... some sort of code goes here
}
}
// And pretend this is on L1
contract MyContract {
function doTheThing(address myOptimisticContractAddress, uint256 myFunctionParam) public {
messenger.sendMessage(
myOptimisticContractAddress,
abi.encodeCall(
MyOptimisticContract.doSomething,
(
myFunctionParam
)
),
1000000 // or use whatever gas limit you want
)
}
}
具体可查看OP主网和测试网上合约地址 中的L1CrossDomainMessenger合约 和 L2CrossDomainMessenger合约。
不同于同一链上的合约调用,以太坊和OP主网间的调用不是即时的。跨链交易的通讯速度取决于方向:
/// @notice Proves a withdrawal transaction.
/// @param _tx Withdrawal transaction to finalize.
/// @param _l2OutputIndex L2 output index to prove against.
/// @param _outputRootProof Inclusion proof of the L2ToL1MessagePasser contract's storage root.
/// @param _withdrawalProof Inclusion proof of the withdrawal in L2ToL1MessagePasser contract.
function proveWithdrawalTransaction(
Types.WithdrawalTransaction memory _tx,
uint256 _l2OutputIndex,
Types.OutputRootProof calldata _outputRootProof,
bytes[] calldata _withdrawalProof
)
external
whenNotPaused
{
// Prevent users from creating a deposit transaction where this address is the message
// sender on L2. Because this is checked here, we do not need to check again in
// `finalizeWithdrawalTransaction`.
require(_tx.target != address(this), "OptimismPortal: you cannot send messages to the portal contract");
// Get the output root and load onto the stack to prevent multiple mloads. This will
// revert if there is no output root for the given block number.
bytes32 outputRoot = l2Oracle.getL2Output(_l2OutputIndex).outputRoot;
// Verify that the output root can be generated with the elements in the proof.
require(
outputRoot == Hashing.hashOutputRootProof(_outputRootProof), "OptimismPortal: invalid output root proof"
);
// Load the ProvenWithdrawal into memory, using the withdrawal hash as a unique identifier.
bytes32 withdrawalHash = Hashing.hashWithdrawal(_tx);
ProvenWithdrawal memory provenWithdrawal = provenWithdrawals[withdrawalHash];
// We generally want to prevent users from proving the same withdrawal multiple times
// because each successive proof will update the timestamp. A malicious user can take
// advantage of this to prevent other users from finalizing their withdrawal. However,
// since withdrawals are proven before an output root is finalized, we need to allow users
// to re-prove their withdrawal only in the case that the output root for their specified
// output index has been updated.
require(
provenWithdrawal.timestamp == 0
|| l2Oracle.getL2Output(provenWithdrawal.l2OutputIndex).outputRoot != provenWithdrawal.outputRoot,
"OptimismPortal: withdrawal hash has already been proven"
);
// Compute the storage slot of the withdrawal hash in the L2ToL1MessagePasser contract.
// Refer to the Solidity documentation for more information on how storage layouts are
// computed for mappings.
bytes32 storageKey = keccak256(
abi.encode(
withdrawalHash,
uint256(0) // The withdrawals mapping is at the first slot in the layout.
)
);
// Verify that the hash of this withdrawal was stored in the L2toL1MessagePasser contract
// on L2. If this is true, under the assumption that the SecureMerkleTrie does not have
// bugs, then we know that this withdrawal was actually triggered on L2 and can therefore
// be relayed on L1.
require(
SecureMerkleTrie.verifyInclusionProof(
abi.encode(storageKey), hex"01", _withdrawalProof, _outputRootProof.messagePasserStorageRoot
),
"OptimismPortal: invalid withdrawal inclusion proof"
);
// Designate the withdrawalHash as proven by storing the `outputRoot`, `timestamp`, and
// `l2BlockNumber` in the `provenWithdrawals` mapping. A `withdrawalHash` can only be
// proven once unless it is submitted again with a different outputRoot.
provenWithdrawals[withdrawalHash] = ProvenWithdrawal({
outputRoot: outputRoot,
timestamp: uint128(block.timestamp),
l2OutputIndex: uint128(_l2OutputIndex)
});
// Emit a `WithdrawalProven` event.
emit WithdrawalProven(withdrawalHash, _tx.sender, _tx.target);
}
msg.sender
合约会频繁使用msg.sender
来基于calling address做决定。如,许多合约使用Ownable模式来选择性地约束对特定函数的访问。因为消息本质上是通过messenger合约在L1和L2之间穿梭的,所以当你接收到其中一条消息时,你会看到的 msg.sender
将是与你所在的层相对应的messenger合约。
为此,每个messenger合约内均有xDomainMessageSender
函数:
/// @notice Retrieves the address of the contract or wallet that initiated the currently
/// executing message on the other chain. Will throw an error if there is no message
/// currently being executed. Allows the recipient of a call to see who triggered it.
/// @return Address of the sender of the currently executing message on the other chain.
function xDomainMessageSender() external view returns (address) {
require(
xDomainMsgSender != Constants.DEFAULT_L2_SENDER, "CrossDomainMessenger: xDomainMessageSender is not set"
);
return xDomainMsgSender;
}
若你的合约被其中一个messenger合约调用,则可使用该函数来看实际上究竟是谁在发送该消息。在L2上实现onlyOwner
modifier示例为:
modifier onlyOwner() {
require(
msg.sender == address(messenger)
&& messenger.xDomainMessageSender() == owner
);
_;
}
对于L1->L2交易:
对于L2->L1方向:
由L2->L1的消息,至少需要7天才能relay。这即意味着由L2发送的任意消息,只能过了一周的挑战期之后,才能在L1上收到。称其为“挑战期”的原因在于,在该期间,交易可被a fault proof挑战。
Optimistic Rollups是“乐观的”,因为其核心思想为:将某交易的结果发送到以太坊,而不在以太坊上实际执行该交易。在“乐观”情况下,该交易结果是正确的,可完全避免在以太坊上执行复杂(且昂贵)的逻辑。
但是,仍需要某种方式,来避免发布不正确的,而不是正确的,交易结果。为此,引入了“fault proof”。当某交易结果发布后,可将其看成“pending”一段时间,又名挑战期。在挑战期,任何人都可在以太坊上重新执行该交易,以试图证明所发布的结果是不正确的。
若某人证明该交易结果是错误的,则该结果将被删除,任何人都可以在该位置发布另一个结果(希望这次是正确的结果,经济惩罚会使错误的结果对发布者来说代价高昂)。一旦给定交易结果的窗口完全通过而没有受到质疑,则该结果可被视为完全有效(否则会有人对其提出质疑)。
无论如何,这里的重点是,在这个挑战期结束之前,不从L1上的智能合约内部对L1交易结果做出决定。否则,可能会根据无效的交易结果做出决策。结果,L2->L1 使用standard messenger合约发送的L1消息在等待整个挑战期之后才能被relay。
为使L1gas开销最小化,OP Bedrock中的L2区块存储在以太坊链的非合约地址内:https://etherscan.io/address/0xff00000000000000000000000000000000000010。
这些L2区块作为交易calldata提交到以太坊,当该“交易”被包含在某具有足够attestations的区块内,则没法对其仅需修改或审查。从而OP主网可继承以太坊的可用性和完整性。
为降低开销,写入L1内的L2区块为压缩格式:
以压缩格式来在以太坊上存储L2区块,这很重要,因为写入到L1是OP主网交易的主要开销。
Optimism区块生产主要由称为“Sequencer”的单一方管理,Sequencer主要提供如下服务:
在 Bedrock 中,Sequencer确实像以太坊那样有一个mempool,但该内存池是私有的,以避免为 MEV 提供机会。在 OP 主网中,区块每两秒生成一次,无论它们是空的(无交易)、被交易填充到区块 Gas limit、还是介于两者之间。
交易通过两种方式到达Sequencer:
目前,Optimism基金会在 OP 主网上运行唯一的区块生产者。有关未来计划对 Sequencer 角色进行去中心化。
op-geth
组件所实现的执行引擎,使用两种机制接收区块:
op_node
组件所实现的rollup节点从 L1 派生出 L2 区块。这种机制速度较慢,但具有抗审查性。具体见Worst-case sync。Optimism 的设计目的是让用户可以在 L2(OP 主网、OP Sepolia 等)和底层 L1(以太坊主网、Sepolia 等)上的智能合约之间发送任意消息。这使得在两个网络之间传输 ETH 或代币(包括 ERC20 代币)成为可能。这种通信发生的确切机制根据消息发送的方向而有所不同。
OP 主网在标准桥中使用此功能,允许用户将代币从以太坊存入 OP 主网,也允许将代币从 OP 主网提取回以太坊。详情见Using the Standard Bridge。
proveWithdrawalTransaction
。这一新步骤可以对提款进行链下监控,从而更容易识别不正确的提款或输出根。这可保护 OP 主网用户免受一系列潜在的桥接漏洞的影响。在 Optimistic Rollup 中,状态承诺被发布到 L1(OP 主网的以太坊),没有任何直接证据证明这些承诺的有效性。相反,这些承诺被视为在一段时间内悬而未决(称为“挑战窗口”)。如果拟议的状态承诺在挑战窗口(当前设置为 7 天)期间没有受到挑战,则该承诺将被视为最终承诺。一旦承诺被认为是最终的,以太坊上的智能合约就可以安全地接受基于该承诺的有关 OP 主网状态的提款证明。
当状态承诺受到质疑时,可以通过“fault proof”过程使其无效。如果该承诺被成功质疑,那么它将被从承诺中删除,StateCommitmentChain(SCC)最终被另一个提议的承诺所取代。值得注意的是,成功的挑战不会回滚 OP 主网本身,只会回滚有关链状态的已发布承诺。交易的顺序和 OP 主网的状态不会因故障证明挑战而改变。
作为 2021 年 11 月 11 日EVM 等效性的副作用,fault proof流程目前正在进行重大重新开发更新。
提款延迟是 Optimistic Rollup 的基本组成部分。当用户向以太坊提出有关 Optimistic Rollup 状态的声明时,就会开始提款。 如,这个声明可能是“我在 Optimism 上烧掉了 20 个代币,所以让我在以太坊上提取 20 个代币。”
由于 Optimistic Rollup 的重点在于 L1 并未实际执行 L2 链,因此 L1 不知道此声明是否有效。ZK Rollups 通过为 L1 提供一个加密证明来证明给定的声明是有效的,从而解决了这个问题。乐观汇总通过要求声明必须通过挑战期才能被视为有效来解决此问题。每个claim必须等待一段挑战期,在此期间挑战者可以声明该claim无效。如果有人对某个主张提出质疑,那么就会开始一些链上游戏来确定该主张是否真正有效。
由于某人检测无效声明并提交挑战可能需要一些时间,因此我们不可避免地需要挑战期大于零。毕竟,如果挑战期的持续时间是零秒,那么就没有机会提交挑战了。那么我们的问题就变成了:挑战期应该是多长?
最终状态的现代乐观汇总挑战游戏本质上采取提出主张的用户和质疑该主张的用户之间来回的形式(实际上,这些协议通常被设计为允许任何人参与要么是“团队”,但现在让我们保持简单)。为了举例,我们假设整个过程中有大约 10 个来回步骤(确切的数量有所不同,但在这里并不重要)。
如果双方都非常非常快,那么整个挑战过程至少需要 10 个以太坊区块(2 分钟)。当然,用户并不是像这样完全快,因此您可能需要添加一些至少是基线数字 10 倍的填充,即大约 100 个块(20 分钟)。尽管如此,100 个区块还是比进入 7 天挑战期的 50400 多个区块要短得多。这里一定还有别的东西。
攻击者可以用他们的钱做什么?攻击者的目标是阻止挑战者将他们的挑战交易包含在链上。毕竟,如果挑战交易成功,那么攻击就会失败。攻击者基本上有三种潜在的策略:
为什么挑战期是7天?
更多详细内容,见:
OP发展历程为:
[1] 2023年4月 为什么从 OP 主网转出资产需要等待一周?
[2] Sending Data Between L1 and L2
[3] L2IV RESEARCH 2024年1月29日 Why we invested in Rome Protocol? The Solana-Based Shared Sequencer
[4] Dispute Game
[5] Rollup Protocol Overview
[6] Withdrawal Flow
[7] Why is the Optimistic Rollup challenge period 7 days?
[8] 2023年10月博客 Ethereum’s Optimistic Future: An Introduction to Optimistic Rollups
[9] Optimism