概述

我们在上一篇文章AAVE交互指南中主要介绍了aave前端、利率计算等内容,本篇文章 将在交互指南基础上介绍aave-v3的合约源代码的相关情况。

与之前所写的深入解析Safe多签钱包智能合约系列文章不同,本文主要以我们在AAVE交互指南中进行的合约操作为主线进行分析介绍,较为实战化。

相比于其他项目,AAVE提供了一个较为完整的文档。在文档内基本涵盖了所有函数的签名及其作用,读者也可作为阅读源代码的重要参考。

AAVE的总体架构如下:

AAVE Frame

本文使用存款描述用户向流动性池内注入资产的行为,或称supplydeposit,当然在 V3 版本中,deposit已被遗弃。当然,有很多人认为此名词应翻译为质押,由于作者的写作习惯,后文统称为存款

代码准备

我们在此处仍使用Foundry作为开发和测试框架,使用以下命令初始化仓库:

forge init aave-v3 

前往AAVE Releases页面下载最新的源代码,并解压。将解压后的contracts中的文件转移到上文初始化的aave-v3仓库中的src文件夹下,最终形成如下目录结构:

.
├── foundry.toml
├── lib
│   └── forge-std
├── script
│   └── Counter.s.sol
├── src
│   ├── Counter.sol
│   ├── dependencies
│   ├── deployments
│   ├── flashloan
│   ├── interfaces
│   ├── misc
│   ├── mocks
│   └── protocol
└── test
    └── Counter.t.sol

整体逻辑

在介绍具体的合约代码前,我们首先应当明确存款行为的具体逻辑。作为金融系统,其逻辑具有相当的数学性,我们会结合具体的数学公式介绍存款的具体逻辑。与上一篇文章相比,本节给出的逻辑会更加详细且主要服务于后文代码解释,建议以本节为纲要以避免迷失在具体实现中。

本文主要参考了AAVE V2 Whitepaper,此文档给出了具体的逻辑阐述。

AAVE V3 的白皮书是建立在 V2 白皮书基础上的,所以 V3 白皮书仅介绍了与 V2 不同的部分,不足够详细。

我们引入以下参数:

  • ${LR}_t$ 当前的存款利率(currentLiquidityRate),计算方法为 ${LR}_t=\bar{R_t}{U_t}$(此公式在上一篇文章内有详细解释,读者可作为参考)

    参数含义如下:

    • $\bar{R_t}$ 为浮动存款和固定存款利率的加权平均数
    • $U_t$ 为利用率
  • ${LI}_t$ 贴现因子(liquidityIndex),计算方法为 ${LI}_t=({LR}_t{\Delta}_{year} + 1){LI}_{t-1}$

    如果有读者阅读过原文,可能发现此遍历英文名为cumulated liquidity index,但本质是贴现因子。我们会在后文讨论此参数,当然读者也可以通过各种方式了解此概念。

有读者可能发现 ${LR}_t{\Delta}_{year} + 1$ 是以线性利率的形式进行的计算,与我们上一篇文章所说明的存款利率复利计算是不符的,但为什么上一篇文章内使用复利计算的结果与和约相同? 原因在于此处的单利计算会在用户每一次进行操作时更新,高频率的单利计算与复利计算会渐趋一致

在AAVE的设计中,贴现因子的使用具有普遍性,如存款、贷款等情况下均使用了贴现因子概念,由于此文主要分析存款,所以若无特殊说明,后文的贴现因子均指存款的贴现因子

假设用户在 $t_0$ 时刻存入资产Token的数量为 $q$ ,我们在智能合约中记录的用户存入数值为

$$\frac{q}{LI_{t0}}$$

在 $t1$ 时刻,用户取出资产,获得的资产数量为

$$\frac{q}{LI_{t0}} \times LI_{t1}$$

此部分使用了金融学内简单的贴现概念,我们将所有存入资产均贴现到 $t_0$ 时期,在用户提款时将其折算回 $t_1$ 。此处使用的 $liquidityIndex$ 事实上就是 贴现因子。如果读者无法理解此部分,可简单选择任一金融学课本阅读此部分内容。

假设用户的存款数量用 ${ScB}_t(x)$ 表示,则用户存入 $m$ 单位质押品后,存款数量为:

$${ScB}_t(x) = {ScB}_{t-1}(x) + \frac{m}{{LI}_t}$$

取出 $m$ 单位质押品后,存款数量为:

$${ScB}_t(x) = {ScB}_{t-1}(x) - \frac{m}{{LI}_t}$$

总结来说,存款的核心步骤如下:

  1. 计算当前的 ${LR}_t{\Delta}_{year} + 1$
  2. 更新 ${LI}_t$
  3. 计算 ${ScB}_t(x)$

当然,上述核心步骤会在合约编程中被高度复杂化。总体来说,复杂性主要来源于以下两点:

  1. 提高用户体验
  2. 更新数据

入口函数

在此处,我们在上一篇文章内进行存款交易的EthTx 地址。如下图:

Supplt Eth Tx

非常明显,在存款交易时,我们使用了supply函数,并将USDC存入获得aEthUSDC

查阅源代码,我们可以在src/protocol/pool/Pool.sol找到此函数,代码如下:

function supply(
    address asset,
    uint256 amount,
    address onBehalfOf,
    uint16 referralCode
) public virtual override {
    SupplyLogic.executeSupply(
        _reserves,
        _reservesList,
        _usersConfig[onBehalfOf],
        DataTypes.ExecuteSupplyParams({
            asset: asset,
            amount: amount,
            onBehalfOf: onBehalfOf,
            referralCode: referralCode
        })
    );
}

