Simplified Compound Protocol Design

  • bixia1994
  • 更新于 2021-11-14 23:42
  • 阅读 2947

Compound简化版

Compound简化版

最近学习了很多的Compound和AAVE,读了白皮书,也参加了合约分享,对经典的借贷项目感觉有一点了解,但是始终感觉不深刻,就还是有一点朦朦胧胧的。

原创!不要转载!不要转载!不要发到公众号上!

写这篇文章的目的是检验一下自己对于Compound等借贷产品的理解,以及用智能合约练练手,找找感觉。

初步的计划有:

  1. 实现Compound中提到的资金池,包含主要的业务逻辑有:存,取,借,还,清算
  2. 要保持代码编写简单,不使用compound中的错误码
  3. 要保持代码结构简单,不考虑Dai,USDT等非标ERC20, 只考虑两种ERC20, 即Test token和WMATIC
  4. 要保持业务逻辑简单,不考虑Compound中的链上治理,不考虑COMP token的分发等
  5. 要保证代码量小,不涉及前端业务调用,只部署合约,以及与合约相关的ethers调用
  6. 不使用价格语言机,或者仅仅使用chainLink的价格预言机
  7. 合约代码部署到Matic链上,后期也可以交互
  8. 利率模型就使用直线型的利率模型,而非后期的折线
  9. 使用代理合约模式,具体代理过程如下:
    1. cTestProxy -> cToken
    2. unicontrollerProxy -> comptroller
  10. TestToken是一个ERC20 Token,用于测试用。WMatic是matic链上的真实Token
  11. 使用openzeppelin的合约模板以及Hardhat开发环境

合约结构设计:

在合约结构上,主要还是参考compound自身的合约结构来进行解耦合。主合约是cToken合约,所有的业务逻辑都在cToken中实现。针对具体的Test token,部署一个代理合约cTestProxy来存储所有的数据,该代理合约指向cToken. 在cToken中,需要使用到价格,这里就调用一个获取价格的接口合约priceOracleInterface,传入underlying Token的地址来获取价格。为保证cToken合约的尽可能简单,将cToken合约中的所有管理类的方法都抽象到comptroller合约中。同时设计一个comptrollerInterface的接口合约,用于定义这些管理类方法的接口。

在关于comptorller合约的设计时,一个关键的问题是comptorller合约是否需要存储状态?comptorller合约的定义是针对不同的cToken中的所有的管理类方法的一个集合。既然是不同的cToken,那么每一种cToken的具体管理参数应该是不一样的,故comptorller合约应该是要存储状态的。同时为了将存储和逻辑分开,这里肯定是将状态存储到代理合约上,具体逻辑写在实现合约中。那么这里肯定是要将针对所有的cToken的管理类参数都要写在这个代理合约unitrollerProxy里面。需要一个map,类似于compound中定义的markets概念。

合约逻辑设计:

compound里面最主要的经济模型都在cToken合约里面实现,所以最主要的还是要设计cToken的逻辑

cToken的逻辑设计:

全局变量的设计

cToken是一个ERC20的合约,其应该继承自openzeppelin的ERC20.除开普通的ERC20部分所涉及到的全局变量,还应该有自身的一些全局变量。

资金池的衡量指标:

对于一个资金池,最重要的指标是资金利用率 U:

当前资金池的资金利用率 U, 其中资金利用率是一个可以通过totalBorrows,cash,totalReserves实时计算出的一个数值,故不需要作为一个全局变量

当前资金池的总借贷:totalBorrows -> 总借贷的值应该作为一个全局变量,且应该是计算复利后的值

当前资金池的总流动性:cash -> 这个值可以通过实时取当前资金池在对应的underlying token的balance来得到,也不需要作为一个全局变量

当前资金池的总储备金:totalReserves

当前cToken的总供给:totalSupply, 来源于cToken,需要作为一个全局变量

还有一个参数需要考虑,即将利息转换成储备金的比例,这个参数也应该是一个全局变量

uint public totalBorrows;
uint public totalReserves;
uint public totalSupply;
uint public reserveFactorMantissa;

