Aave v3 学习

aToken 的余额自己会涨,因为 balanceOf 是乘出来的。跟着 aave-v3-core 读一遍:一个 uint256 怎么装下一个资产的全部风控参数、E-Mode 和隔离模式各自放松和收紧了什么、Portal 为什么能让 aToken 先于资产到账。

预计
9 分钟

存 1000 USDC 进 Aave,钱包里的 aUSDC 余额会自己往上爬,不需要你领取,也没有任何一笔转账把利息打给你。

这不是前端在算给你看。aTokenbalanceOf 本身就是一个乘法:合约里存的是缩放后的余额(scaled balance),读的时候乘以当前的流动性指数。存进去的那一刻,scaledBalance = 存入金额 / 当时的指数;之后指数一直涨,你的 scaledBalance 一动不动,balanceOf 就一路涨上去。

Pool 上那个 getReserveNormalizedIncome 就是给外部读这个指数用的。指数只在有人动这个资产时才被推进 —— 和 Compound v2 学习里的 borrowIndex 是同一个套路,区别只在 Aave 按秒计息、Compound 按区块。

入口合约叫 Pool。v2 那个 LendingPool 在 v3 里已经不存在了,函数名也跟着改了一轮:存款从 deposit 改成 supplydeposit 留着做兼容),加上 supplyWithPermitrepayWithATokensflashLoanSimple 这些 v3 才有的入口。

Pool 上五个动作各自在池子里做了什么:supply 收进资产再增发 aToken,withdraw 销毁 aToken 再把资产转回用户,borrow 校验抵押率后先增发债务 token、之后才把资产转给用户,repay 销毁债务 token 再收回还款资产,liquidationCall 只在健康因子低于 1 时放行 Pool 上五个动作各自在池子里做了什么:supply 收进资产再增发 aToken,withdraw 销毁 aToken 再把资产转回用户,borrow 校验抵押率后先增发债务 token、之后才把资产转给用户,repay 销毁债务 token 再收回还款资产,liquidationCall 只在健康因子低于 1 时放行

一个 uint256 装下一个资产的全部风控参数

每个资产的配置是一个结构体,里面只有一个 uint256ReserveConfiguration.sol 用一组掩码和起始位常量把它切开:

字段 装什么
LTV 0–15 抵押率上限,能借出抵押品价值的百分之多少
清算阈值 16–31 跌破这个比例就可以被清算
清算奖励 32–47 清算人拿走抵押品时的折扣
小数位 48–55 该资产的 decimals
active 56 关掉就完全停用
frozen 57 只许还款和取款,不许新存新借
borrowing enabled 58 能不能被借出
stable borrowing enabled 59 能不能用固定利率借
paused 60 全部动作暂停,含清算
borrowable in isolation 61 隔离模式下能不能被借
siloed borrowing 62 借了它就不能同时借别的
flashloan enabled 63 能不能被闪电贷
储备因子 64–79 利息里归协议的比例
借款上限 80–115 这个资产总共能被借出多少
供应上限 116–151 这个资产总共能被存入多少
清算协议费 152–167 清算奖励里再切给协议的一刀
E-Mode 分类 168–175 属于哪个高效模式分类
unbacked 铸造上限 176–211 Portal 能凭空铸出多少 aToken
债务上限 212–251 隔离模式下这个资产能撑起多少债

读一遍这张表,v3 相对 v2 加了什么就很清楚了:供应上限、借款上限、债务上限、隔离、隔离内可借、siloed、闪电贷开关、清算协议费、E-Mode 分类、unbacked 上限 —— 全是收紧风险的旋钮,而且全是按单个资产配的。

每个字段配一对 getter / setter,长得都一样 —— 掩码把该字段之外的位全置 1,取值时用 ~MASK 抠出来右移,写值时用 MASK 把原位清零再或上去:

aave-v3-core · ReserveConfiguration.sol · 供应上限的读写(节选)
function getSupplyCap(DataTypes.ReserveConfigurationMap memory self) internal pure returns (uint256) {
return (self.data & ~SUPPLY_CAP_MASK) >> SUPPLY_CAP_START_BIT_POSITION;
}
function setSupplyCap(DataTypes.ReserveConfigurationMap memory self, uint256 supplyCap) internal pure {
require(supplyCap <= MAX_VALID_SUPPLY_CAP, Errors.INVALID_SUPPLY_CAP);
self.data = (self.data & SUPPLY_CAP_MASK) | (supplyCap << SUPPLY_CAP_START_BIT_POSITION);
}

SUPPLY_CAP_START_BIT_POSITION 就是 116,MAX_VALID_SUPPLY_CAP 那道 require 挡的是写入的值超出 36 位、溢进隔壁的清算协议费字段。十九个字段十九对这样的函数,全部 internal pure,编译后内联成几条位运算。

打包成一个字是为了省 SLOAD。一次 borrow 要读发起资产和全部抵押资产的配置,如果每个参数一个存储槽,光读配置就能把这笔交易的 gas 顶上去。现在是一次读取加若干次位移。

改这些参数的合约叫 PoolConfigurator,函数是 setSupplyCapsetBorrowCapsetDebtCeilingsetSiloedBorrowingsetReserveFactorsetReserveBorrowingsetEModeCategory 这一组。注意是 setReserveBorrowing —— v2 里那个 enableBorrowingOnReserve 在 v3 里已经没有了。

债务也是代币,而且不可转让

借款人这一侧同样被代币化:借浮动利率发 variableDebtToken,借固定利率发 stableDebtToken。余额涨的方式和 aToken 一样,靠各自的指数往上乘。