此函数的各参数含义如下:

  1. asset 存入资产的合约地址
  2. amount 存入资产的数量
  3. onBehalfOf 接受aToken代币的地址
  4. referralCode 第三方集成商的标识。此参数主要用于第三方供应商检索集成交易

只有持有aToken的用户可以获得存款回报,所以一般情况下onBehalfOf仅为用户自己的地址,但用户也可以设置为其他人的地址以方便进行利益转移。

虽然此函数看似简单,但其内部调用了一些复杂函数。使用Solidity Visual Developer中的ftrace工具获得如下调用栈:

└─ Pool::supply
   ├─ SupplyLogic::executeSupply | [Ext] ❗️  🛑 
   │  ├─ DataTypes.ReserveData::cache | [Int] 🔒   
   │  ├─ DataTypes.ReserveData::updateState | [Int] 🔒  🛑 
   │  ├─ ValidationLogic::validateSupply | [Int] 🔒   
   │  ├─ DataTypes.ReserveData::updateInterestRates | [Int] 🔒  🛑 
   │  ├─ ValidationLogic::validateUseAsCollateral | [Int] 🔒   
   │  │  ├─ DataTypes.UserConfigurationMap::isUsingAsCollateralAny | [Int] 🔒   
   │  │  ├─ DataTypes.UserConfigurationMap::getIsolationModeState | [Int] 🔒   
   │  │  └─ DataTypes.ReserveConfigurationMap::getDebtCeiling | [Int] 🔒   
   │  └─ DataTypes.UserConfigurationMap::setUsingAsCollateral | [Int] 🔒  🛑 
   └─ DataTypes::ExecuteSupplyParams

在此处,我们简单给出每个函数的作用:

  1. cacheReserveData内部的数据进行缓存以降低 gas 消耗
  2. updateState 更新贴现因子(即Index系列变量)等变量和准备金余额
  3. validateSupply 校验存款限制条件
  4. updateInterestRates 更新利率
  5. validateUseAsCollateral 验证和设置抵押品

我们在后文中使用了抵押品,此名词指可以用于作为贷款担保的存款。在AAVE内,资产是否用作贷款担保由用户自己决定。

数据结构

基础数据结构

此节内容内有大量的变量被定义,读者可能无法完全理解,建议读者简单读一遍。在后文,我们会更加详细的叙述每一个变量的作用。

在此处,我们发现了一些未被定义的变量_reserves_reservesList_usersConfig,这些变量来自src/protocol/pool/PoolStorage.sol合约定义,代码如下:

// Map of reserves and their data (underlyingAssetOfReserve => reserveData)
mapping(address => DataTypes.ReserveData) internal _reserves;

// Map of users address and their configuration data (userAddress => userConfiguration)
mapping(address => DataTypes.UserConfigurationMap) internal _usersConfig;

// List of reserves as a map (reserveId => reserve).
// It is structured as a mapping for gas savings reasons, using the reserve id as index
mapping(uint256 => address) internal _reservesList;

通过注释,我们可以获得各参数的定义:

  1. _reserves 对于资产地址和该资产的存款数据ReserveData的对应关系
  2. _usersConfig 用户地址与其设置之间的对应关系
  3. _reservesList 资产id及其地址之间的对应关系,设置此映射目的是节省gas,其具体节省原理会在后文介绍

上述特殊的DataType均定义在src/protocol/libraries/types/DataTypes.sol中,为方便读者后文阅读,我们也将给出此处使用的各个特殊的数据结构的代码定义。

首先,我们给出ReserveData的代码定义:

struct ReserveData {
    //stores the reserve configuration
    ReserveConfigurationMap configuration;
    //the liquidity index. Expressed in ray
    uint128 liquidityIndex;
    //the current supply rate. Expressed in ray
    uint128 currentLiquidityRate;
    //variable borrow index. Expressed in ray
    uint128 variableBorrowIndex;
    //the current variable borrow rate. Expressed in ray
    uint128 currentVariableBorrowRate;
    //the current stable borrow rate. Expressed in ray
    uint128 currentStableBorrowRate;
    //timestamp of last update
    uint40 lastUpdateTimestamp;
    //the id of the reserve. Represents the position in the list of the active reserves
    uint16 id;
    //aToken address
    address aTokenAddress;
    //stableDebtToken address
    address stableDebtTokenAddress;
    //variableDebtToken address
    address variableDebtTokenAddress;
    //address of the interest rate strategy
    address interestRateStrategyAddress;
    //the current treasury balance, scaled
    uint128 accruedToTreasury;
    //the outstanding unbacked aTokens minted through the bridging feature
    uint128 unbacked;
    //the outstanding debt borrowed against this asset in isolation mode
    uint128 isolationModeTotalDebt;
}

我们以表格的形式依次给出各参数的含义:

变量名具体含义及用途
configuration包含有大量数据的配置项,会在后文介绍
liquidityIndex流动性池自创立到更新时间戳之间的累计利率(贴现因子)
currentLiquidityRate当前的存款利率
variableBorrowIndex浮动借款利率自流动性池建立以来的累计利率(贴现因子)
currentVariableBorrowRate当前的浮动利率
currentStableBorrowRate当前固定利率
lastUpdateTimestamp上次数据更新时间戳
id存储资产的id
aTokenAddressaToken代币地址
stableDebtTokenAddress固定利率借款代币地址
variableDebtTokenAddress浮动利率借款代币地址
interestRateStrategyAddress利率策略合约地址
accruedToTreasury当前准备金余额
unbacked通过桥接功能铸造的未偿还的无担保代币
isolationModeTotalDebt以该资产借入的未偿债务的单独模式

此处存在一系列后缀为Index的变量,这一系列变量都应用于状态更新。当用户与合约进行交互时,每次交互都是促使存款和贷款数据更新,而上述Index变量的功能就是用于存款和贷款数据更新。