对于资金池的资金利用率指标,与之直接关联的是借贷指数因子Index:

借贷指数因子的概念是将$FV=PV(1+x)^t$公式在借贷中一次应用,其反应的是这个资金池在单位资金量下的,经过一段时间和利率波动后,资金的未来价值与当前价值的比例。具体设计时,一个Index的计算为$Index_B=Index_A+Index_A\times BorrowRate_A \times \Delta Blocks$,即Index值应该也是一个全局变量,更新这个全局变量需要用到$\Delta Blocks$和$BorrowRateA$, 对于$\Delta Blocks$,需要用到一个全局变量, 来记录之前的区块高度

uint public borrowIndex;
uint public accrualBlockNumber;

对于BorrowRate,需要根据资金利用率来实时的计算,故不需要作为一个全局变量。

用户的数据结构设计:

债务记账方面:

因为compound是给用户记账来表明用户实际的借款,那么记账模式中,需要把用户的当前最新总债务principal和记账时刻的index记录到一个用户中。即为每一个用户都构建一个结构体:

struct BorrowSnapshot {
        uint principal;
        uint interestIndex;
}
maping(address=>BorrowSnapshot) internal accountBorrows;

设计用户的数据结构时,需要考虑的一个问题是:是否应该把区块高度也设计到用户的数据结构体中?因为最简单的想法肯定是把用户的当前累计债务含利息,用户的利率指数,以及当前的区块高度一起写进去。但是需要考虑到的问题是利率指数事实上已经包含了时间这一信息,当计算用户的新的债务时,只需要把Index_B/Index_A即可。不再需要时间。故不应该把blocknumber写到用户的数据结构中。

权益方面:

针对用户存款获取相应的权益,事实上就是用户收到的cToken。这里对于用户的cToken记账,可以采取最简单的ERC20记账模式,即:

mapping(address=>uint256) internal accountTokens;
汇率相关的指标:

汇率是一个可以通过如下公式来进行实时计算的一个值,但是对于每一个资金池的初始添加流动性时,需要用到一个汇率的初始值,这个初始值应该设计成一个全局变量:

汇率计算公式:

$汇率=\frac{cToken总数}{underlyingToken总数}$

uint internal initialExchangeRateMantissa;
ERC20相关的全局变量:

cToken是一个ERC20的token,它应该包含有普通的ERC20所应该有的全局变量:

string public name;
string public symbol;
uint8 public decimals;
mapping (address => mapping (address => uint)) internal transferAllowances;
uint public totalSupply;

accrueInterest函数

借贷产品如Compound中,最核心的一个概念是借贷指数,即accrueInterest, 如前文所言,借贷指数事实上是一个复利公式,即:

$FV=PV\times(1+x)^t=PV\times(1+xt)$, 这里在compound中的具体设计应为:$Index_B=Index_A+Index_A\times BorrowRate_A \times \Delta Blocks$

accrueInterest中涉及到的全局变量:
accrualBlockNumber -> 用于存放上一次更新的区块高度
borrowIndex -> 借贷指数,即Index_a
totalBorrows -> 资金池的总债务,含利息
具体函数设计逻辑:
function accrueInterest() public returns  {
//index应该是全局变量,这个函数的最终目的是更新Index的值
//它应该是按照块来更新的,即delta Blocks不应该为0,如果delta Blocks为0,则应该直接返回
//需要计算delta Blocks,也就是需要用到当前的区块高度和上一次的区块高度。Index_A是未更新Index之前的数值,borrowRateA应该是此时的利用率对应的借贷利率。需要思考的是这里的区块高度应该是一个全局变量还是一个写在用户数据里的一个结构体变量?这里应该是一个全局变量
//计算delta Blocks
//第一步:拿到当前的blocknumber
//第二步:拿到记录在合约中的accrualBlockNumber,即此前的blocknumber
//第三步:进行判断,如果当前的blocknumber与此前的blocknumber相等,则直接返回
//计算BorrowRateA
//第四步:拿到当前的cash,totalBorrows,totalReserves, borrowIndex
//第五步:根据cash,totalBorrows,totalReserves计算出资金利用率,然后查贷款利率曲线,计算出该资金利用率情况下对应的BorrowRate
//第六步:根据公式计算出新的BorrowIndex
//第七步:计算新的债务总额totalBorrows
//第八步:计算新的总储备金totalReserves
//第九步:更新区块高度,更新borrowIndex,更新总债务,更新总储备金
}

