4

蝴蝶效应:一文了解 Compound 代码更新事故

 2 years ago
source link: https://www.tuoluocaijing.cn/kepu/detail-10070954.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

蝴蝶效应:一文了解 Compound 代码更新事故

BlockSec_Team 原创 2021-10-10 02:08 热度 561951
分享

微信扫一扫:分享



微信里点“发现”,扫一下 二维码便可将本文分享至朋友圈

"有些事,发生在别人身上是故事,发生在自己身上就成了事故"

b506433ae338b92cf517253a7d6ed59e

代码的升级是一件痛苦且脆弱的事。尤其是在本就十分复杂的代码大厦上,任何微小的改动,都可能因为某些边界条件的疏忽而造成崩塌,Compound最近就遇到了这事

ffda3e7a131f497fb8f6e431b6c8a8ea

Compound是一个老牌的去中心化借贷平台

Q1: 为什么要有借贷这回事儿?

在区块链上,所有的资产都是代币化的。我们看好一个项目,但是却没有这个项目发行的代币。这时最简单的方法就是去交易所用手里有的币来换。但是,如果我们又不舍得自己手里的币,该怎么办呢。这时可以去借,抵押品便是我们拥有的币。如果这个项目涨了,获得收益的同时,还可以赎回曾经抵押的币。

交易会发生所有权的转移,而借贷不发生所有权的转移,只是被暂时锁在合约中

同时,由于借贷的引入,我们可以操控更大的资金敞口,实现杠杆交易...

Q2:Compound的运作流程?

和AMM类似,在区块链上实现自动化的借贷,首先要做的便是吸引资金(流动性),而用户所以能将钱存在一个平台,必然是受到利益的驱使。AMM通过交易费来激励用户添加流动性,而借贷平台的手段便是借款利息

由于存在借贷这一需求,总会有人愿意付出利息来借币。而有了利息的激励,也有人愿意将闲钱拿来提供流动性。此外,借贷平台通过利率模型参数的动态调整,可以维持整个系统的供给平衡与风险

Q3: 如何与Compound交互?

用户与Compound的交互接口主要是CToken/CEther合约(这些合约本身就是一种代币), CToken 相当于 Compound这一平台的"入场券"。通过向不同CToken合约质押其底层代币(underlying token)便可以获得相应的CToken

这一操作,表现在代码层面就是ctoken.mint(amount),比方说:我手里有1000个ETH,便可以调用cEth.mint(1000)来向cEth池中 "注入流动性"

要注意的是,cToken和底层代币并不是1:1的兑换关系,当蛋糕越做越大时,cToken所能换出的底层代币也就越多。这和LP token的类似,利息便是以这种形式来发放的

fab9a6aef37e861af24241c68ba9c7d6

那有了cToken以后,我们可以做什么呢?

最简单的便是借钱,因为cToken代表用户质押在Compound的资产,因此可以通过"过抵押"的方式来借出Compound拥有的代币。Compound会先计算用户拥有所有cToken的价值(可能来自于不同的池),根据抵押率来计算用户的流动性(Liquidity)

表现在代码层面就是ctoken.borrow(amount),比方说:我通过ceth.mint(1000)质押了 1000 个 ETH,如果我想借 Dai 的话,需要调用cDai.borrow(x)这里的 x 最多价值750 ETH (抵押率75%)

这些都是以美元计算的,再根据Oracle来换算成不同的Token数量

而Comptroller这一合约是一个中间层,它所做的事情,便是交互前的一些计算与验证工作,类似银行的审计员。比方说:张三借了多少钱,欠了多少钱,这小子又来借1000个ETH还能不能借给他

表现在代码层面就是:getHypotheticalAccountLiquidityInternal()、borrowAllowed()、mintAllowed()...

Q4: COMP代币与Compound的关系?

COMP代币是Compound发行的平台代币,可以用于管理。因为Compound采用DAO的治理模式。对Compound所有的操作,都需要通过投票来决定,提案(proposals)通过后由一个特权合约来执行写在提案中的操作。通过COMP可以获得投票的权重

详情见:https://compound.finance/governance

当然只能用来投票显然还是缺少些吸引力的,COMP本质上就是Compound发行的股票,拥有更多的COMP,可以享受更优的利率,随着Compound的发展,COMP带来的价值也会越来越大,因此COMP值钱(目前 $300 左右)