有读者好奇为什么此处使用 uint128 而不是 uint256 作为数字的基本类型呢? 原因在于 AAVE 在表示浮点数时使用一种较为简单的定点浮点数的表示方法。此处的各种利率均使用了RAY表示,其具有固定的 27 位小数,使用 uint128 足够进行表示且更节省存储空间。

关于此处数学运算的相关内容,读者可阅读深入解析AAVE智能合约:计算和利率

Index系列变量实现了一个极其特殊的功能,即使用统一参数计算所有用户的质押收益或者贷款利息,此变量系列均属于贴现因子。正如上文所述,在本节内,我们所提及的贴现因子一般指存款的贴现因子。

UserConfigurationMap的定义如下:

struct UserConfigurationMap {
    uint256 data;
}

具体来看,其结构如下图:

UserConfig

此数据结构定义了用户所存入及贷出的资产。此 256 bit 数据可用于表示 128 种不同资产的存款和贷款情况,其中低位代表是否存在贷款,而高位代表是否存在抵押物。上图展示了用户持有Asset 0Asset 2的抵押物并贷出了Asset 2资产。

这是一种在AAVE中常用的数据表示方法,将数据编码为纯粹2进制后通过uint256保存。

特殊数据结构

在上文介绍ReserveData时,我们跳过其定义在最开始的configuration变量,其属于ReserveConfigurationMap数据类型

此数据类型是由多个参数压缩产生的。事实上,此数据类型底层为uint256,但为了减少数据存储的gas费用,程序员选择通过规定uint256中 256 位中不同位置的含义实现了在uint256内保存 18 个参数设置的目的,其对应表如下:

位置参数含义
0-15LTV
16-31Liquidation threshold
32-47Liquidation bonus
48-55Decimals质押代币(ERC20)精度
56reserve is active质押品可以使用
57reserve is frozen质押品冻结,不可使用
58borrowing is enabled是否可贷出
59stable rate borrowing enabled是否可以以固定利率贷出
60asset is paused资产是否被暂停
61borrowing in isolation mode is enabled资产是否可以在isolation mode内使用
62-63保留保留位以待后期扩展
64-79reserve factor储备系数,即借款利息中上缴AAVE风险准备金的比例
80-115borrow cap in whole tokens代币贷出上限
116-151supply cap in whole tokens代币存款上限
152-167liquidation protocol fee在清算过程中,AAVE收取的费用
168-175eMode categoryE-Mode 类别
176-211unbacked mint cap in whole tokens无存入直接铸造的代币数量上限(此变量用于跨链)
212-251debt ceiling for isolation mode with decimals隔离模式中此抵押品的贷出资产上限
252-255unused未使用

此表格内的部分变量的作用留空是因为我们已经在上一篇文章内对这些变量的使用进行了讨论。也可以使用下图更加清晰的展示ReserveConfigurationMap的基础数据结构:

ReserveConfigurationMap

此处以Liquidation threshold为大家介绍如何进行数据写入和读取:

为了在uint256中指定的位置读取和写入,我们首先需要一个Mask,此Mask应在 16 - 31 位处置 0 。稍加思考,我们可以得到如下Mask

uint256 internal constant LIQUIDATION_THRESHOLD_MASK = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF0000FFFF;
uint256 internal constant LIQUIDATION_THRESHOLD_START_BIT_POSITION = 16;

此处我们也定义了Liquidation thresholduint256中的起始位置。

注意 16 进制中每一个位相当于 2 进制的 4 位

写入函数如下:

function setLiquidationThreshold(
    DataTypes.ReserveConfigurationMap memory self,
    uint256 threshold
) internal pure {
    require(
        threshold <= MAX_VALID_LIQUIDATION_THRESHOLD,
        Errors.INVALID_LIQ_THRESHOLD
    );

    self.data =
        (self.data & LIQUIDATION_THRESHOLD_MASK) |
        (threshold << LIQUIDATION_THRESHOLD_START_BIT_POSITION);
}

在写入前,我首先确认待写入的threshold少于 16 位,即65535,避免写入时发生越界写入。

在具体的写入过程中,我们遵循以下流程图:

Write Bitmap

相信读者在读完上图后尽可以很好的理解合约源代码。

读取函数如下:

function getLiquidationThreshold(
    DataTypes.ReserveConfigurationMap memory self
) internal pure returns (uint256) {
    return
        (self.data & ~LIQUIDATION_THRESHOLD_MASK) >>
        LIQUIDATION_THRESHOLD_START_BIT_POSITION;
}

此处我们不再进行绘图而采用推导的方式,仍取上一个例子,我们在0x5233(0b 0101 0010 0011 0011)中读出0x23部分。步骤如下:

  1. Mask 取反(~),结果为0b 0000 1111 1111 0000
  2. 对取反后的Mask与原数据取并&,结果为0b 0000 0010 0011 0000
  3. 对取并后的结果进行向右移位,得到结果0b 0010 0011 (0x23)

上述过程即读取流程。

executeSupply 函数

本节主要介绍具体的存款逻辑,继续查看上文给出的supply函数,发现此函数核心调用了SupplyLogic.executeSupply函数,此函数功能较为复杂,我们会分成多个部分逐个介绍。

我们再次给出函数调用栈:

└─ Pool::supply
   ├─ SupplyLogic::executeSupply | [Ext] ❗️  🛑 
   │  ├─ DataTypes.ReserveData::cache | [Int] 🔒   
   │  ├─ DataTypes.ReserveData::updateState | [Int] 🔒  🛑 
   │  ├─ ValidationLogic::validateSupply | [Int] 🔒   
   │  ├─ DataTypes.ReserveData::updateInterestRates | [Int] 🔒  🛑 
   │  ├─ ValidationLogic::validateUseAsCollateral | [Int] 🔒   
   │  │  ├─ DataTypes.UserConfigurationMap::isUsingAsCollateralAny | [Int] 🔒   
   │  │  ├─ DataTypes.UserConfigurationMap::getIsolationModeState | [Int] 🔒   
   │  │  └─ DataTypes.ReserveConfigurationMap::getDebtCeiling | [Int] 🔒   
   │  └─ DataTypes.UserConfigurationMap::setUsingAsCollateral | [Int] 🔒  🛑 
   └─ DataTypes::ExecuteSupplyParams

此入口函数的定义如下:

function executeSupply(
    mapping(address => DataTypes.ReserveData) storage reservesData,
    mapping(uint256 => address) storage reservesList,
    DataTypes.UserConfigurationMap storage userConfig,
    DataTypes.ExecuteSupplyParams memory params
) external 

需要以下参数输入:

  1. reservesData 质押品相关数据 ReserveData,详细定义参考上文
  2. reservesList 质押品ID与地址的对应关系
  3. DataTypes.UserConfigurationMap 用户的借贷存储数据
  4. DataTypes.ExecuteSupplyParams 用户在supply函数内输入的参数的打包

可能有读者好奇reservesData等数据保存在 PoolStorage 合约内,为什么需要在函数调用时传入这些存储内容? 原因在于 executeSupply 位于library库合约中,其不能访问原合约内的内容,所以此处将其传入。

数据缓存

首先,executeSupply进行数据缓存操作,此步骤主要是为了将reservesData中的部分数据提取出来,主要是ReserveConfigurationMap部分。

此部分代码如下:

DataTypes.ReserveData storage reserve = reservesData[params.asset];
DataTypes.ReserveCache memory reserveCache = reserve.cache();

reservesData映射中获得指定质押物的质押信息,然后我们将此资产的质押信息进行缓存。

缓存部分使用了ReserveCache数据结构,此数据结构定义如下:

struct ReserveCache {
    uint256 currScaledVariableDebt;
    uint256 nextScaledVariableDebt;
    uint256 currPrincipalStableDebt;
    uint256 currAvgStableBorrowRate;
    uint256 currTotalStableDebt;
    uint256 nextAvgStableBorrowRate;
    uint256 nextTotalStableDebt;
    uint256 currLiquidityIndex;
    uint256 nextLiquidityIndex;
    uint256 currVariableBorrowIndex;
    uint256 nextVariableBorrowIndex;
    uint256 currLiquidityRate;
    uint256 currVariableBorrowRate;
    uint256 reserveFactor;
    ReserveConfigurationMap reserveConfiguration;
    address aTokenAddress;
    address stableDebtTokenAddress;
    address variableDebtTokenAddress;
    uint40 reserveLastUpdateTimestamp;
    uint40 stableDebtLastUpdateTimestamp;
}

此数据结构与 ReserveData 中的部分数据有直接对应关系,总结如下:

Cache with ReserveData

在后文介绍具体代码时,我们会跳过直接相等的这九种数据字段,接下来我们逐一分析每个字段的来源。

reserveCache.reserveFactor = reserveCache
    .reserveConfiguration
    .getReserveFactor();

此处的reserveFactor被称为 储备系数 。此系数规定将协议中的一部分收益分配给AAVE违约准备金,用于支持安全模块,所以波动性越低的资产,储备系数越小。

类似传统金融中的投资者保障基金提取交易手续费的操作

从代码中,我们可以看出此参数是在reserveConfiguration通过getReserveFactor提取出来的,对于此函数,我们已经在上文内讨论过类似的函数。

reserveCache.currScaledVariableDebt = reserveCache
    .nextScaledVariableDebt = IVariableDebtToken(
    reserveCache.variableDebtTokenAddress
).scaledTotalSupply();

此处定义的参数含义为:

  1. currScaledVariableDebt 当前经过贴现的可变利率贷款总额
  2. nextScaledVariableDebt 含义与上文相同,用于更新状态相关逻辑

currScaledVariableDebtnextScaledVariableDebt均被暂时定义为通过IVariableDebtToken接口调取scaledTotalSupply获得的值。我们有必要指定此值的具体来历,使用Solidity Visual Developer通过的inherbitance功能查询IVariableDebtToken的继承关系,生成下图:

Inherbit svg

注意,此图为倒置图,被继承合约在下,继承合约在上,或称子合约在上

VariableDebtToken的函数绘制相关图像(graph this)获得如下图像:

VariableDebitToken func

通过回溯代码,我们发现scaledTotalSupply被定义在src/protocol/tokenization/base/ScaledBalanceTokenBase.sol中,具体代码如下:

function scaledTotalSupply()
    public
    view
    virtual
    override
    returns (uint256)
{
    return super.totalSupply();
}

根据合约继承关系,此处的superMintableIncentivizedERC20合约。简单阅读源代码,我们发现MintableIncentivizedERC20中不包含此定义,继续回溯MintableIncentivizedERC20的父合约IncentivizedERC20,发行定义如下:

function totalSupply() public view virtual override returns (uint256) {
    return _totalSupply;
}

所以此函数仅是返回当前VariableDebt代币的总数量。

可能读者会问_totalSupply没有与贴现利率相除,似乎与我们上文给出的变量含义不符,事实上,贴现放缩步骤是在代币mint时实现的,具体请参考src/protocol/tokenization/VariableDebtToken.sol合约中的mint函数

下述代码给出了固定利率贷款的相关参数定义:

(
    reserveCache.currPrincipalStableDebt,
    reserveCache.currTotalStableDebt,
    reserveCache.currAvgStableBorrowRate,
    reserveCache.stableDebtLastUpdateTimestamp
) = IStableDebtToken(reserveCache.stableDebtTokenAddress)
    .getSupplyData();

其中各参数含义如下:

  1. currPrincipalStableDebt 当前已固定利率借入的本金
  2. currTotalStableDebt 当前以固定利率借出的总资产(即本金与利息之和)
  3. currAvgStableBorrowRate 平均固定利率
  4. stableDebtLastUpdateTimestamp 固定利率更新时间

在具体数据获取方面,我们使用了IStableDebtToken接口中的getSupplyData函数,通过搜索我们可以得到此函数定义在StableDebtToken.sol合约内,具体代码如下:

function getSupplyData()
    external
    view
    override
    returns (
        uint256,
        uint256,
        uint256,
        uint40
    )
{
    uint256 avgRate = _avgStableRate;
    return (
        super.totalSupply(),
        _calcTotalSupply(avgRate),
        avgRate,
        _totalSupplyTimestamp
    );
}

生成相关调用图,如下图:

getSupplyData func

通过调用图,我们发现getSupplyDatasuper.totalSupply()来自IStableDebtToken,显然这是一个接口其不存在具体实现,我个人认为此处是此插件的绘图错误。自己查找相关继承关系,我们发现实际上totalSupply被定义在IncentivizedERC20中,其功能与常规ERC-20合约对totalSupply的定义一致。

不同于上文给出的scaledTotalSupply变量,此处的totalSupply只是代币发行量而没有放缩

currTotalStableDebt的计算需要_calcTotalSupply函数,定义如下:

function _calcTotalSupply(uint256 avgRate) internal view returns (uint256) {
    uint256 principalSupply = super.totalSupply();

    if (principalSupply == 0) {
        return 0;
    }

    uint256 cumulatedInterest = MathUtils.calculateCompoundedInterest(
        avgRate,
        _totalSupplyTimestamp
    );

    return principalSupply.rayMul(cumulatedInterest);
}

此处将totalSupply的结果与累计利率相乘获得当前以固定利率借出的总资产的价值。

其他变量来与都较为简单,在此处不再赘述。

限于篇幅,我们在此处不讨论具体的数学计算方法的合约实现,我们会在未来讨论这一话题。

最终的缓存的数据较为简单,此种类型缓存仅为了方便后期进行数据更新,不再赘述。

reserveCache.nextTotalStableDebt = reserveCache.currTotalStableDebt;
reserveCache.nextAvgStableBorrowRate = reserveCache
    .currAvgStableBorrowRate;

数据更新

再介绍完数据缓存后,我们接下来讨论本处最为重要且核心的函数updateState,此函数的逻辑较为复杂,代码如下:

function updateState(
    DataTypes.ReserveData storage reserve,
    DataTypes.ReserveCache memory reserveCache
) internal {
    _updateIndexes(reserve, reserveCache);
    _accrueToTreasury(reserve, reserveCache);
}

具体调用栈如下:

└─ ReserveLogic::updateState
   ├─ ReserveLogic::_updateIndexes | [Int] 🔒  🛑 
     ├─ MathUtils::calculateLinearInterest | [Int] 🔒   
     ├─ cumulatedLiquidityInterest::rayMul | [Int] 🔒   
     └─ MathUtils::calculateCompoundedInterest | [Int] 🔒   
        └─ MathUtils::calculateCompoundedInterest | [Int] 🔒   : ..[Repeated Ref]..
   └─ ReserveLogic::_accrueToTreasury | [Int] 🔒  🛑 
      └─ MathUtils::calculateCompoundedInterest | [Int] 🔒   : ..[Repeated Ref]..

此函数中调用的两个其他函数的作用是:

  • _updateIndexes 更新Index系列变量
  • _accrueToTreasury 更新风险准备金

_updateIndexes

首先介绍用于更新Index(即贴现变量)的_updateIndexes函数,此函数代码如下:

reserveCache.nextLiquidityIndex = reserveCache.currLiquidityIndex;
reserveCache.nextVariableBorrowIndex = reserveCache
    .currVariableBorrowIndex;

首先初始化 nextLiquidityIndexnextVariableBorrowIndex 变量,这些变量用于计算新的 存款贴现因子 和 浮动借款贴现因子。

在上文进行cache缓存操作等行为中,我们没有对这两个变量进行初始化

复习一下 贴现因子 的计算公式,如下:

$${LI}_t=({LR}_t{\Delta}_{year} + 1){LI}_{t-1}$$

我们首先使用以下代码:

uint256 cumulatedLiquidityInterest = MathUtils
    .calculateLinearInterest(
        reserveCache.currLiquidityRate,
        reserveCache.reserveLastUpdateTimestamp
    );

计算 ${LR}_t{\Delta}_{year} + 1$ 的数值,具体的实现读者可以自行阅读calculateLinearInterest的代码实现。

通过以下代码实现完整计算:

reserveCache.nextLiquidityIndex = cumulatedLiquidityInterest.rayMul(
    reserveCache.currLiquidityIndex
);

其中,rayMul代表乘法,而currLiquidityIndex即 ${LI}_{t-1}$

当我们获得nextLiquidityIndex,需要将其从reserveCache 缓存内更新到reserve中,代码如下:

reserve.liquidityIndex = reserveCache
    .nextLiquidityIndex
    .toUint128();

其中的toUint128()定义在src/dependencies/openzeppelin/contracts/SafeCast.sol中,用于保证uint256uint128转换的安全无溢出。

更新nextVariableBorrowIndex的过程与上述过程基本类似,但有一点不同,浮动利率贴现因子严格按照复利进行计算,调用calculateCompoundedInterest函数计算复利。

