Liquity 协议 — 从其智能合约角度解析 DeFi 协议 (第一部分)

  • zealynx
  • 发布于 2023-04-29 13:19
  • 阅读 30

该文章深入分析了去中心化借贷协议 Liquity,详细解释了其核心组成部分,包括 LUSD 和 LQTY 两种代币、Trove(抵押金库)的工作原理、借贷流程及费用计算,以及喂价机制和预言机的作用。文章通过引用智能合约代码片段,从技术实现层面阐述了协议的运作逻辑。

考虑到我们为了提高审计技能需要学习多少 DeFi 协议,这是关于 Liquity 协议的第一部分——直接从其智能合约解释。

我最终决定将其分为两部分,否则它可能成为一篇冗长的文章,而且我觉得可以将其分开以理解不同的概念。

目录

引言

Liquity 是一个全自动化、无治理的去中心化借贷协议。它旨在允许对其原生代币进行无需许可的借贷,并以 Ether 作为抵押品获取免息贷款。

Liquity 不运行自己的前端。要与协议交互,用户必须使用第三方前端。这样做是为了实现最大的资本效率和用户友好性。

贷款以 LUSD 支付,需要维持至少 110% 的最低抵押率。

除了抵押品,贷款还由包含 LUSD 的 Stability Pool 以及共同充当最后保障者的其他借款人集体担保。

代币

什么是 LUSD?

LUSD 是一种与美元Hook的稳定币,作为 Liquity 协议的主要网络代币。当用户将其 Ethereum 存入他们的账户,或者说是 Trove 中时,他们会收到以 LUSD 形式发放的贷款。

涉及的智能合约:

LUSDToken.sol:

它是稳定币代币合约,实现了 ERC20 可替代代币 标准,并结合 EIP-2612 和一种机制,阻止(意外地)向 StabilityPool 和 address(0) 等不应通过直接转账接收资金的地址进行转账。

该合约铸造、销毁和转移 LUSD 代币。

为了验证是谁在铸造代币,我们可以在 mint 函数中注意到,它只允许从 BorrowOperations 合约中铸造:

function mint(address _account, uint256 _amount) external override {
    _requireCallerIsBorrowerOperations();
    _mint(_account, _amount);
}

而新 LUSD 代币被铸造的主要情况是通过用于借贷的 openTrove() 函数。

什么是 LQTY?

LQTY 是 Liquity 借贷系统的辅助网络代币,作为奖励和激励分发给那些为系统工作的人。这些包括完成交易的前端、Stability Pool 的贡献者和流动性提供者。这些是赚取 LQTY 的唯一方式。

LQTY 合约包括:

LQTYStaking.sol:

质押合约,包含 LQTY 持有者的质押和解除质押功能。该合约从赎回中收取 ETH 费用,并从新债发行中收取 LUSD 费用。

CommunityIssuance.sol:

该合约根据时间向 Stability Providers 发行 LQTY 代币。

function issueLQTY() external override returns (uint) {
    _requireCallerIsStabilityPool();

    uint latestTotalLQTYIssued = LQTYSupplyCap
        .mul(_getCumulativeIssuanceFraction())
        .div(DECIMAL_PRECISION);
    uint issuance = latestTotalLQTYIssued.sub(totalLQTYIssued);

    totalLQTYIssued = latestTotalLQTYIssued;
    emit TotalLQTYIssuedUpdated(latestTotalLQTYIssued);

    return issuance;
}

它由 StabilityPool 控制。你可以通过 _requireCallerIsStabilityPool() 的使用看出这一点。

该合约还随着时间向 Stability Providers 发行这些 LQTY 代币。并通过以下方式进行转移:

function sendLQTY(address _account, uint _LQTYamount) external override {
    _requireCallerIsStabilityPool();
    lqtyToken.transfer(_account, _LQTYamount);
}

LQTYToken.sol:

这是 LQTY ERC20 合约。它的供应上限为 1 亿枚。它的铸造方式很有趣:

// 为赏金/黑客马拉松分配 200 万
_mint(_bountyAddress, bountyEntitlement);
uint bountyEntitlement = _1_MILLION.mul(2);

