Aave v3 学习
aToken 的余额自己会涨,因为 balanceOf 是乘出来的。跟着 aave-v3-core 读一遍:一个 uint256 怎么装下一个资产的全部风控参数、E-Mode 和隔离模式各自放松和收紧了什么、Portal 为什么能让 aToken 先于资产到账。
存 1000 USDC 进 Aave,钱包里的 aUSDC 余额会自己往上爬,不需要你领取,也没有任何一笔转账把利息打给你。
这不是前端在算给你看。aToken 的 balanceOf 本身就是一个乘法:合约里存的是缩放后的余额(scaled balance),读的时候乘以当前的流动性指数。存进去的那一刻,scaledBalance = 存入金额 / 当时的指数;之后指数一直涨,你的 scaledBalance 一动不动,balanceOf 就一路涨上去。
Pool 上那个 getReserveNormalizedIncome 就是给外部读这个指数用的。指数只在有人动这个资产时才被推进 —— 和 Compound v2 学习里的 borrowIndex 是同一个套路,区别只在 Aave 按秒计息、Compound 按区块。
入口合约叫 Pool。v2 那个 LendingPool 在 v3 里已经不存在了,函数名也跟着改了一轮:存款从 deposit 改成 supply(deposit 留着做兼容),加上 supplyWithPermit、repayWithATokens、flashLoanSimple 这些 v3 才有的入口。
一个 uint256 装下一个资产的全部风控参数
每个资产的配置是一个结构体,里面只有一个 uint256。ReserveConfiguration.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 把原位清零再或上去:
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,函数是 setSupplyCap、setBorrowCap、setDebtCeiling、setSiloedBorrowing、setReserveFactor、setReserveBorrowing、setEModeCategory 这一组。注意是 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 在余额变化时回调它,它据此把该资产的奖励指数往前推,并结算这个用户到目前为止累计的奖励。
领取走 claimRewards 或 claimAllRewards,还可以用 setClaimer 授权别的地址替自己领。
又是同一个形状。Perpetual Protocol 三:ClearingHouse的资金费用、Compound 的 borrowIndex、这里的奖励指数,解的是同一个问题:链上不能遍历所有人,那就维护一个全局累加量,每个人自己记着上次结算时的读数,用的时候算差。
一个 v3 特有的细节是 configureAssets 和 setTransferStrategy:同一个资产可以同时挂多种奖励代币,每种代币的发放方式(直接转账还是走某个策略合约)分开配。v2 时代一个市场只能发一种激励。
这套设计的代价
风控参数多到成为一门运维。 十九个字段、每个资产一套、还要配 E-Mode 分类和隔离参数。灵活性的另一面是治理提案的复杂度,改错一个上限的后果不比代码 bug 小。
位打包省了 gas,读起来要靠常量表。 链上拿到的是一个 uint256,不查 ReserveConfiguration.sol 里那组起始位常量,你不知道第 116 位开始的那 36 位是供应上限。调试和写集成的人得随身带着这张表。
E-Mode 的放松是有条件的。 它假设同一分类里的资产价格保持相关。这个假设成立时抵押率可以很激进,一旦某个 LST 脱锚,被放宽的清算阈值就变成了放大器。E-Mode 把「相关性判断」变成了一个治理参数。
Portal 引入了池子之外的信任。 这一条前面说过,值得在结尾再说一遍:无背书的 aToken 是真的没有资产在后面,安全性取决于被授权的桥。
它不再是一份能一口气读完的代码。 v2 的 LendingPool 加几个库就差不多了;v3 把逻辑切进 SupplyLogic、BorrowLogic、LiquidationLogic、EModeLogic、BridgeLogic 这些库里,Pool 本身大多只是转发。好处是绕开了合约大小限制、也便于单独审计,代价是读源码时要在文件之间跳来跳去才能拼出一条完整的调用链。