由于此节专注于介绍存款流程,关于此处的贷出资产的利率和贴现因子等计算,我们不进行详细叙述,待后文介绍

最后,我们使用reserve.lastUpdateTimestamp = uint40(block.timestamp);更新了lastUpdateTimestamp时间戳。

_accrueToTreasury

首先,我们可以知道质押品的准备金率可以通过reserveFactor获得,此变量我们已经提前缓存到了reserveCache中,提取的准备金数量为当前借贷总量与风险准备金率的乘积。所以我们只需要解决计算借贷增量的问题。

此处我们只需要计算由于借贷利率导致的借贷的增量数据

此问题可以分解为以下两部分:

  1. 计算浮动利率贷款增量

    已知目前贴现后的贷款总量和前贴现因子(currVariableBorrowIndex)和现贴现因子(nextVariableBorrowIndex)。我们可以通过以下公式进行计算:

    $${VD}_{accrued} = {ScVB}_{t} \times {VI}_{t} - {ScVB}_{t-1} \times {VI}_{t-1} $$

    此公式中,${VD}_{accrued}$ 即表示浮动利率贷款增量,而 $ScVB$ 则表示浮动利率贴现因子

    具体代码如下:

    vars.prevTotalVariableDebt = reserveCache.currScaledVariableDebt.rayMul(
        reserveCache.currVariableBorrowIndex
    );
    
    vars.currTotalVariableDebt = reserveCache.currScaledVariableDebt.rayMul(
        reserveCache.nextVariableBorrowIndex
    );
    
  2. 计算固定利率贷款增量

    已知当前固定贷款总额(reserveCache.currTotalStableDebt)、 固定平均利率(currAvgStableBorrowRate)和以固定利率贷出的本金(currPrincipalStableDebt)。

    基于以上参数,我们首先计算出上一阶段的固定利率(基于reserveLastUpdateTimestamp参数和 currAvgStableBorrowRate 平均固定利率),然后使用此参数计算出上一阶段的固定利率贷款总额prevTotalStableDebt

有了上述参数计算风险准备金提取时极其简单的,代码如下:

// 债务增量计算
vars.totalDebtAccrued =
    vars.currTotalVariableDebt +
    reserveCache.currTotalStableDebt -
    vars.prevTotalVariableDebt -
    vars.prevTotalStableDebt;
// 待铸造风险准备金计算
vars.amountToMint = vars.totalDebtAccrued.percentMul(
    reserveCache.reserveFactor
);
// 更新相关数据
if (vars.amountToMint != 0) {
    reserve.accruedToTreasury += vars
        .amountToMint
        .rayDiv(reserveCache.nextLiquidityIndex)
        .toUint128();
}

校验设置

此节主要讨论validateSupply函数,此函数主要用于校验存款是否符合一系列限制参数。

首先校验存款数量是否为 0,代码如下:

require(amount != 0, Errors.INVALID_AMOUNT);

然后校验存款池是否被启用、是否处于暂停或冻结状态,代码如下:

(bool isActive, bool isFrozen, , , bool isPaused) = reserveCache
    .reserveConfiguration
    .getFlags();
require(isActive, Errors.RESERVE_INACTIVE);
require(!isPaused, Errors.RESERVE_PAUSED);
require(!isFrozen, Errors.RESERVE_FROZEN);

此处使用了reserveConfiguration中的getFlags()函数,此函数被定义在src/protocol/libraries/configuration/ReserveConfiguration.sol中,主要基于位操作提取相应的参数

最后判断用户存款后市是否超过存款限额,代码如下:

uint256 supplyCap = reserveCache.reserveConfiguration.getSupplyCap();
require(
    supplyCap == 0 ||
        (IAToken(reserveCache.aTokenAddress).scaledTotalSupply().rayMul(
            reserveCache.nextLiquidityIndex
        ) + amount) <=
        supplyCap *
            (10**reserveCache.reserveConfiguration.getDecimals()),
    Errors.SUPPLY_CAP_EXCEEDED
);

此处涉及到 ERC20 代币的精度问题,这也是一种浮点数。如 USDT 的精度为 6, 则表示 1 USDT 在合约内使用 1e6 此数值表示,也意味着 USDT 精度最多为 6 位小数

更新利率

对于利率的更新主要集中在updateInterestRates函数内,但此函数将核心的利率计算分配给了interestRateStrategyAddress合约。此合约内包含一系列利率计算参数和具体的逻辑函数。

在具体计算利率时,我们需要以下参数:

struct CalculateInterestRatesParams {
    uint256 unbacked;
    uint256 liquidityAdded;
    uint256 liquidityTaken;
    uint256 totalStableDebt;
    uint256 totalVariableDebt;
    uint256 averageStableBorrowRate;
    uint256 reserveFactor;
    address reserve;
    address aToken;
}

这些参数的含义为:

  • unbacked 无存入直接铸造的代币数量上限(此变量用于跨链)
  • liquidityAdded 流动性增加量,在此处为存入资产的数量
  • liquidityTaken 流动性移除量,此变量用于贷出资产的情况,故而此值在存入流程内置为 0
  • totalStableDebttotalVariableDebt 固定利率总贷出量和浮动利率总贷出量
  • averageStableBorrowRate 平均贷款固定利率
  • reserveFactor 准备金比率
  • aToken aToken地址

可能有读者好奇为什么会出现无存入入直接铸造代币的情况? 此情况发生在跨链时,用户可能在另一区块链内存入了资产而在当前区块链铸造代币的情况

我们可以在reserveCachereserve中找到此处使用的大部分变量,具体的构造代码如下:

(
    vars.nextLiquidityRate,
    vars.nextStableRate,
    vars.nextVariableRate
) = IReserveInterestRateStrategy(reserve.interestRateStrategyAddress)
    .calculateInterestRates(
        DataTypes.CalculateInterestRatesParams({
            unbacked: reserveCache
                .reserveConfiguration
                .getUnbackedMintCap() != 0
                ? reserve.unbacked
                : 0,
            liquidityAdded: liquidityAdded,
            liquidityTaken: liquidityTaken,
            totalStableDebt: reserveCache.nextTotalStableDebt,
            totalVariableDebt: vars.totalVariableDebt,
            averageStableBorrowRate: reserveCache
                .nextAvgStableBorrowRate,
            reserveFactor: reserveCache.reserveFactor,
            reserve: reserveAddress,
            aToken: reserveCache.aTokenAddress
        })
    );

为减少本文篇幅,我们将具体的利率计算放在未来介绍。

在完成具体的利率计算后,我们将这些数据写入reserve,代码如下:

reserve.currentLiquidityRate = vars.nextLiquidityRate.toUint128();
reserve.currentStableBorrowRate = vars.nextStableRate.toUint128();
reserve.currentVariableBorrowRate = vars.nextVariableRate.toUint128();

转移资产

当用户存入资产时,用户资产会被转移到流动性池内,此步骤通过一些函数完成:

IERC20(params.asset).safeTransferFrom(
    msg.sender,
    reserveCache.aTokenAddress,
    params.amount
);

直接调用ERC20代币都实现的safeTransferFrom功能,将代币转移。

注意用户在存款前已经进行了approve授权操作,所以此处可以进行直接划转资产。

此次的safeTransferFrom被定义在src/dependencies/gnosis/contracts/GPv2SafeERC20.sol中,主要是用于兼容不同ERC20实现,一般来说,ERC20在转账失败后,有以下两者操作:

  1. 使用revert回退交易和抛出异常
  2. 返回False代表交易错误

具体代码如下:

function safeTransferFrom(
    IERC20 token,
    address from,
    address to,
    uint256 value
) internal {
    bytes4 selector_ = token.transferFrom.selector;

    // solhint-disable-next-line no-inline-assembly
    assembly {
        let freeMemoryPointer := mload(0x40)
        mstore(freeMemoryPointer, selector_)
        mstore(
            add(freeMemoryPointer, 4),
            and(from, 0xffffffffffffffffffffffffffffffffffffffff)
        )
        mstore(
            add(freeMemoryPointer, 36),
            and(to, 0xffffffffffffffffffffffffffffffffffffffff)
        )
        mstore(add(freeMemoryPointer, 68), value)

        if iszero(call(gas(), token, 0, freeMemoryPointer, 100, 0, 0)) {
            returndatacopy(0, 0, returndatasize())
            revert(0, returndatasize())
        }
    }

    require(getLastTransferResult(token), "GPv2: failed transferFrom");
}

总体流程大致为:

  1. 获取空闲内存地址
  2. 构造请求calldata
    1. 写入transferFrom选择器(4 bytes)
    2. 写入from变量,此变量属于address类型,应占用 20 bytes(使用and运算保证长度),但注意mstore一次写入 32 bytes,所以此处写入了32 bytes 的数据
    3. 写入to变量,首先计算起始内存位置,4 bytes 选择器加上上步写入的 32 bytes 的地址数据,所以此处起始位置应为 36 bytes
    4. 写入value变量
  3. 使用call操作符发送请求
  4. 判断call操作是否为0,如果为0,则意味着调用失败,进行returndata拷贝和revert回退操作

revert含义为回退当前的call调用,恢复状态变量,并返回堆栈信息。值得注意的是,returndata区域内的数据并不会被回退。

通过以上操作,我们只能保证调用的准确性,而没有对某些通过返回值表示错误的合约的进行兼容,所以此处又使用了getLastTransferResult进行判断。此函数较为简单,功能为审核以下两种返回情况:

  1. 返回值为空时,并进一步判断合约地址确实是合约地址时,我们认为调用正确
  2. 返沪值包含数据时,我们需要判断数据是否为True,如果为True,则认为调用正确

如果不属于以上情况,我们通过以下函数:

function revertWithMessage(length, message) {
    mstore(0x00, "\x08\xc3\x79\xa0")
    mstore(0x04, 0x20)
    mstore(0x24, length)
    mstore(0x44, message)
    revert(0x00, 0x64)
}

返回报错

由以上代码,我们可以充分认识到ERC20合约的多样性。建议读者使用一些较为通用的实现方案而不是自己造轮子

如果您无法理解上述内容,建议读者阅读EVM底层探索:字节码级分析最小化代理标准EIP1167系列文章。在这些文章内,我较为详细的讨论了字节码问题。当然,我之前的文章基本均涉及到yul底层编程,读者可以按照我的写作的时间顺序逐个阅读

存款代币铸造

在完成存款资产转移后,合约需要铸造对应的ATokens,代码如下:

bool isFirstSupply = IAToken(reserveCache.aTokenAddress).mint(
    msg.sender,
    params.onBehalfOf,
    params.amount,
    reserveCache.nextLiquidityIndex
);

此处调用了ATokenmint方法,此方法定义如下:

function mint(
    address caller,
    address onBehalfOf,
    uint256 amount,
    uint256 index
) external virtual override onlyPool returns (bool) {
    return _mintScaled(caller, onBehalfOf, amount, index);
}

进一步查找_mintScaled方法定义如下:

function _mintScaled(
    address caller,
    address onBehalfOf,
    uint256 amount,
    uint256 index
) internal returns (bool) {
    uint256 amountScaled = amount.rayDiv(index);
    require(amountScaled != 0, Errors.INVALID_MINT_AMOUNT);

    uint256 scaledBalance = super.balanceOf(onBehalfOf);
    uint256 balanceIncrease = scaledBalance.rayMul(index) -
        scaledBalance.rayMul(_userState[onBehalfOf].additionalData);

    _userState[onBehalfOf].additionalData = index.toUint128();

    _mint(onBehalfOf, amountScaled.toUint128());

    uint256 amountToMint = amount + balanceIncrease;
    emit Transfer(address(0), onBehalfOf, amountToMint);
    emit Mint(caller, onBehalfOf, amountToMint, balanceIncrease, index);

    return (scaledBalance == 0);
}

上述代码的运行流程如下:

  1. 使用amount.rayDiv(index)计算应铸造的AToken数量并判断铸造数量是否为0
  2. 使用super.balanceOf(onBehalfOf)获得用户当前持有的代币数量
  3. 计算用户代币利息(_userState[onBehalfOf].additionalData为上一次的贴现因子)
  4. 更新_userState[onBehalfOf].additionalData为当前贴现因子
  5. 铸造代币_mint(onBehalfOf, amountScaled.toUint128());
  6. 计算代币铸造量uint256 amountToMint = amount + balanceIncrease;
  7. 释放事件
  8. 返回账户是否为从零开始铸造代币

上述流程在真正的计算流程中,我们使用以折现后的代币数量进行计算,但为了抛出相关事件,我们有将折现后的代币数量与贴现因子相乘。这可能是为了一般用户方便查询自己当前的代币数量。但如此操作也增加了合约的复杂性,显然,在合约复杂性和用户体验方面,AAVE 开发者选择了用户体验

更新抵押品

此部分主要处理用户质押品情况,正如上一篇文章所述,在AAVE内的存款可以作为贷款抵押品存在也可以单纯作为存款操作,本部分主要处理此情况。

大部分情况下,用户资产是否作为贷款质押品都由用户自己决定,本部分代码仅针对用一些特殊情况。在这些特殊情况下,用户第一笔存入的资产会被默认为贷款抵押资产,启动资产抵押选项。

上述功能可通过运行以下代码实现:

if (isFirstSupply) {
    if (
        ValidationLogic.validateUseAsCollateral(
            reservesData,
            reservesList,
            userConfig,
            reserveCache.reserveConfiguration
        )
    ) {
        userConfig.setUsingAsCollateral(reserve.id, true);
        emit ReserveUsedAsCollateralEnabled(
            params.asset,
            params.onBehalfOf
        );
    }
}

其中userConfig.setUsingAsCollateral(reserve.id, true);启动用户当前资产的抵押选项。

至于包含那些特殊情况,我们需要研究validateUseAsCollateral函数,此函数定义如下:

function validateUseAsCollateral(
    mapping(address => DataTypes.ReserveData) storage reservesData,
    mapping(uint256 => address) storage reservesList,
    DataTypes.UserConfigurationMap storage userConfig,
    DataTypes.ReserveConfigurationMap memory reserveConfig
) internal view returns (bool) {
    if (!userConfig.isUsingAsCollateralAny()) {
        return true;
    }
    (bool isolationModeActive, , ) = userConfig.getIsolationModeState(
        reservesData,
        reservesList
    );

    return (!isolationModeActive && reserveConfig.getDebtCeiling() == 0);
}

根据代码,我们可以得出特殊情况包含的具体范围:

  1. 用户当前状态下不存在任何一种抵押品资产,此判断通过isUsingAsCollateralAny实现。
  2. 用户未启动了isolation mode且当前资产未被纳入isolation mode

出现上述情况之一,用户的存款会自动启用质押选项。下图展示了在无DAI存款情况下,进行存款自动启用质押选项的AAVE对话框:

auto AAVE

显然这也是为了增加用户体验增加代码复杂度的又一案例

supplyWithPermit

在上文中,我们深挖了在Pool合约内的supply函数,这也是大部分用户存款时使用的函数,但事实上,AAVE也提供了另一个使用体验更好的函数supplyWithPermit。此函数的核心在于Permit,笔者在之前的文章内讨论过此概念,如果读者不了解此概念,请阅读EIP712的扩展使用,我们在本文的最后讨论了Permit这一函数。

在此处,我们给出supplyWithPermit的代码:

function supplyWithPermit(
    address asset,
    uint256 amount,
    address onBehalfOf,
    uint16 referralCode,
    uint256 deadline,
    uint8 permitV,
    bytes32 permitR,
    bytes32 permitS
) public virtual override {
    IERC20WithPermit(asset).permit(
        msg.sender,
        address(this),
        amount,
        deadline,
        permitV,
        permitR,
        permitS
    );
    SupplyLogic.executeSupply(
        _reserves,
        _reservesList,
        _usersConfig[onBehalfOf],
        DataTypes.ExecuteSupplyParams({
            asset: asset,
            amount: amount,
            onBehalfOf: onBehalfOf,
            referralCode: referralCode
        })
    );
}

与正常的supply函数相比,此函数增加了IERC20WithPermit(asset).permit部分。此部分为supply增加了特殊的功能,即调用者不需要在使用此函数前进行approve授权操作,该授权操作隐含在EIP712签名中。如果读者无法理解此内容,请阅读基于链下链上双视角深入解析以太坊签名与验证EIP712的扩展使用

总结

终于我们完成了对于AAVE存款部分的描述,可见相对于Safe等功能性合约,AAVE作为DeFi合约充分体现了其复杂性。本文是对AAVE V3版本存款的简单描述,由于篇幅和主题限制,本文对于部分函数的深挖不足,读者可根据自身需求在本文基础上继续深挖部分函数。