这两种 debt token 不能转账。它们存在的意义不是流通,是让「谁欠了多少」变成一个可以被任何合约直接 balanceOf 查询的标准接口,顺便让钱包能显示出来。repayWithATokens 就是这个设计的一个直接好处:拿你的 aToken 直接抵掉同种资产的债,不用先取出来再还进去,省一次转账。

E-Mode 放松抵押率,前提是两个资产的价格本来就绑在一起

一个 ETH 的抵押率上限可能是 80%,但如果你拿 wstETH 抵押去借 ETH,这两个东西的价格本来就贴着走,80% 就过于保守了。

E-Mode 就是给这种情况开的口子:治理把相关性高的资产归进同一个分类,用户调 setUserEMode 进入该分类之后,这一类资产之间的抵押率和清算阈值都会显著放宽。代价是你被锁在这个分类里 —— 进了 ETH 类,就只能拿这一类的资产做抵押、借这一类的资产。

分类由 configureEModeCategory 配置,getEModeCategoryData 读出来,用户当前在哪一类用 getUserEMode 查。资产属于哪一类,就是配置字里 168–175 那 8 位。

隔离模式给新资产一个带天花板的沙盒

上一个还没经过时间检验的资产,最大的顾虑是它把坏账带给整个池子。隔离模式的做法是:被标成隔离的抵押品,用户拿它做抵押时不能同时用别的抵押品,而且只能借那些被标了「borrowable in isolation」的资产(实践中就是主流稳定币)。

真正的天花板是配置字最高那一段的债务上限(212–251 位):所有人拿这个资产撑起来的债务总额加起来不能超过它。一个新资产就算出事,损失被这个数字框住了。Pool 上还有个 resetIsolationModeTotalDebt,用来在债务清空之后把这个累计值归零。

siloed borrowing(62 位)是另一个方向的隔离:借了被标成 siloed 的资产,你就不能再借任何别的资产。前者隔离抵押品,后者隔离债务。

Portal 让 aToken 先于资产到账

跨链桥搬资产要时间,用户在目的链上得干等。Portal 的思路是让被授权的桥先把 aToken 凭空铸出来给用户,真实资产随后补上。

对应的两个函数在 Pool 上:mintUnbacked 铸出没有真实资产背书的 aToken,backUnbacked 在资产到账后把它们补实。中间这段时间,池子里存在一批「无背书」的 aToken,其规模被配置字里 176–211 位那个 unbacked 铸造上限卡着,桥还要付一笔 BRIDGE_PROTOCOL_FEE

这是全篇风险最集中的一处设计:它把「资产已经在路上」这件事的判断权交给了被授权的桥。桥出问题,铸出来的 aToken 就永远补不实,缺口由池子承担。所以这个能力从来不对普通地址开放,且有上限卡着。

奖励靠指数记账,不遍历用户

发奖励的合约在 periphery,叫 RewardsController。核心函数只有一个 handleAction(address user, uint256 totalSupply, uint256 userBalance) —— aToken 和 debt token 在余额变化时回调它,它据此把该资产的奖励指数往前推,并结算这个用户到目前为止累计的奖励。

领取走 claimRewardsclaimAllRewards,还可以用 setClaimer 授权别的地址替自己领。

奖励的两条路径落在同一个数上:余额一变就回调 handleAction、推进该资产的奖励指数,把新增的奖励累加进该用户的未领取额度;claimRewards 则先结算到最新,把这个额度清零,再按配置的策略把奖励代币转出去 奖励的两条路径落在同一个数上:余额一变就回调 handleAction、推进该资产的奖励指数,把新增的奖励累加进该用户的未领取额度;claimRewards 则先结算到最新,把这个额度清零,再按配置的策略把奖励代币转出去

又是同一个形状。Perpetual Protocol 三:ClearingHouse的资金费用、Compound 的 borrowIndex、这里的奖励指数,解的是同一个问题:链上不能遍历所有人,那就维护一个全局累加量,每个人自己记着上次结算时的读数,用的时候算差。

一个 v3 特有的细节是 configureAssetssetTransferStrategy:同一个资产可以同时挂多种奖励代币,每种代币的发放方式(直接转账还是走某个策略合约)分开配。v2 时代一个市场只能发一种激励。

这套设计的代价

风控参数多到成为一门运维。 十九个字段、每个资产一套、还要配 E-Mode 分类和隔离参数。灵活性的另一面是治理提案的复杂度,改错一个上限的后果不比代码 bug 小。

位打包省了 gas,读起来要靠常量表。 链上拿到的是一个 uint256,不查 ReserveConfiguration.sol 里那组起始位常量,你不知道第 116 位开始的那 36 位是供应上限。调试和写集成的人得随身带着这张表。

E-Mode 的放松是有条件的。 它假设同一分类里的资产价格保持相关。这个假设成立时抵押率可以很激进,一旦某个 LST 脱锚,被放宽的清算阈值就变成了放大器。E-Mode 把「相关性判断」变成了一个治理参数。

Portal 引入了池子之外的信任。 这一条前面说过,值得在结尾再说一遍:无背书的 aToken 是真的没有资产在后面,安全性取决于被授权的桥。

它不再是一份能一口气读完的代码。 v2 的 LendingPool 加几个库就差不多了;v3 把逻辑切进 SupplyLogicBorrowLogicLiquidationLogicEModeLogicBridgeLogic 这些库里,Pool 本身大多只是转发。好处是绕开了合约大小限制、也便于单独审计,代价是读源码时要在文件之间跳来跳去才能拼出一条完整的调用链。