Mint函数

针对mint函数,其主要业务逻辑是用户拿underlying Token换成cToken。在mint函数执行前,需要优先执行accrueInterest函数,用于计算最新的borrowIndex。核心的兑换逻辑是拿到最新的cToken汇率,然后按照汇率将token兑换成cToken。最后把cToken转账给用户。

在mint函数之前,需要先更新资金池的状态,即调用accuralInterest函数

mint函数中是否涉及到管理类的方法?

涉及到,即用户是否允许mint,这个方法里面应该检查什么?

即:mintAllowed函数里面应该检查什么?

应该检查用户想要mint的cToken是否合法,用户转过来的token是否合法,cToken是否允许mint,即该cToken没有被Paused掉。事实上在compound中,comptroller中的mintAllowed方法检查了cToken是否被暂停,然后是针对COMP Token的分发机制进行了整理。

function mintAllowed(address cToken,address Minter,uint mintAmount) external returns {
    //第一步:检查cToken是否被paused掉
    //第二步:检查cToken是否列入了markets目录
    //第三步:COMP token相关
}
function mintFresh(address minter, uint mintAmount) internal returns {
    //第一步:管理类方法:是否允许mint
  //第二步:如果允许mint,则继续,如果不允许mint,则直接返回
  //第三步:检查目前资金池的状态是否为最新状态,即检查accuralBlockNumber的值
  //第四步:拿到此时的cToken汇率
  //第五步:把用户的Token转账到cToken的代理合约中
  //第六步:根据汇率计算出应该要mint出的cToken数量
  //第七步:更新状态:计算出总的totalSupply的值
  //第八步:更新状态:计算出用户的新的cToken的值
}

在mintFresh中,有一个检查:

require(accrualBlockNumber == getBlockNumber);

这个检查的目的是为了检查资金池的状态是否已经更新到最新。也就是保证用户与资金池交互的时候,资金池始终保持在最新的一个状态上。因为如前所说,一个资金池的状态由如下变量来衡量:totalBorrows, totalSupply, cash, accuralBlockNumber, borrowIndex。 可以通过检查accuralBlockNumber是否为当前的blockNumber,就可以判断出资金池的状态是否为最新状态。

Redeem函数

redeem函数的主要目的是取钱,与mint函数相反。用户拿着cToken按照当时的汇率换算成Token,然后把cToken给销毁掉。在用户取钱之前,需要先检查用户是否有足够的流动性来取钱,即需要一个管理类的函数,redeemAllowed。

管理类函数:redeemAllowed应该计算什么?

首先应该检查cToken是否合法,即cToken是否被paused掉,或者cToken是否在markets中

其次应该检查用户的流动性,应该如何去检查用户的流动性呢?简单的思路就是把用户的所有抵押资产的价值按照最新的价格重新算一下,然后按照各自的抵押系数进行乘积,然后再把所有的债务的价值全部算一下,这里需要注意的是把用户要取出的钱算在债务的价值里。然后两方相减得到流动性。

这里计算价格应该是U本位还是ETH本位?经确认是USDC本位

借钱的时候也应该把借钱的token对应的cToken写入到用户的accountAssets中

function redeemAllowed(address cToken,address redeemer,uint redeemTokens) return {
  //第一步:检查cToken是否被paused掉
  //第二步:检查cToken是否列入markets中
  //第三步:拿到用户所有的assets目录,然后针对每一个asset都进行循环,其实质是一个cToken
  //第四步:针对一个cToken,拿到用户的cToken余额,用户的债务和当时的汇率
  //第五步:从预言机中拿到cToken对应的underlying Token的价格,USDT本位还是ETH本位?是USDC本位
  //第六步:计算总的抵押品价值和总的债务价值
  //第七步:如果当前的cToken正好是这次要借出的cToken,则把借出的Token价值或者取出的cToken价值算到债务中
}

