本文介绍了Optimism团队设计的一种新型Timelock,它与Gnosis Safe Multisig紧密集成,旨在简化治理流程并提高协议应对外部攻击的安全性。该Timelock通过Gnosis Safe Guard插件实现,具有交易队列可见、动态取消阈值和快速重置机制等特点,并着重强调了在危机情况下检测和阻止恶意行为者的能力,同时最大限度地减少对现有治理流程的改变,从而更有效地保护协议安全。
时间锁是区块链治理中的一项重要工具。它获取交易并延迟其执行,同时使所有人都可见。
传统上,时间锁一直被用来让用户放心,协议所有者无法在不给用户时间做出反应和移除其资产的情况下做任何不希望的事情。但这并不是时间锁的全部用途。
相对较新的黑客攻击事件,比如 Bybit 遭受的那次,很可能可以通过时间锁来预防。在黑客设法获得执行恶意交易的签名的情况下,时间锁会揭示该计划并提供反击的机会。
不幸的是,目前使用最广泛的时间锁,例如来自 OpenZeppelin 和 Compound 的时间锁,存在一些缺点,使得它们在保护协议免受外部攻击方面表现不佳:
它们要求时间锁对资产或协议具有特权权限。
它们为治理过程增加了几个步骤。
它们不能轻易地显示计划交易,因为只存储了一个哈希标识符。
它们不能轻易地取消计划交易:取消需要与计划相同的权限,或者需要一个单独的特权参与者。
鉴于这些限制,我们在 Optimism 决定设计一个时间锁,该时间锁将是简单的、健壮的、易于插入任何协议治理结构中,并且允许我们轻松地检测和移除恶意交易。
大多数治理行为都源于 Gnosis Safe Multisig,而这种多重签名钱包中可用的工具之一是 Gnosis Safe Guards。
Gnosis Safe Guard 是 Gnosis Safe Multisigs 的一个智能合约插件,它允许该多重签名钱包的所有者规定执行任何操作所需的条件。
从本质上讲,任何时间锁都是执行的条件。一笔交易需要在执行前的特定时间向时间锁公开。Gnosis Safe Guard 是一个完美的选择,它允许将时间锁功能实现为 Gnosis Safe Multisig 的插件。

