【收集】-剖析DeFi借贷产品之Compound:清算篇

清算机制

因为数字资产存在价格波动,若用户的所借资产上涨或抵押资产下跌,导致用户的债务价值超过抵押资产的安全门槛时,就可以被清算。我们用具体的场景来说明。

假如,用户存入了 1 个 ETH,价值 2200 USD。将其开启作为抵押品,ETH 的抵押因子(即抵押率)为 75%,所以最多可借出价值 2200*0.75 = 1650 USD 的资产。假设用户想借 USDC,这是个稳定币,会恒定等于 1 USD,因此用户最多可借出 1650 USDC,不要全部借满,那样很容易一下子就触发清算了,就借个 1500 USDC 吧,这时的借款债务即为 1500 USD,还剩下 1650 - 1500 = 150 USD 的可借额度。

过了一段时间,假设 ETH 价格下跌了,跌到了 2000 USD,那这时候,抵押资产的可借额度已经降到了 2000*0.75 = 1500 USD,而借款债务也是 1500 USD,这时候就处于一个清算的临界点了。不过,这时候,还不会被清算。但是,只要 ETH 跌破 2000 USD,那借款债务价值就会超过抵押资产的可借价值,就可以被清算了。

然而,因为智能合约的特性,合约层面没办法自动执行清算,只能提供执行清算的入口函数,供外部程序调用。这些调用清算函数的外部程序也被称为**清算人。**而清算人要来帮助完成清算功能,是存在成本的,因此清算时会给清算人一些清算激励,由被清算人(即借款人)承担。清算激励也是从用户的抵押资产中扣减。

Compound 使用的是代还款清算的模式,需要清算人代借款人进行还款,并得到借款人的抵押资产。另外,还有一个 closeFactor 用来限制清算人每次代还款的比例,目前该值为 50%,即清算人每次清算时只能帮借款人代还款 50%。比如,借款人借了 1500 USDC,那清算时只能代还款 1500*0.5=750 USDC。这样,对借款人就起到了保护作用,不会一下子就给全部清算了。

另外,清算时,使用哪种抵押资产作清算,也是由清算人来指定。这样,对清算人来说更具有灵活性了。比如,借款人有 cETH 和 cUNI 两种抵押资产,清算人可能更倾向于得到 cETH,那清算人就可以指定 cETH 作为当次清算时的抵押资产。不过,另一方面,作为清算人的清算服务,需要程序化的工作更多了。

那么,清算时,清算人能得到多少抵押资产呢?这是由以下公式计算得到的:

seizeTokens = actualRepayAmount * liquidationIncentive * priceBorrowed / (priceCollateral * exchangeRate)

  • seizeTokens 即最后得到的抵押资产数量,是 cToken 的数量

  • actualRepayAmount 为代还款的实际金额

  • liquidationIncentive 是清算激励,该值目前为 1.08,即清算人可获得借款价值 8% 的额外收益

  • priceBorrowed 所借资产的当前价格

  • priceCollateral 抵押物的标的资产价格

  • exchangeRate 兑换率

我们再举一个例子来说明,以便能更好地理解清算逻辑。

还是引用上面的例子,用户借了 1500 USDC,抵押资产为 cETH。清算人要对这笔借款进行清算时,可代还款金额为 1500*0.5=750 USDC,清算的抵押资产就指定为 cETH。ETH 价格假设为 1990,兑换率为 0.02,那根据公式计算得出清算人可得到的抵押资产数量为 750 * 1.08 * 1 / (1990 * 0.02) = 810 / 39.8 = 20.3517... 即清算人最终可得到 20.3517... 多的 cETH。

下面,我们就来聊聊担任清算人的清算服务具体如何设计。

清算服务v1版

先从最简单的 v1 版本开始,整体架构大致如下:

第一步,先从 Subgraph 查询出所有 Market,因为 Compound 的市场不多,一次查询就能全部查询出来了。另外,为了应对后续有增加新的市场,可以开启一个定时任务,每隔一天或一个小时重新查询所有 market 并更新数据。