// 为算法发行计划分配 3200 万
uint depositorsAndFrontEndsEntitlement = _1_MILLION.mul(32);
_mint(_communityIssuanceAddress, depositorsAndFrontEndsEntitlement);

// 为 LP 奖励分配 133 万
uint _lpRewardsEntitlement = _1_MILLION.mul(4).div(3);
lpRewardsEntitlement = _lpRewardsEntitlement;
_mint(_lpRewardsAddress, _lpRewardsEntitlement);

// 将剩余部分分配给 LQTY 多重签名:
// (100 - 2 - 32 - 1.33) 百万 = 6466 万
uint multisigEntitlement = _1_MILLION.mul(100)
    .sub(bountyEntitlement)
    .sub(depositorsAndFrontEndsEntitlement)
    .sub(_lpRewardsEntitlement);

_mint(_multisigAddress, multisigEntitlement);

什么是 Trove?

Trove 是你借出和维持贷款的地方。每个 Trove 都链接到一个 Ethereum 地址,并且每个地址只能拥有一个 Trove。

如果你熟悉其他平台上的 Vaults 或 CDP,Troves 在概念上是相似的。

struct Trove {
    uint debt;
    uint coll;
    uint stake;
    Status status;
    uint128 arrayIndex;
}

Trove 维护两个余额:一个是作为抵押品的资产 (ETH),另一个是以 LUSD 计价的债务。

你可以通过增加抵押品或偿还债务来改变两者的数量。

你可以随时通过完全偿清债务来关闭你的 Trove。

TroveManager.sol

包含清算和赎回功能。

在这个枚举中,它用于相应函数的事件,我们可以看到一些主要函数的名称:

enum TroveManagerOperation {
    applyPendingRewards,
    liquidateInNormalMode,
    liquidateInRecoveryMode,
    redeemCollateral
}

有不同的“Trove 清算功能”:

// 单一清算函数。
// 如果 Trove 的 ICR 低于最低抵押率,则关闭该 Trove。
function liquidate(address _borrower) external

// 在正常模式下清算一个 Trove。
function _liquidateNormalMode(
    IActivePool _activePool,
    IDefaultPool _defaultPool,
    address _borrower,
    uint _LUSDInStabPool
) internal returns (LiquidationValues memory singleLiquidation)

// 在恢复模式下清算一个 Trove。
function _liquidateRecoveryMode(
    IActivePool _activePool,
    IDefaultPool _defaultPool,
    address _borrower,
    uint _ICR,
    uint _LUSDInStabPool,
    uint _TCR,
    uint _price
) internal returns (LiquidationValues memory singleLiquidation)

/*
 * 清算一系列 Trove。最多关闭 n 个抵押不足的 Trove,
 * 从系统中抵押率最低的 Trove 开始,然后向上移动。
 */
function liquidateTroves(uint _n) external

它在 redeemCollateral 函数中将赎回费用发送到 LQTYStaking 合约:

function redeemCollateral(
    uint _LUSDamount,
    address _firstRedemptionHint,
    address _upperPartialRedemptionHint,
    address _lowerPartialRedemptionHint,
    uint _partialRedemptionHintNICR,
    uint _maxIterations,
    uint _maxFeePercentage
) external

TroveManager 合约将其数据存储在 ContractsCache 中:

ContractsCache memory contractsCache = ContractsCache(
    activePool,
    defaultPool,
    lusdToken,
    lqtyStaking,
    sortedTroves,
    collSurplusPool,
    gasPoolAddress
);

并且在赎回抵押品时,它将它们用于以下操作:

// 确认赎回者的余额小于 LUSD 总供应量
assert(
    contractsCache.lusdToken.balanceOf(msg.sender)
        <= totals.totalLUSDSupplyAtStart
);

// 将借款人从重新分配中获得的抵押品和债务奖励
// 添加到他们的 Trove 中
_applyPendingRewards(
    contractsCache.activePool,
    contractsCache.defaultPool,
    currentBorrower
);

// 将 ETH 费用发送到 LQTY 质押合约
contractsCache.activePool.sendETH(
    address(contractsCache.lqtyStaking),
    totals.ETHFee
);
contractsCache.lqtyStaking.increaseF_ETH(totals.ETHFee);