与其他时间锁一样,我们实现了三个函数:schedule(计划)、execute(执行)和 cancel(取消)。我们还补充了一个非常显眼的交易队列、一个动态取消阈值和一个清除时间锁队列的机制。
还有一些事情我们没有构建。Safe 已经完成了存储和执行交易的所有工作,作为其核心功能的一部分,即实现对账户的集体控制。Safe 还包括批量处理交易的逻辑。通过不需要自己编写这些代码,我们大大降低了时间锁的复杂性和负担。
让我们详细讨论一下实现。
为了在我们的时间锁中计划一笔交易,用户需要已经收集了在 Safe 中执行该交易的签名,我们重用 Safe 代码来检查签名并派生一个标识符哈希。唯一的新代码是存储所有交易数据,以及一个最小执行时间。
// 获取在 Safe 中定义的编码交易数据
// 返回的字符串格式为:"0x1901{domainSeparator}{safeTxHash}"
bytes memory txHashData = _safe.encodeTransactionData(
_params.to, _params.value, _params.data, _params.operation,
_params.safeTxGas, _params.baseGas, _params.gasPrice, _params.gasToken,
_params.refundReceiver, _nonce);
// 获取在 Safe 中定义的交易哈希和数据
// 该值与 keccak256(txHashData) 相同,但我们更喜欢使用
// Safe 自己的内部逻辑,因为它在 Safe 的未来版本中
// 更改交易哈希派生方式时更具前瞻性。
bytes32 txHash = _safe.getTransactionHash(
_params.to, _params.value, _params.data, _params.operation,
_params.safeTxGas, _params.baseGas, _params.gasPrice, _params.gasToken,
_params.refundReceiver, _nonce);
// 检查交易是否存在
// 一个交易只能被计划一次,无论它是否已经被取消,
// 否则观察者可以重用相同的签名来
// 1. 在交易被取消后重新计划交易
// 2. 重新计划一个待处理的交易,这将更新执行时间,
// 从而延长原始交易的延迟时间。
if (_currentSafeState(_safe).scheduledTransactions[txHash].executionTime != 0) {
revert TimelockGuard_TransactionAlreadyScheduled();
}
// 使用 Safe 的签名检查逻辑验证签名
// 如果签名无效,则此函数调用将回滚。
_safe.checkSignatures(txHash, txHashData, _signatures);
safe.checkSignatures 逻辑完成了所有工作:它验证签名是否有效,以及是否有足够的签名达到法定人数。通过重用 Safe 中的这段代码,我们的时间锁不需要有自己的访问控制机制。
没有独立的 execute 函数。相反,作为 Safe Guard,Timelock 在 checkTransaction 函数中执行交易之前会被 Safe 查询。此函数的主要目标是,如果交易在执行前至少没有被计划一段定义的时间,则回滚。
/// @notice Guard 接口的实现。在执行交易之前由 Safe 调用
/// @dev 此函数用于检查该交易是否已被计划并且已准备好
/// 执行。它只会读取合约的状态,并且可能会回滚,以便
/// 防止执行未经计划、过早或已取消的交易。
function checkTransaction(
address _to,
uint256 _value,
bytes memory _data,
Enum.Operation _operation,
uint256 _safeTxGas,
uint256 _baseGas,
uint256 _gasPrice,
address _gasToken,
address payable _refundReceiver,
bytes memory, /* signatures */
address _msgSender
)
这里没有执行函数,时间锁而是使用了 guard 的执行Hook
通过将自己插入 Safe 的执行过程中,我们的时间锁不需要签名者在计划和等待之后进行任何额外的工作。它在幕后工作。
在其他时间锁中,取消通常是事后才考虑的事情,最终以增加复杂性和风险的方式实现。
在我们的时间锁中,我们希望 Safe 的所有者可以轻松地取消交易。在危机情况下,可能很难召集足够数量的签名者来取消恶意交易。同时,我们不想创建一个平行的治理结构来管理取消。
我们通过创建一个 no-opsignCancellation 函数解决了这个问题。此函数不执行任何操作,但是 Safe 的所有者可以创建链下签名,允许 Safe 执行它,就像他们从多重签名钱包中签名任何其他交易一样。
多重签名钱包不会执行 signCancellation,因为它不执行任何操作。相反,我们实现了一个cancelTransaction 函数,该函数使用 signCancellation 的签名来评估是否有足够的签名者想要取消交易。这听起来很复杂,但是该实现非常简单,因为它完全是由 Safe 多重签名钱包代码中已经存在的功能构建的。
// 生成取消交易数据
bytes memory txData = abi.encodeCall(this.signCancellation, (_txHash));
// 此处可以使用任何 nonce,只要所有签名都使用相同的
// nonce。在实践中,我们希望 nonce 与
// 要取消的交易的 nonce 相同,因为这最能模仿 Safe UI 的交易替换功能。
// 但是,我们不在此处强制执行,以允许灵活性并避免
// 需要从要取消的交易中检索 nonce 的逻辑。
bytes memory cancellationTxData = _safe.encodeTransactionData(
address(this), 0, txData, Enum.Operation.Call, 0, 0, 0, address(0), address(0), _nonce
);
bytes32 cancellationTxHash = _safe.getTransactionHash(
address(this), 0, txData, Enum.Operation.Call, 0, 0, 0, address(0), address(0), _nonce
);
// 使用 Safe 的签名检查逻辑验证签名,并将取消
// 阈值作为所需的签名数量。
_safe.checkNSignatures(
cancellationTxHash, cancellationTxData, _signatures, _currentSafeState(_safe).cancellationThreshold
);
cancelTransaction 函数主要编码为对 Safe 代码的调用。
在描述 cancelTransaction 函数时,我们跳过了需要多少签名者才能取消交易的问题。
在一些非常聪明的人(samczsun, dvf)的建议下,我们决定实施动态取消阈值,这是迄今为止时间锁中最复杂的部分。不过,我们认为这是值得的。
取消交易所需的签名数量从 1 开始,这使得危机团队有尽可能最好的机会来阻止攻击。
如果取消阈值始终为 1,那么攻击者很容易通过破坏单个密钥来永久 DoS Safe。为了避免这种情况,取消阈值每次连续取消都会增加 1,并在成功执行后重置为 1。
////////////////////////////////////////////////////////////////
// 内部状态更改函数 //
////////////////////////////////////////////////////////////////
/// @notice 增加 safe 的取消阈值
/// @dev 此函数必须仅调用一次,并且仅在调用取消时调用
/// @param _safe 要增加取消阈值的 Safe 地址。
function _increaseCancellationThreshold(Safe _safe) internal {
SafeState storage safeState = _currentSafeState(_safe);
if (safeState.cancellationThreshold < maxCancellationThreshold(_safe)) {
uint256 oldThreshold = safeState.cancellationThreshold;
safeState.cancellationThreshold++;
emit CancellationThresholdUpdated(_safe, oldThreshold,
safeState.cancellationThreshold);
}
}
/// @notice 重置 safe 的取消阈值
/// @dev 此函数必须仅调用一次,并且仅在调用
/// checkAfterExecution 时调用
/// @param _safe 要重置取消阈值的 Safe 地址。
function _resetCancellationThreshold(Safe _safe) internal {
SafeState storage safeState = _currentSafeState(_safe);
uint256 oldThreshold = safeState.cancellationThreshold;
safeState.cancellationThreshold = 1;
emit CancellationThresholdUpdated(_safe, oldThreshold, 1);
}
取消阈值管理功能
一次坚决的 DoS 攻击需要大量泄露的密钥才能成功。我们将取消阈值限制为 min(quorum, blocking_threshold),因此它永远不会超过法定人数,也永远不会超过永久阻止 Safe 执行所需的签名数量。
我们认为这应该是限制,因为如果攻击者持有 blocking_threshold 个密钥或 quorum 个密钥,那么我们已经遇到了比他们能够滥用时间锁来 DoS Safe 更严重的问题,最好分别处理这些问题。
我们希望计划的交易清晰可见。其他时间锁只在计划交易时发出一个事件,但我们的经验是监控通常会错过事件或被欺骗而错过事件。
因此,我们将所有待处理交易的哈希标识符存储在一个可枚举的集合中。在查询时,我们返回每个计划交易的所有数据字段,以便尽可能简化分析。
/// @notice 返回给定 safe 的所有已计划但未取消或执行的
/// 交易的列表
/// @dev 警告:此操作会将整个待处理
/// 交易集合复制到内存中,
/// 这可能会非常昂贵。这仅用于由查看
/// 访问器查询而没有任何 gas 费用。开发人员应该记住
/// 此函数具有无限的成本,并且将其用作状态更改函数的一部分
/// 可能会导致该函数无法调用,如果该集合增长到
/// 复制到内存中消耗太多的 gas 无法放入一个区块。
/// @return 待处理交易哈希的列表
function pendingTransactions(Safe _safe) external view
returns(ScheduledTransaction[] memory) {
SafeState storage safeState = _currentSafeState(_safe);
// 获取待处理交易哈希的列表
bytes32[] memory hashes = safeState.pendingTxHashes.values();
// 我们希望为调用者提供每个待处理
// 交易的完整参数,但映射是不可迭代的,因此我们使用待处理
// 交易哈希的可枚举集合来检索
// 每个哈希的 ScheduledTransaction 结构体,然后返回一个 ScheduledTransaction 结构体数组。
ScheduledTransaction[] memory scheduled =
new ScheduledTransaction[](hashes.length);
for (uint256 i = 0; i < hashes.length; i++) {
scheduled[i] = safeState.scheduledTransactions[hashes[i]];
}
return scheduled;
}
查询时间锁对于所有监控目的都非常方便
我们认为增加的复杂性是合理的,因为时间锁本身永远不会在链上查询待处理的交易集合。相反,它是作为完全平行的结构进行维护,以方便监控工具。
即使有了上述所有预防措施,仍然存在一些可能会使时间锁不堪重负的不需要的交易的情况,例如我们在处理取消阈值时讨论的那些情况。
在这些情况下,我们希望协议会跳过时间锁并执行暂停。然后,危机管理团队会在允许正常运营恢复之前解决问题。
为了使重启更安全,我们包含了一个非常简单的开关,通过将时间锁指向新的配置集,可以在单个调用中移除所有待处理的交易。
/// @notice 清除 Safe 的时间锁 guard 配置。
/// @dev 注意:清除配置还会取消所有待处理的交易。
/// 此函数旨在在 Safe 想要永久
/// 移除 TimelockGuard 配置时使用。典型用法模式:
/// 1. Safe 通过 GuardManager.setGuard(address(0)) 禁用 guard。
/// 2. Safe 调用此 clearTimelockGuard() 函数以移除存储的
/// 配置。
/// 3. 如果 Safe 稍后重新启用 guard,则必须再次调用
/// configureTimelockGuard()。警告:清除配置允许重新计划以前计划的所有交易,
/// 包括已取消的交易。强烈建议
/// 在取消计划的交易时手动增加 Safe 的 nonce。
function clearTimelockGuard() external {
Safe callingSafe = Safe(payable(msg.sender));
// 检查此 guard 是否未在调用 Safe 上启用
// 这可以防止在 guard 仍然启用时清除配置
if (_isGuardEnabled(callingSafe)) {
revert TimelockGuard_GuardStillEnabled();
}
// 通过增加 nonce 清除配置,所有配置和待处理的
// 交易都将被有效地擦除。
_safeConfigNonces[callingSafe]++;
}
我们可以通过单次调用来重置时间锁
通过使配置和待处理的交易队列依赖于一个连续的标识符,我们只需要增加它就可以立即切换到默认配置和空的交易队列,而无需进行繁重的清理循环。
主流的时间锁实现非常适合保护用户免受有害的治理行为的影响,但在保护协议免受外部攻击者的攻击方面却有所不足。此外,它们对已经复杂的治理过程造成了负担。
在 Optimism,我们实现了一个时间锁,该时间锁与 Gnosis Safe 紧密集成,并要求对现有的治理过程进行尽可能小的改动。通过重用它所集成的 Safe 中的代码,时间锁变得更加简单和健壮。
我们还特别关注在危机情况下如何使用时间锁,以便时间锁所有者有尽可能最好的机会来检测和阻止恶意行为者。
我们的时间锁是公共产品,并且已通过 Spearbit 的审计。你可以以任何适合你的方式使用它,包括 fork 和修改它。我们很乐意听到你的反馈!
该模块是 Optimism 的几个人共同努力的结果。这个愿景来自 Kelvin Fichter,John Mardlin 将代码放在一起。Josep Bové, Ethnical, Matt Solomon 和 我 以其他身份提供了帮助。
在 Optimism,我们一直在寻找有才华的人,他们将帮助我们完成扩展以太坊和构建 Superchain 的使命,推动可能的边界,并为复杂的问题找到聪明的解决方案。如果这让你感兴趣,我们正在招聘。
- 原文链接: optimism.io/blog/timeloc...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!