在redeem之前,也需要先更新下资金池的状态,即调用accuralInterest函数,进行一下复利计算并更新资金池的最新状态。

在redeem中,取钱的业务逻辑存在两种可能,第一种:明确告诉你要取出多少token,第二种:明确告诉你要销毁多少cToken,而换出等值的Token。但不能存在两种都给定的情况。

function redeemFresh(address payable redeemer,uint redeemTokensIn,uint redeemAmountIn) internal returns {
    //第一步:检查传入参数是否合法,即必须要求redeemTokensIn为0或者redeemAmountIn为0
  //第二步:拿到此时的cToken汇率
  //第三步:如果是给定cToken,换出Token,则利用cToken的数量除以汇率得到Token的数量
  //第四步:如果是给定token的数量,销毁cToken,则利用token的数量乘以汇率得到对应的cToken的数量
  //第五步:将计算得到的cToken数量,token数量和取款人,cToken的地址传给管理函数redeemAllowed,来判断用户是否可以取款
  //第六步:如果用户不可以取款,则直接返回
  //第七步:如果用户可以取款,则首先检查当前资金池的状态是否是最新的状态,即判断accuralBlockNumber是否等于当前blockNumber
  //第八步:更新totalsupply的值,即减去redeem的cToken数量
  //第九步:更新accountTokens中用户记账的cToken的数量
  //第十步:判断此时资金池中是否有足够的cash来支付给用户取钱
  //第十一步:把token转账给用户
  //第十二步:写入totalsupply和accountTokens[redeemer]
}

Borrow函数

Repay函数:

这里需要思考的是:实际偿还的金额跟他声明要偿还的金额是否一致?有可能不一致,因为有可能存在类似于USDT等扣手续费的一些token,所以需要返回一个值,返回一个实际偿还的数额

这里他提供了一个repayAmount为-1时,全部偿还的功能。所以需要针对repayAmount进行判断;

债务都是以Token来计量的,所以repayAmount的时候就直接就是Token来计算的,不像borrow还需要算一下汇率。

清算方法

清算涉及到两种cToken,要求两种cToken的状态都必须同步。涉及到两个角色,清算者和被清算人。清算人代为偿还token,获取cToken。被清算人将对应部分的cToken转账给清算人。

为什么会需要用到两种cToken,应该只是一种cToken啊?

第一种是债务Token对应的cToken,第二种是清算人指定的cToken

function liquidateBorrowFresh(address liquidator, address borrower, uint repayAmount, CTokenInterface cTokenCollateral) internal returns (uint, uint) 
{
  //第一步:更新本cToken资金池的状态
  //第二步:更新另一个cToken资金池的状态
  //第三步:检查是否能够被允许清算
  //第四步:检查cToken资金池的状态是否最新
  //第五步:检查另一个cToken资金池的状态是否最新
  //第六步:检查输入参数,要求清算者不能是被清算人
  //第七步:检查输入参数,要求清算金额不能为0,要求清算金额不能为uint(-1)
  //第八步:调用repayBorrowAmount方法由清算者代为偿还被清算人的债务
  //第九步:调用comptroller管理类方法,计算需要发放给清算者的cToken数量
  //第十步:检查被清算人的相应cToken数量是否大于发放给清算者的数量
  //第十一步:如果两种cToken相同,则直接调用内部方法seizeInternal,把被清算人的cToken转账给清算者
  //第十二步:如果两种cToken不同,则通过外部调用seize方法,实现同样的逻辑。
}

这里需要思考的是repayBorrowAmount方法中,实际偿还的token数量是不是就是传进去的repayAmount数量?

这里应该可以认为是一致的。但是这里还是应该让repayBorrowAmount直接返回一个数值,即actualRepayAmount值。

点赞 1
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
bixia1994
bixia1994
0x92Fb...C666
learn to code