同时,为了激励用户使用Compound,无论是向Compound提供流动性,还是从Compound借出资产,都会获得一定的COMP奖励,这些奖励以区块为单位计算(划重点:这里与本次事件相关)

修了东墙又补西墙事故1:Bug的原理

事故1代码地址:0x75442Ac771a7243433e033F3F8EaB2631e22938f

事情的起因是这样的:

2021年9月31日,Compound DAO出现这样一条提案(Proposals 62: https://compound.finance/governance/proposals/62):

该提案提出更新 Comptroller 合约以修复一些 Bug

622d7c15671c96c742cb00b0ee24371e

这里我们可以看出 Bug 和 CompSpeed 有关,CompSpeed 这个变量代表是每个区块可以挖出的 COMP 数量

这里以 mint 为例简单介绍Bug的原理:

ctoken 的 mint 函数的调用链为:mint → mintInternal → mintFresh

7be9196053e81369656b0a88129259a3

可以看到,在mintFresh中,会先调用 Comptroller 的mintAllowed函数,再更新用户 ctoken 的余额

df3338a5ccdbe7f86cc3f1f76a73a1eb

而 mintAllowed 中,会先调用 updateCompSupplyIndex,再调用 distributeSupplierComp

18ca0931143e09dd943c085dda0bb764

前者会更新借贷池的奖励状态,主要是 compSupplyState

cc42de65f2900679c5bdc3659ad704b7

这一结构体中,block字段记录了更新时的区块号,index字段记录的是更新时的奖励指数

**什么是奖励指数(index)呢?**这是一个随时间不断累加的值,其公式为

334e0c7c0ba9b455618c8be18e317ce4

表示的是一个借贷池,随着时间的推移,向每个cToken分发的COMP数量。因此,其差值可以简单理解为,这段时间内一个cToken可以获得的COMP数量

接下来我们看另一个函数:distributeSupplierComp。这个函数的作用,就是将用户可以获得的COMP数记录到compAccrued[supplier]中:

4b9fb581868c3da93865e0b1e9c8e6ce

每次有用户来和 Compound 交互,都会触发全局的奖励指数 compSupplyState 更新

与此同时,在上面的函数中,我们可以看到,用户会先从 compSupplierIndex 中取得上次的 compSupplyState 保存在临时变量 supplierIndex 中,接下来更新 compSupplyState

这里要区分好 supplyIndex 和 supplierIndex,前者表示当前的奖励指数,后者表示用户上次交互时的奖励指数

而两个时间点全局奖励指数的差 * 用户拥有的 cToken 数量,就是这段时间奖励给该用户的 COMP 数量

现在看起来都是一起正常,岁月静好,直到...

有一天Compound调用了setCompSpeed:

ed8b083598af200ec9c8904c73f30051

因为一个Market的CompSpeed是可以设置为0(表示暂停发放COMP奖励),所以存在这样一种情况:

我们先把一个市场的CompSpeed设置为0

过了一段时间后又想要重新开启COMP奖励,这时就会调用setCompSpeed设置compSpeed为一个非零值

这会发生什么呢?

很显然,合约会走到else if (compSpeed != 0)这个分支。我们来看这个分支中有两个if判断(以第一个为例):if (compSupplyState[address(cToken)].index == 0 && compSupplyState[address(cToken)].block == 0)。其作用是:为一个未初始化的市场,初始化奖励指数(index)和区块号(block)

问题1:这里可以想想:未初始化的市场(index = 0 && block = 0)和被暂停的市场(index = 0)一样吗?

先别急,我们重新来看updateCompSupplyIndex:

dbd94634da841cf1b4780ae7ebddfe4e

这里我们可以回答一下问题1:未初始化的市场和暂停的市场是不一样的,暂停的市场虽然index = 0,但是block会一直更新!

因此,当我们为一个暂停的市场重新设置compSpeed时:index不会被初始化!

【注】Compound假设奖励指数初始值为CompInitialIndex = 1e36

这会有什么影响呢?

我们再来看下奖励分发函数distributeSupplierComp:

415c7cd02be5e9a0dbdc72fb699d92dc

看出来了吗?用户自己的奖励指数(supplierIndex)会被初始化为compInitialIndex (1e36),而市场的奖励指数(supplyIndex)由于上面的问题为0,这就导致:Double memory deltaIndex = sub_(supplyIndex=0, supplierIndex=1e36)出现下溢!

5f01bb95a619ef4f8cf478f0f5640114

事故2:修复后引入的Bug

事故2代码地址:0x374abb8ce19a73f2c4efad642bda76c797f19233

Compound方面对事故1的修复如下:

Compound很显然意识到了问题出在setCompSpeed函数只考虑了"未初始化市场",而没有考虑"暂停的市场"

因此,新代码中,增加了函数:_initializeMarket这个函数会在添加新市场时调用。也就是说,只要添加新市场,就会初始化其奖励指数为compInitialIndex

7481fa7d11cef4c1559da993b15f5aa8

但是既然市场奖励指数初始化为了compInitialIndex,那用户的奖励指数呢?这是我们来看新的distributeSupplierComp函数:

489218110abeb7a81e5291d88c56d6be

因为很多市场的 CompSpeed 为0,所以其奖励指数会停留在 compInitialIndex(1e36) 这个值,此时如果调用这个函数会发生什么?

很显然上图中的if被绕过了,这意味着没有初始化用户的奖励指数(supplierIndex),而市场的奖励指数(supplyIndex)是compInitialIndex

所以deltaIndex本应是(compInitialIndex - compInitialIndex = 0)就变成了 (compInitialIndex - 0 = 1e36)

哦豁,出大问题。可是,奖励不仅仅依赖于这个deltaIndex,还需要用户有cToken(supplierTokens)

是否存在这一情况呢?显然是存在的,如果用户在合约更新之前就做了mint操作,其supplierIndex=0,但是手里是存在cToken的。当合约更新后,用户再次调用该函数,就可以获得 1e36 * ctoken.balanceOf(user) 数量的COMP奖励Real World

通过compStateIndex = compInitialIndex,可以很容易的得到受到影响的市场有:

0xF5DCe57282A584D2746FaF1593d3121Fcac444dC: cSAI0x12392F67bdf24faE0AF363c24aC620a2f67DAd86: cTUSD0x95b4eF2869eBD94BEb4eEE400a99824BF5DC325b: cMKR0x4B0181102A0112A2ef11AbEE5563bb4a3176c9d7: cSUSHI0xe65cdB6479BaC1e22340E4E755fAE7E509EcD06c: cAAVE0x80a2AE356fc9ef4305676f7a3E2Ed04e12C33946: cYFI

我们以一位涉事者为例:0xa7b95d2a2d10028cc4450e453151181cbcac74fc

我们看到在这笔交易中:0x6416ed016c39ffa23694a70d8a386c613f005be18aa0048ded8094f6165e7308

f53851b3e14e824209685f64563dda11

其Claim大量的COMP代币,通过调试我们发现,在调用distribute时:

b2c2014b7874a58375abd1a0cf27ae7b

由于事故2,获得的deltaIndex = 1e36,而恰恰该用户之前有cToken

0ce9ab3eb5b6cba4fd0a458599704140

从而可以薅到大量的COMP:

c8dd39ab145c90bef263c9514f27f1ec

最终,事情的解决方式也很简单

在接下来一条提案中(Proposal 63),暂停COMP奖励,但是最终被取消掉了

最新的一条提案,更新了Comptroller合约,该提案目前仍在排队中:

d345b516af4711a6855607072667fadf

最新的合约里,distributeSupplierComp函数中初始化用户奖励指数的判断条件修改如下:

c2b27a4561a3f04ec2796729dc8ebe89

总结与反思

Compound作为借贷平台的老大哥,本次的事件有些唏嘘

虽然Compound软硬兼施,一方面承诺拿出10%的白帽奖励给获得"意外之财"的用户,一方面又寻求法律的手段。但是,事故终究已经发生

当我们不断探索区块链,不断追求更高的APY,追求项目快速落地。是否还有人记得,区块链最基本的一条原则就是:覆水难收!

启示如下:

代码部署上链前一定要做好充足的审计与测试工作

使用代理模式时,更新逻辑合约要保证一致性,注意是否会对原来的Storage产生影响

DAO模式虽然减少了中心化的风险,但是应对紧急情况时的反应迟缓问题

即使是大公司依然会有犯错误的可能,借鉴其他项目代码时要注意检查

Comptroller: compSpeed bug:https://www.comp.xyz/t/comptroller-compspeed-bug/2111

github issue:https://github.com/compound-finance/compound-protocol/pull/144/commits/f6d717bb78bef0c9851ad672f7b9aa1d90b0f00a


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK