作者:Brock Elmore. 编译:Cointime.com QDD
简述:
在编写require语句时,不要只为特定的函数编写require语句;要为你的协议编写require语句。函数需求-影响-交互+协议不变式(FREI-PI)模式可以通过强制开发人员专注于协议级别的不变式以及函数级别的安全性,从而使你的合约更加安全。
动机:
2023年3月,Euler Finance被黑客攻击,损失2亿美元。Euler Finance是一个借贷市场,用户可以提供抵押品并进行借贷。虽然它具有一些独特的功能,但从本质上讲,它与Compound Finance和Aave等借贷市场相似。
你可以在此处阅读有关此次黑客攻击的事后总结。简单来说,问题在于特定函数中缺少健康检查,这使得用户能够破坏借贷市场的基本不变式。
基本不变式
大多数DeFi协议都有一个不变式,即程序状态的某个属性始终为真。它们可能有多个不变式,但通常围绕一个核心思想构建。以下是一些例子:
- 借贷市场:用户不能采取任何使任何账户处于不安全或更不安全抵押品位置的操作(“更不安全”意味着它已经低于最低安全阈值,因此不能进一步减少)
- AMM:x * y == k,x + y == k等
- 流动性挖矿质押:用户只能取回其存入的质押代币数量
Euler的问题不一定在于他们添加了功能、没有编写测试或没有遵循常规的最佳实践。他们审计了升级版本并进行了测试,但问题被忽略了。核心问题在于他们忘记了借贷市场的核心不变式(审计员也是如此!)。
注:我并不是要针对Euler,他们是一支有才华的团队,但这只是最近的一个鼓舞人心的例子。
问题的核心
你可能会想:“嗯,当然。这就是为什么他们被黑客攻击,他们忘记了require语句。”是的,也有一些不同。
然而,为什么他们在那里忘记了require语句呢?
Checks-Effects-Interactions模式的问题
推荐Solidity开发人员使用的一种常见模式是Checks-Effects-Interactions模式。它对于消除与递归相关的错误非常有用,并且通常会增加执行输入验证的开发人员数量。但是,它隐藏了问题的本质。
它教导开发人员:“首先编写require语句,然后进行效果操作,然后可能进行交互,就可以安全了”。问题在于,通常情况下,这变成了检查和效果的混合 - 还好吧?交互仍然是最后一个,因此递归不是问题。但是,这迫使用户专注于特定的函数和单个状态转换,换取了更少的上下文。也就是说:
仅仅使用Checks-Effects-Interactions模式会导致开发人员忘记他们的协议的核心不变式。
对于开发人员而言,这仍然是一个很好的模式,但它始终应服从于确保协议不变式的要求(认真地说,你仍然应该使用CEI!)。
正确的方式:FREI-PI模式
以前的版本中,dYdX的SoloMargin合约(源代码)是一个借贷市场和杠杆交易合约的一个精彩示例。这是我称之为“函数需求-效果-交互+协议不变式(FREI-PI)”模式的一个美好例子。
因此,我相信这是借贷市场早期阶段中唯一一个没有任何市场相关漏洞的借贷市场。Compound和Aave并没有直接受到影响,但是它们的代码分叉却受到了攻击。而bZx则遭受了多次黑客攻击。
从下面的代码片段中可以看到以下抽象内容:
1. 输入要求(_verifyInputs)
2. 操作(数据转换、状态操作)
3. 状态要求(_verifyFinalState)
function operate(
Storage.State storage state,
Account.Info[] memory accounts,
Actions.ActionArgs[] memory actions
)
public
{
Events.logOperation();
scss
Copy code
_verifyInputs(accounts, actions);
(
bool[] memory primaryAccounts,
Cache.MarketCache memory cache
) = _runPreprocessing(
state,
accounts,
actions
);
_runActions(
state,
accounts,
actions,
cache
);
_verifyFinalState(
state,
accounts,
primaryAccounts,
cache
);
}
仍然执行常用的Checks-Effects-Interactions。值得注意的是,Checks-Effects-Interactions与额外的Checks不等同于FREI-PI-它们相似但目标根本不同。因此,开发人员应将它们视为不同的东西:FREI-PI用于协议的安全性,CEI用于函数的安全性。
该合约的结构非常有趣-用户可以连续进行多个操作(存款、借款、交易、转账、清算等)。想要存入3种不同的代币,取出第4种代币,并清算一个账户?这可以通过一次调用完成。
这就是FREI-PI的优势:用户可以在协议内部进行任何操作,只要在调用结束时核心借贷市场不变式仍然成立:用户没有采取任何使任何账户处于不安全或更不安全抵押品位置的操作。对于该合约而言,在_verifyFinalState中执行此操作,检查每个受影响账户的抵押品化程度,并确保协议比交易开始时更安全。
该函数中还包含一些额外的不变式,作为核心不变式的补充,以促进附加功能(如关闭市场),但真正保持协议安全的是核心检查。
以实体为中心的FREI-PI
FREI-PI中的一个额外细节是实体为中心的概念。以借贷市场为例,假设的核心不变式是:
用户不能采取任何使任何账户处于不安全或更不安全抵押品位置的操作
从技术上讲,这不是唯一的不变式,但对于用户实体来说是如此(它仍然是核心协议不变式,通常用户不变式是核心协议不变式)。借贷市场通常还会有两个附加实体:
1. 预言机
2. 管理员/治理
每个附加不变式都会使协议更难以保护,因此不变式越少越好。这基本上是Dan Elitzer在他那篇标题为《为什么DeFi有问题以及如何解决》第一部分中所说的(提示:这篇文章实际上并没有说预言机是问题)。
预言机
对于预言机来说,以1.3亿美元的Cream Finance攻击为例。预言机实体的核心不变式是:
预言机提供准确和(相对)实时的信息
在FREI-PI中,在运行时验证预言机可能有些棘手,但可以通过一些预先考虑的方法来实现。一般而言,Chainlink是一个不错的选择,并且大多数情况下可以可靠地满足大部分不变式。在防止操纵或意外情况(例如检查上一个已知值是否比当前值大几百个百分点)的罕见情况下,减少实时性可能会更有利。再次强调一下,dYdX的SoloMargin系统在这方面做得非常出色,特别是他们的DAI预言机(如果你没有注意到,我认为它是有史以来写得最好的复杂智能合约系统)。
有关预言机评估的更多信息,并突出Euler团队的能力,他们在计算操纵Uniswap V3 TWAP预言机价格方面写了一篇很好的文章。
管理员/治理
管理员是最难为不变式创建的实体。这在很大程度上是因为他们的大部分职责是更改其他不变式的保持方式。尽管如此,如果你可以避免拥有管理角色,就应该这样做。
从根本上讲,管理员实体的核心不变式可能是:
只有在效果维护所有其他不变式或故意删除或更改不变式的情况下,管理员才能采取任何操作
解释一下:管理员可以执行不破坏任何不变式的操作,除非他们正在以保护用户资金的方式进行大规模更改(例如,将资产转移至救援合约是删除不变式)。管理员还应被视为用户,因此核心借贷市场用户不变式也对他们有效(这意味着他们不能对其他用户或协议进行不当操作)。某些管理员操作目前无法通过FREI-PI在运行时验证,但是通过在其他地方设置足够强的不变式,希望大部分问题都可以得到缓解。我之所以说是目前,是因为可以想象使用zk证明系统来潜在地检查合约的整个状态(每个用户、每个预言机等)。
以管理员违反不变式为例,以2022年8月发生的破坏cETH市场的Compound治理操作为例。从根本上讲,此升级破坏了预言机不变式:预言机提供准确和(相对)实时的信息。由于缺少函数,预言机无法提供任何信息。通过运行时的FREI-PI验证,检查受影响的预言机是否能够提供实时信息,可以防止升级发生。这可以嵌入到_setPriceOracle中,检查所有资产是否接收到实时信息。FREI-PI对于管理员来说的好处是,管理员相对于价格不敏感(或至少应该是这样),因此相对于用户来说,更重的Gas使用应该不是那么重要。
复杂性是危险的
因此,尽管最重要的不变式是协议的核心不变式,但可能存在必须为核心不变式保持的实体特定不变式。但是,最简单(最小)的不变式集可能是最安全的。而关于简单性更好的光辉例子是...
为什么Uniswap从未被黑客攻击过(可能)
AMM可以有任何DeFi原始协议中最简单的基本不变式:tokenBalanceX * tokenBalanceY == k(例如,常数乘积)。Uniswap V2中的每个函数都基于这个简单的不变式:
1. 增加:增加到k
2. 销毁:从k减去
3. 交换:移动x和y,保持k不变
4. 溢出:通过修剪多余的方式重新调整tokenBalanceX * tokenBalanceY等于k
Uniswap V2的安全之道:核心不变式的简单性,所有函数都为此服务。唯一可以争议的其他实体是治理,它可以切换手续费,但不会影响核心不变式,只会影响代币余额的所有权分配。这种简单性是Uniswap从未被黑客攻击过的原因。这并不是对Uniswap智能合约出色的开发人员们的不公平待遇-找到简单性需要优秀的工程师。
Gas问题
我的Twitter提及已经充斥着对这些检查是否不必要和低效的优化恐慌和痛苦的呼声。关于这个问题有两点:
1. 你知道什么是低效的吗?不得不使用ETH转账通过以太坊区块链向劳伦斯发送消息,威胁要牵涉到联邦调查局
2. 你可能已经从存储中加载了所需的所有数据,所以只需在调用结束时使用一些带有热数据的require语句。你是希望你的协议成本稍微增加一点,还是让它在惨痛的死亡中消亡?
如果成本过高,重新考虑核心不变式并尝试简化。
对我来说意味着什么?
作为开发人员,在开发过程中及早并经常定义和阐明核心不变式。作为具体建议:第一个函数应该是_verifyAfter,它在每次调用合约后验证不变式。将其保留在合约中并部署时一同部署。此外,通过更广泛的不变式测试(在部署前进行检查),补充核心不变式(以及其他实体特定的不变式)。
临时存储提供了一些有趣的优化和改进方法,Nascent将进行尝试-我建议考虑如何将临时存储用作更好的跨调用上下文安全性工具。
在本文中,并没有花太多时间讨论FREI-PI模式中的输入验证方面,但这也非常重要。定义输入范围可能是一个具有挑战性的任务,以避免溢出等问题。考虑一下我们的工具pyrometer的进展情况,并关注它的进展:pyrometer(目前处于测试阶段,在那里给我们一个星星)。它可以提供见解并帮助你找到可能没有执行输入验证的地方。
结论
超越任何时髦的首字母缩写或模式名称,真正重要的是:
在协议的核心不变式中找到简单性。并努力确保它永远不会被破坏(或在破坏之前就能察觉到)。
所有评论