第二步,将每个市场分开用不同协程/线程处理,定时(每隔1分钟)查询出市场中尚有债务未还的所有账户信息,也是从 Subgraph 中查询,只要查询出 storedBorrowBalance 大于零的 AccountCToken 数据就可得到。查询的 GraphQL 语句如下:

query ($symbol: String!, $lastBlockNumber: BigInt) {
 accountCTokens(first: 1000, orderBy: accrualBlockNumber,
 where: {accrualBlockNumber_gt: $lastBlockNumber, storedBorrowBalance_gt: 0, symbol: $symbol}) {
 id
 symbol
 accrualBlockNumber
 storedBorrowBalance
 market {
   id
   underlyingAddress
   underlyingSymbol
 }
 account {
   id
 } 
}
}

指定 first: 1000 表示最多查出 1000 条记录,这也是 GraphQL 每次查询的上限条数。那超出 1000 条之外的数据又要怎么查呢?这就是 accrualBlockNumber 发挥作用的时候了。查询时通过添加 orderBy对 accrualBlockNumber 进行排序,并在 where 条件中添加 accrualBlockNumber_gt:lastBlockNumber 就可以从指定的区块开始查询。第一次查询的时候,$lastBlockNumber 参数为 0,而如果查询结果的条数达到了 1000,那就将最后一条记录的 accrualBlockNumber 设为下一次查询的 lastBlockNumber,再进行一次查询,依此类推,就可以查出所有数据。

查询所有尚有债务未还的 AccountCToken 记录之后,就需要对每条记录分别判断是否超过了清算门槛。这可以通过调用 Comptroller 合约的 GetAccountLiquidity(address) 函数查询得知,如果该函数返回的 shortfall 值大于 0,就表示超过清算门槛了,这时就可以将这条 AccountCToken 记录丢进待清算队列,等待清算执行器进行下一步处理。

清算执行器是一个独立的协程/线程,它会一直监听待清算队列并依次消费该队列中的 AccountCToken记录。因为 AccountCToken 记录在队列中经过了等待时间,而这段时间内用户的债务状态可能又已经回到了安全值,所以清算执行器从队列中读取到 AccountCToken 之后,应该再调用一次 GetAccountLiquidity(address) 判断是否超过清算门槛,是的话才进行下一步的清算处理。清算处理也并非简单地直接调用合约的清算函数就了事了,在这之前,还有几步准备工作要做。

首先,清算执行器需要绑定一个钱包私钥,该钱包需要有资产可以用来支付 Gas 和代还款。其次,需要对该钱包进行授权操作,需要授权给每个市场的 cToken 合约进行标的资产的划转。接着,就需要计算本次清算的代还款金额,以及选定清算用户的哪种抵押资产,这两个都将作为清算函数的入参传递过去。只是,要确定这两个参数的有效性,其实并不简单,考虑以下两种场景:

  • 假设借款人的借款金额 100 USDC,那本次清算最多可代还款 100*0.5=50 USDC,但是,钱包余额不足 50 USDC。

  • 假设代还款金额 50 USDC,但选定的抵押资产价值却值不了 50 USDC。

第一种场景容易解决,钱包余额不足的话,那代还款金额就取钱包剩下的余额即可。当然,如果代还款资产是 ETH,则需要预留一些作为 Gas 费。

第二种场景处理起来就比较麻烦了,需要反向计算出用户的单个抵押资产能实际还款多少借款资产,可根据以下公式计算得出:

actualRepayAmount = seizeTokens * priceCollateral * exchangeRate / (liquidationIncentive * priceBorrowed)

要获得计算所需的这些数据,涉及三个合约:

  • PriceOracle 价格预言机合约,读取两个币种的最新价格

  • Comptroller 合约,读取清算激励值

  • cToken 合约,读取借款人的抵押资产余额和兑换率