还包含每个 Trove 的状态——即 Trove 的抵押品和债务记录。该状态在一个枚举中定义,并作为参数传递给像 _closeTrove(address _borrower, Status closedStatus) 这样的内部函数:

enum Status {
    nonExistent,
    active,
    closedByOwner,
    closedByLiquidation,
    closedByRedemption
}

TroveManager 不持有价值(即 Ether / 其他代币)。TroveManager 函数会调用各种 Pool,指示它们在必要时在 Pool 之间移动 Ether/代币。

SortedTroves.sol

一个双向链表,存储 Trove 所有者的地址,按其个人抵押率 (ICR) 排序。

它根据 Trove 的 ICR 将 Trove 插入和重新插入到正确的位置。用于此的函数是 _insert

function _insert(
    ITroveManager _troveManager,
    address _id,
    uint256 _NICR,
    address _prevId,
    address _nextId
) internal

在进入存储地址的逻辑之前,它会经历一系列要求:

// 列表不能已满
require(!isFull(), "SortedTroves: List is full");
// 列表中不能已经包含该节点
require(!contains(_id), "SortedTroves: List already contains the node");
// 节点 ID 不能为 null
require(_id != address(0), "SortedTroves: Id cannot be zero");
// NICR 必须非零
require(_NICR > 0, "SortedTroves: NICR must be positive");

借贷

用户可以通过开立 Trove 来借贷。在将 ETH 作为基础抵押品提交后,贷款接收者将获得 LUSD 的免息贷款,LUSD 是该协议的原生稳定币。

借款费用如何计算?

借款费用被添加到 Trove 的债务中,由 baseRate 给出。费率限制在 0.5% 到 5% 之间,并乘以借款人提取的流动性金额。

此外,还将收取 200 LUSD 的清算储备金,但在偿还债务时会退还给你。

例如:借款费用为 0.5%,借款人希望收到 4,000 LUSD 到他们的钱包。被收取 20.00 LUSD 的借款费用后,借款人在加上清算储备金和发行费后将产生 4,220 LUSD 的债务。

BorrowerOperations.sol

Trove 创建、ETH 充值/提现、稳定币发行和偿还。

它还将发行费发送到 LQTYStaking 合约。BorrowerOperations 函数会调用 TroveManager,指示它在必要时更新 Trove 状态。BorrowerOperations 函数还会调用各种 Pool,指示它们在必要时在 Pool 之间或 Pool 与用户之间移动 Ether/代币。

我们可以在这个枚举中看到其主要功能:

enum BorrowerOperation {
    openTrove,
    closeTrove,
    adjustTrove
}

该合约将主要的 相关数据 存储在一个结构体中:

struct ContractsCache {
    ITroveManager troveManager;
    IActivePool activePool;
    ILUSDToken lusdToken;
}

openTrove()

function openTrove(
    uint _maxFeePercentage,
    uint _LUSDAmount,
    address _upperHint,
    address _lowerHint
) external payable

为了开始借贷操作,需要满足两个要求:

function _requireValidMaxFeePercentage(
    uint _maxFeePercentage,
    bool _isRecoveryMode
) internal pure {
    if (_isRecoveryMode) {
        require(
            _maxFeePercentage <= DECIMAL_PRECISION,
            "Max fee percentage must less than or equal to 100%"
        );
    } else {
        require(
            _maxFeePercentage >= BORROWING_FEE_FLOOR
                && _maxFeePercentage <= DECIMAL_PRECISION,
            "Max fee percentage must be between 0.5% and 100%"
        );
    }
}

和:

function _requireTroveisNotActive(
    ITroveManager _troveManager,
    address _borrower
) internal view {
    uint status = _troveManager.getTroveStatus(_borrower);
    require(status != 1, "BorrowerOps: Trove is active");
}

然后它为这个 Trove 设置了一些属性:

contractsCache.troveManager.setTroveStatus(msg.sender, 1);
contractsCache.troveManager.increaseTroveColl(msg.sender, msg.value);
contractsCache.troveManager.increaseTroveDebt(msg.sender, vars.compositeDebt);

在这种情况下,“1”是 active 状态。

紧接着,它存储分配给借款人地址的 Trove:

