本文系统梳理了 Yearn Finance 从 V2 到 V3 的金库架构演进与安全教训:V2 通过多策略债务隔离、锁定利润线性释放、内部余额跟踪来抵御抢跑与捐赠攻击;V3 进一步拆分为 ERC-4626 原生 Vault、外部 Accountant/Debt Allocator 以及共享的 TokenizedStrategy 代理,实现更模块化的策略执行。
Yearn Finance 已悄然成为 DeFi 中研究最多的金库架构之一。目前两个活跃版本并存:V2(Vyper Vault.vy 配合 Solidity BaseStrategy 框架)和 V3(Vyper VaultV3.vy,加上位于 0xBB51273D6c746910C7C06fe718f30c936170feD0 的全链 Solidity TokenizedStrategy 代理)。
Yearn 之所以是一个值得研究的案例,不仅在于代码本身,更在于其历史记录。Yearn 历史上每一次被确认的损失,都可以追溯到旧版 v1 合约、对外部协议预言机的依赖,或者集成方将 pricePerShare() 用作抵押品估值。V2 和 V3 的核心合约至今都未遭受过直接利用。
这篇文章是一份内容密集、以审计为导向的参考资料,适合审查基于 Yearn 的协议,或设计类似金库系统的团队阅读。

Vault.vy 是一个单体 Vyper 合约(implements: ERC20,而非 ERC-4626)。其核心不变量位于一个简短的常量块中:
MAXIMUM_STRATEGIES = 20
DEGRADATION_COEFFICIENT = 1e18
MAX_BPS = 10_000
SECS_PER_YEAR = 31_556_952
StrategyParams 为每个注册策略保存九个字段:performanceFee、activation、debtRatio、minDebtPerHarvest、maxDebtPerHarvest、lastReport、totalDebt、totalGain、totalLoss。固定长度的 withdrawalQueue: address[20] 会在每次 withdraw 调用时被遍历,且不支持按单次调用覆盖。
份额价值基于自由资金计算:
1_shareValue(shares) = shares * (totalAssets - _calculateLockedProfit()) / totalSupply
_calculateLockedProfit() 会按 lockedProfitDegradation(默认约 6 小时)线性释放上一次报告的收益。在一次盈利的 report() 之后、仅隔一个区块就进入的存款人,无法获取这部分被锁定的利润。当利润刚刚产生时,这一存款不变量被刻意设计为对存款人不利。
report(gain, loss, _debtPayment) 是策略 → 金库的会计入口。顺序非常重要:
strategy.debtRatio 和 vault.debtRatioself.lockedProfit = gain - totalFees 替换任何残余的锁定利润creditAvailable 通过 min(strategy_debtLimit - strategy_totalDebt, vault_debtLimit - vault_totalDebt, totalIdle) 限制资金流,并受 minDebtPerHarvest 与 maxDebtPerHarvest 约束。
withdraw(maxShares, recipient, maxLoss=1) 会遍历 withdrawalQueue,遇到 ZERO_ADDRESS(队列终止符)时停止,并对每个策略调用 Strategy.withdraw(amountNeeded)。关键点在于默认的 maxLoss = 1 bp(0.01%):任何存在未实现或接近实现损失且损失幅度 ≥ 0.01% 的策略,都会导致 assert totalLoss <= maxLoss * (value + totalLoss) / MAX_BPS 回滚。
Code4rena 的 Popcorn 竞赛(发现 #581)将其记录为:在 Aave 坏账事件期间,它会对集成方造成一种静默拒绝服务。如果你正在基于 Yearn 构建系统,这是首先要审计的内容。

VaultV3.vy(Vyper 0.3.7,API_VERSION = "3.0.4")是一个原生 ERC-4626 分配器,其职责范围大幅缩小。StrategyParams 仅包含四个字段——activation、last_report、current_debt、max_debt——因为费用位于外部 Accountant 中,债务分配位于外部 Debt Allocator 中,而每个策略的性能逻辑则位于各自的 Tokenized Strategy 内部。
total_idle 由手动维护(“取代 balanceOf(this) 以避免 price_per_share 操纵”)——这是一个明确的反捐赠攻击不变量。
V3 用 14 位的 Vyper enum Flag 取代了 V2 的五地址治理,涵盖 ADD_STRATEGY_MANAGER、DEBT_MANAGER、REPORTING_MANAGER、EMERGENCY_MANAGER 和 PROFIT_UNLOCK_MANAGER 等角色。角色管理 API 暴露了 set_role、add_role、remove_role,以及可将单个角色设为无权限门槛开放状态的 set_open_role(role)。
yAudit 在 2023 年 6 月的报告中明确警告:“一个错误,例如开启一个本应保持关闭的角色,可能会对整个金库造成不可逆且破坏性的影响。” 在实践中,只有 REPORTING_MANAGER 可以算是一个有合理性的开放候选角色。
V3 用份额层级的会计方式取代了 V2 的锁定利润标量。在 process_report 中,金库向自身铸造 shares_to_lock = convertToShares(gain + refunds - fees),并通过以下方式逐步释放:
1_unlocked_shares() = profit_unlocking_rate * (block.timestamp - last_profit_update) / MAX_BPS_EXTENDED
_total_supply() = total_supply - _unlocked_shares(),因此 PPS 会在解锁窗口内单调上升。损失会先销毁被锁定的份额,之后才会影响已实现的 PPS。
这是审计人员必须真正理解的 V3 创新。一个代币化策略本身就是一个完全符合 ERC-4626 的金库,由策略师直接部署,并将所有标准基础逻辑委托给一个单一、不可变的 TokenizedStrategy 实现。该实现部署在每条链上相同的确定性 CREATE2 地址(0xBB51273D6c746910C7C06fe718f30c936170feD0)。
其机制是:BaseStrategy.fallback() delegatecall 到该地址。存储通过 bytes32(uint256(keccak256("yearn.base.strategy.storage")) - 1) 进行命名空间隔离——这是一种类似 ERC-7201 的模式。
策略师唯一需要做的,就是实现三个回调:
可选项包括:_tend、_tendTrigger、availableDepositLimit、availableWithdrawLimit、_emergencyWithdraw。
来自 storm0x 的“V3 Strategies GOTCHAS”:
StrategyData行动项:对每个策略运行选择器冲突检查器,并 grep 搜索带有保留存储槽常量的 sstore。

V2 在 Vault 层面的重入由 Vyper 的键控 @nonreentrant 锁阻止;V3 则在 TokenizedStrategy 的 StrategyData 中使用单字节 entered 标志。现实中的攻击面在于跨合约重入:如果策略师在 _deployFunds / _freeFunds 中覆写逻辑并调用另一个代币化策略,就可以绕过按插槽隔离的保护,因为每个策略都有自己的 entered 标志。
舍入和双重舍入是最常见的审计发现类别。yAudit 的审查曾发现 mint() 中的双重舍入、_redeem 中损失份额的 off-by-one 错误,以及 withdraw() 中的边界舍入问题。反复出现的教训是:错误的 estimatedTotalAssets / _harvestAndReport,是静默损失的首要根源。
V2 的早期版本在 totalAssets() 中读取 token.balanceOf(self),使得捐赠可以推高 PPS——这一问题从 API 0.4.4 起通过内部 totalIdle 跟踪得到缓解。V3 将这一思路扩展到了 Vault 和每个 TokenizedStrategy。
关键在于,V3 不使用 OpenZeppelin 的虚拟份额与精度偏移方案,也不会销毁死份额。其防御是操作层面的:部署流程会在初始化时将 deposit_limit = 0,由治理先注入一笔不可忽略的首笔存款,然后再通过一笔批处理多签交易开放存款。
无权限的工厂金库会打破这一假设——任何人都可以部署一个空金库,而第一个毫无防备的存款人就会暴露在经典的“1 wei 存款 + 捐赠”偏斜攻击之下。
Yearn 的核心合约并不强制使用 TWAP;预言机如何选择,完全由策略作者自行决定。最危险的模式是在 _harvestAndReport 中,对那些在 remove_liquidity 期间会转移原生 ETH 或 ERC-777 Token 的池,使用 ICurvePool.get_virtual_price() 或 ICurvePool.get_dy(i, j, dx)。
ChainSecurity 在 2022 年 4 月的披露证实,Curve 池的状态在 ETH 回调内部会暂时处于不一致状态。Yearn 的缓解模式是:在读取 get_virtual_price 之前,立即通过 pool.remove_liquidity(0, [0,0]) 进行一次重入“poke”。可复用的经验法则是:永远不要从任何在回调期间可读取实时池状态、且返回值依赖该状态的函数中推导 totalAssets。
Yearn 的主多签 ychad.eth 是一个 6-of-9 的 Gnosis Safe。在 Governance 2.0 下,权力分散在各个 yTeams 之间。Yearn 没有在多签与核心金库参数之间设置链上 Timelock——策略添加、债务比例调整以及注册背书,都会在多签批准后原子执行。
这是审计人员和集成方应当明确指出的信任假设。最灾难性的路径是:攻破六个签名者 → addStrategy(evilStrategy, 10000, 0, MAX, 0) → 对现有策略执行 harvest() → evilStrategy._deployFunds 外流资金。V3 要求 ADD_STRATEGY_MANAGER 与 MAX_DEBT_MANAGER 共同作用,这缓解了单一角色被攻破的风险,但无法防御整个多签被接管。
yDAI v1 上的 StrategyDAICurve 将资金存入 Curve 3pool。用一句话概括根本原因:v1 的公开可调用 earn() 会以 1% 滑点限制和 0% 提现费将金库余额投入 Curve,因此由闪电贷驱动的 3pool 失衡可以对存款路径形成夹击。
攻击流程:从 dYdX 闪电贷约 116,000 ETH → 使 Curve 3pool 失衡 → 调用 yDAI 的 earn() → 反转失衡 → 赎回 yDAI 份额。共执行了五轮。Yearn 多签在约 11 分钟内叫停操作,从 3500 万 DAI 中保住了 2400 万。
架构上的回应是:V2 的整个设计——非公开 harvest、多策略隔离、锁定利润衰减、totalIdle 空投保护、emergencyShutdown——都是这次事后复盘的制度化结果。
Cream 使用 yUSDVault.pricePerShare() 为 yUSD 抵押品定价,而这是一个可被捐赠操纵的基于份额的预言机。攻击者向 yUSD 金库捐赠了底层 crvY LP(在不增加 totalSupply 的情况下提高 totalAssets),使 PPS 大致翻倍。Cream 随即将该抵押品高估,攻击者据此借出了约 1.3 亿美元。
Yearn 损失为 0;yUSD 金库本身按设计运行,但被另一个协议当作可组合预言机使用,且没有防捐赠能力。给集成方的教训是:如果没有 TWAP、上下限约束或防捐赠机制,永远不要把 pricePerShare 当作预言机。
yUSDT v1 部署于 2020 年,原本应配置 iUSDT,却错误地填入了 Fulcrum iUSDC Token 地址。由于该金库 TVL 极低,这个问题潜伏了约 3 年。攻击者利用闪电贷资金与旧版 iUSDC 交互,抬高了金库的内部会计值。一笔很小的 USDT 存款就产生了不成比例的 yUSDT 份额。
V2 金库未受影响。教训是:如果已弃用合约没有被清空并暂停,它们就会无限期地保留为攻击面;而配置错误可能沉睡多年才暴露。
performanceFee ≤ MAX_BPS/2 = 5000,sum(strategy.debtRatio) ≤ MAX_BPS,默认 lockedProfitDegradation ≈ 6 小时解锁MAX_FEE = 5000,profitMaxUnlockTime ≤ SECONDS_PER_YEAR,默认 10 天minimum_total_idle 下限,可插拔的 IDepositLimitModule / IWithdrawLimitModule| 函数 | 默认 maxLoss | 行为 |
|---|---|---|
V2 Vault.withdraw |
1 bp | 超出则回滚 |
V3 Vault.withdraw |
0 bps | 任意损失都会回滚 |
V3 Vault.redeem |
MAX_BPS = 10_000 |
静默接受任何损失 |
如果集成方在调用 redeem() 时使用默认值,可能会在不回滚的情况下承受 100% 的损失——这是金库套金库(vaults-of-vaults)包装器中的一类静默损失漏洞。
V2 的 setEmergencyShutdown(bool) 可由治理在两个方向上调用,而 guardian 只能将其设为 true。V3 的 shutdown_vault() 仅限 EMERGENCY_MANAGER 调用,且不可逆——若再结合开放角色机制,被攻破的 EMERGENCY_MANAGER 将永久锁死写操作。
Yearn 的策略风险评分在八个维度上采用 1–5 级:审计覆盖、代码审查频率、复杂度、协议安全性、团队认知、测试评分、TVL 影响和存续时间。高风险策略在证明其生产环境安全性之前,会被限制在较低的债务比例上限。该框架明确承认,由于部署节奏较快,Yearn “无法依赖传统瀑布式流程”——而风险评分正是对应的补偿性控制。
Yearn V3 位于收益聚合器设计光谱上的一个特定位置:最大化链上灵活性 + 内部余额反捐赠 + 不可变策略 + 可选时间锁。
allowedRebalanceDeviation 和 shareLockPeriod 保护架构对应关系:Yearn 的 DEBT_MANAGER ≈ Morpho 的 Allocator ≈ Sommelier 的 Strategist;Yearn 的 MAX_DEBT_MANAGER ≈ Morpho 的 Curator;EMERGENCY_MANAGER ≈ Morpho 的 Guardian / Sentinel。
Yearn 多年来的防御演进,是收益聚合器安全思路如何成熟的一份清晰记录。V1 那种采用现货 DEX 定价、单体且公开 earn() 的合约,被 V2 的多策略债务分配模型取代;随后 V3 又将剩余逻辑拆解为一个不可变、共享的 Solidity TokenizedStrategy 实现,以及若干外围模块。
给审计人员和设计者的具体经验法则如下:
totalAssetspricePerShare 用作预言机sstoreREPORTING_MANAGER 外任何角色上的 set_open_role 都视为灾难性操作maxLoss 默认值V3 架构将这些教训制度化了,但最薄弱的环节也随之转移:要么出现在作者的 _harvestAndReport 读取了可操纵价格的地方,要么出现在集成方不加限定地读取 PPS 的地方。
- 原文链接: zealynx.io/blogs/yearn-v...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!