为了清算服务的清算利益最大化,那就要尽可能多还款,所以,如果用户的第一种抵押资产价值不足以抵扣第一种场景中的代还款金额时,那就需要计算下一种抵押资产价值是否足够,若每一种抵押资产都不足够的话,则只能从中选择价值最高的抵押资产来做清算,且代还款金额设为该抵押资产所计算出来的 actualRepayAmount。

总结一下,整个清算执行的流程图大致如下:

清算服务v2版

v1 版本的清算服务可以实现功能,当数据量不大的时候也能轻松应对。不过,一旦数据量上来了,性能将会成为瓶颈。主要有两个地方会影响性能,一在于查询所有尚有借款的账户并依次查询是否可清算,二在于清算执行器。

先分析第一点,虽然我们已经分不同市场作并发处理了,但因为不同市场存在热度差异,所以数据量也存在差异,我曾经对比过数据,有借款记录的市场,少的只有几百,多的达到几千。那对于达到了几千记录的市场,从 Subgraph 查询回所有数据就需要分批次调用多了几次,而依次查询是否可清算时又因为数据量较大,时间消耗也会比较长。

优化方案也不难,多开几个协程/线程分开处理就好了。可以限定每个协程/线程最多只查询并处理 500 条数据,超出 500 条就启动另一个协程/线程去处理,另一个协程/线程里也同样只查询并处理后面的 500 条数据,以此类推。这样的话,那些达到了几千条借款数据的市场,无非就是多开了几个协程/线程进行分批次的并发处理。流程图如下:

接着,就来看看清算执行器如何优化。清算执行的性能瓶颈,主要在于只有一个待清算队列,那么,当需要被清算的资产较多的时候,队列就容易形成堆积,甚至产生阻塞,从而还会影响前面的查询处理的性能。那么,优化思路其实也和前面的一样,分开进行并发处理就好了,即可以将不同市场的待清算资产分发到不同的待清算队列,每个队列再用不同的清算执行器进行处理。如下图:

不过,这样分开之后,每个清算执行器就需要使用各自独立的钱包执行清算操作了。如果有 10 个市场,就需要分别准备 10 个钱包。

有些小伙伴可能不明白,为什么每个清算执行器需要使用单独的钱包,不能所有执行器都使用同一个钱包吗?

这是因为区块链的特性要求了每个账户的交易需要保证串行,否则就容易产生双花问题。举个例子说明一下就很容易理解这点了。假设用户钱包里原本有 10 个 ETH,现在需要给 A 和 B 各自转 1 个 ETH,如果可以同时转,那将出现下面的状态:

这自然是不对的,正确的状态应该是这样的:

其实,传统金融也存在同样的场景,技术上主要是通过加锁的方式解决。而在区块链中,主要则是通过一个递增的 nonce 值来控制。每个钱包地址发生交易的时候,都会在前一笔交易的基础上 nonce 值加1,而且还不能跳过,比如前一笔交易的 nonce 值为 10,如果这一笔交易的 nonce 值设为 12,那就会一直等待,等待 nonce 值为 11 的交易执行之后,才会执行 nonce 值为 12 的这一笔。

因此,涉及资产转账的时候,就需要严格保证顺序性。因此,设计上才添加队列且每个队列后的清算执行器用单独的钱包,这样才能保证交易的顺序性。

清算服务v3版

正如上面所说,当市场多的时候,那需要管理的钱包也多了,清算服务 v3 版本就来解决这个问题。

优雅的方案应该是人工只需要管理一个主钱包,而每个清算执行器使用的钱包则是由程序自动管理,包括钱包的创建、分配、转账等。