sortedTroves.insert(msg.sender, vars.NICR, _upperHint, _lowerHint);
vars.arrayIndex = contractsCache.troveManager.addTroveOwnerToArray(msg.sender);

最后但同样重要的是,发生了两件非常重要的事情——添加用户提供的作为抵押品的以太币,并将新铸造的 LUSD 代币返还给用户:

// 将以太币转移到 Active Pool,并向借款人铸造 LUSDAmount
_activePoolAddColl(contractsCache.activePool, msg.value);
_withdrawLUSD(
    contractsCache.activePool,
    contractsCache.lusdToken,
    msg.sender,
    _LUSDAmount,
    vars.netDebt
);

第二件事是,将特定的 LUSD 代币存储在 Gas Pool 中:

// 将 LUSD Gas补偿转移到 Gas Pool
_withdrawLUSD(
    contractsCache.activePool,
    contractsCache.lusdToken,
    gasPoolAddress,
    LUSD_GAS_COMPENSATION,
    LUSD_GAS_COMPENSATION
);

closeTrove()

function closeTrove() external

主要要求是:

_requireTroveisActive(troveManagerCached, msg.sender);
uint price = priceFeed.fetchPrice();
_requireNotInRecoveryMode(price);

要求函数的名称非常 不言自明。价格是从 Oracle 获取的。

现在,让我们将这里发生的事情分为三个主要行动:

首先,它处理 Trove。这意味着我们关闭它并移除质押:

troveManagerCached.removeStake(msg.sender);
troveManagerCached.closeTrove(msg.sender);

其次,它销毁用户余额中已偿还的 LUSD 和 Gas Pool 中的Gas补偿:

_repayLUSD(activePoolCached, lusdTokenCached, msg.sender,
    debt.sub(LUSD_GAS_COMPENSATION));
_repayLUSD(activePoolCached, lusdTokenCached, gasPoolAddress,
    LUSD_GAS_COMPENSATION);

第三,它将作为抵押品添加的 ETH 发送回用户:

activePoolCached.sendETH(msg.sender, coll);

PriceFeed 和 Oracle

Liquity 函数中需要最新 ETH:USD 价格数据的功能会根据需要,通过核心 PriceFeed.sol 合约动态获取价格,该合约使用 Chainlink ETH:USD 参考合约作为其主要数据源,并使用 Tellor 的 ETH:USD 价格 feed 作为其辅助(回退)数据源。

PriceFeed 是有状态的,即它根据合约的当前状态记录可能来自这两个来源中的任何一个的最后一个有效价格。

回退逻辑区分了 Chainlink 的 3 种不同故障模式和 Tellor 的 2 种故障模式:

  • Frozen(对于两个 oracle):上次价格更新超过 4 小时前
  • Broken(对于两个 oracle):响应调用被回滚,timeStamp 无效(为 0 或在未来),或报告价格非正数 (Chainlink) 或为零 (Tellor)。如果最新轮次的响应或最新轮次之前的轮次的响应未能满足这些条件之一,则 Chainlink 被视为 Broken。
  • PriceChangeAboveMax (仅限 Chainlink):两次连续价格更新之间的偏差高于 50%

还有一个返回条件 bothOraclesLiveAndUnbrokenAndSimilarPrice,如果两个 oracle 都处于运行状态且未损坏,并且两个报告价格之间的百分比差异低于 5%,则该函数返回 true

当前的 PriceFeed.sol 合约有一个外部 fetchPrice() 函数,核心 Liquity 函数在需要当前 ETH:USD 价格时会调用该函数。fetchPrice() 调用每个 oracle 的代理,对响应进行断言,并将返回的价格转换为 18 位数字。


联系我们

在 Zealynx,我们专注于 DeFi 协议安全审计——从像 Liquity 这样的借贷系统到复杂的稳定币机制和清算逻辑。无论你是在构建基于 CDP 的协议还是 fork 现有协议,我们的团队都准备好帮助你交付安全的代码。联系我们,开始对话。

想通过更多像这样的深度分析保持领先?订阅我们的新闻通讯,确保你不会错过未来的见解。

  • 原文链接: zealynx.io/blogs/liquity...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
zealynx
zealynx
江湖只有他的大名,没有他的介绍。