资产的进出都只通过主钱包操作,而清算执行器使用的子钱包则可以用 HD钱包(分层确定性钱包)的方式从主钱包派生出来。这样,就只需保存一套助记词即可。而不同子钱包是用 path 来区分的,比如,第一个子钱包的路径为 m/44'/60'/0'/0/0,第二个子钱包为 m/44'/60'/0'/0/1,依次在最后一个数字上递增即可,我们可以将最后一个数字称为 index。不同市场的清算执行器需要使用不同的子钱包,那就需要配置不同的 index,这在配置文件中进行配置即可。

因为清算时需要代还款,所以处理每个市场的钱包里就需要预留相应市场的标的资产余额。可将资产充值到主钱包,再通过程序本身自动将资产从主钱包划转到子钱包。比如, m/44'/60'/0'/0/1 这个子钱包用来处理 UNI 市场, m/44'/60'/0'/0/2 子钱包用来处理 DAI 市场,这两个子钱包里就分别需要存有 UNI 和 DAI 资产。那先往主钱包里充值一定量的 UNI 和 DAI,清算程序再自动将主钱包里的 UNI 转到 m/44'/60'/0'/0/1 的子钱包,而将主钱包里的 DAI 转到 m/44'/60'/0'/0/2 子钱包。

随着每个子钱包不断地执行清算,标的资产不断被消耗,就需要不断给它转账新的资产,如果每次都要从外部往主钱包转账其实也挺麻烦的。那有没有优雅的解决方案呢?以下,我提供三种不同的方案。

第一种方案,每次清算完成后,得到了 cToken,可以直接将 cToken 赎回换成对应的标的资产,再接入 DEX 平台(比如 Uniswap)将标的资产兑换成清算时的代还款资产。这样做的好处显而易见,清算时付出了什么资产,最后收回的依然是此种资产,而且一般情况下能收回更多。如此,只要有了第一次资金投入之后,子钱包自身基本就能自给自足了。此方案的弊端则是因为多增加了两步操作,使得单次清算的时间更长了,会对整体的清算性能有影响。

第二种方案,也是每个子钱包自己去将 cToken 赎回并 swap 成代还款资产,但不是在每次清算完成后做这一步,而放在每次清算周期的最后再做。我们是每隔 1 分钟执行一次全量查询的,即是说,每次清算周期为 1 分钟,在这 1 分钟内会执行完所有的清算记录。那么,我们可以在周期内执行完所有清算后,再进行 cToken 的全部赎回并全部 swap 成代还款资产。这样一来,对原本的清算性能不会造成影响,也能保证资产的自给自足。只是,与第一种方案不同的是,第一次资金的投入,需要可以支撑整一轮的清算。

第三种方案,则换个思路了,我们不把 cToken 进行 swap,而是把这些 cToken 直接划转给其他需要的子钱包,比如,把 cUNI 划转到负责 UNI 市场的子钱包,把 cDAI 划转给负责 DAI 市场的子钱包,这样,其实就是将所有子钱包清算所得的 cToken 进行重新分配了,分配给对应市场的子钱包,这样,子钱包就无需去做大量 swap 操作,可以将从其他子钱包汇集过来的 cToken 直接赎回成标的资产即可。不过,有几个市场却无法使用此方案,这几个市场的资产只能借款但不能作为抵押品,包括 USDT、TUSD、LINK,即是说,所有子钱包都不会清算得到这几个资产的 cToken,那怎么办呢?那就用方案二单独处理吧。总而言之,不支持抵押的几个市场,就用第二种方案;其他市场,则可以使用方案三。这样,可以减少大量 swap 所造成的成本。

最后,当清算收益累积到一定程度,还应该允许将资产从子钱包转移到主钱包,从而可以将收益提取出来。

至此,清算服务 v3 版本的核心设计就这些了。

总结

不过,也不是说清算服务完成了 v3 版本就结束了,后面依然还有可以继续优化迭代的空间,比如,拆分为多个服务,变成集群化;比如,增加运营后台,可以调整一些清算策略。

后面,Compound 该系列的文章就剩下最后一篇了,延伸篇,敬请期待!

Subscribe to 0x00pluto
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.