全面解析 ERC-4626 与 DeFi

滑点是指交易的预期价格与实际执行价格之间的差异。当下单交易与执行交易之间存在延迟,交易的资产价格发生变化时,滑点就会出现。

ERC-4626 作为一个出现 2 年多的以太坊改进方案,被人们讨论的似乎并不多,远不如 ERC-20、ERC-721 等,原因在于它的应用场景相对来说不是很广泛,但笔者还是比较推荐学习它,理由如下:

  1. 它是 OpenZeppelin 为数不多有着完整代码实现的 ERC 之一,同时也是被以太坊官方列入的 token stanards(目前共有 5 个,另外几个分别是 ERC-20、ERC-721、ERC-777、ERC-1155) 之一,对于合约开发者和审计人员来说是非常重要的。
  2. 它是一个非常适合新手学习 DeFi 的 ERC,其 input token/output shares 的设计理念,在 liquidity protocol、staking 等 DeFi 特性中都有广泛的应用。
  3. 金库与 DeFi 借贷、质押业务联系是非常紧密的。

下面我们从提案内容、合约实现、应用生态、安全等几个方面来全面解析 ERC-4626。

DeFi

# 01什么是 ERC-4626

ERC-4626 是一个使用单一基础 ERC-20 的代币化金库。

Tokenized Vaults with a single underlying EIP-20 token.

首先,它是一个基于 ERC-20 的提案,并与之完全兼容。

其次,理解金库(vault)的概念,它不是国库(treasury)。现在市面上的国库基本上就是一个合约钱包,大多以 Gnosis Safe 为主,主要提供安全的资金出入功能。但是对于一个组织来说,除了资金出入外,还可以让资金流动产生收益。

该提案产生的动机:代币化的金库缺乏标准,导致市场上的很多金库实现细节不一样,比如借贷市场、聚合器、生息代币等。这使得在协议层面的聚合器和插件集成工作变得困难,容易出错和浪费开发资源。

该提案当前状态:Final,意味着是相对比较稳定的标准了。

# 02

规范

遵循 ERC-4626 的代币必须完全实现 ERC-20,用来表示份额(shares)。下面是几个简单的概念。

  • 资产(asset):由金库管理的基础代币(the underlying token),遵循 ERC-20 标准。
  • 份额(share):金库代币,也可以称为 vToken。它跟 asset 有一个比例关系。
  • 费用(fee):在资产或份额发生变化时,金库收取的一个金额。可以是 存款、收益、资产管理、取款等。
  • 滑点(slippage):份额存款和取款的公布价格和实际经济差。下面是关于 DeFi 领域滑点概念的更多解读。

滑点是指交易的预期价格与实际执行价格之间的差异。当下单交易与执行交易之间存在延迟,交易的资产价格发生变化时,滑点就会出现。

例如,你在 AMM 池中发现有 20 个 ETH 和 80 个 USDT,那么你预期的 ETH 价格为 4 USDT/ETH。然而,如果你计划花费 20 个 USDT 在池子里进行 swap,最终只会得到 4 个 ETH,而不是预期的 5 个 ETH,这意味着你遭受了 1 USDT/ETH 的滑点损失。你的实际购买价格将是 5 USDT,而不是预期的 4 USDT。

滑点在快速变化的市场或高波动性资产以及流动性受限的长尾资产中尤其常见。无论如何,它对交易表现有重大影响,在下单交易时考虑滑点非常重要。

# 03合约分析

合约代码来自 OpenZeppelin 智能合约代码库:

https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/extensions/ERC4626.sol

ERC-4626 合约继承自 ERC-20,这部分就不概述了,它本身也是一个抽象合约,该合约必须实现的接口如下:

// Returns the address of the underlying token used for the Vault for accounting, depositing, and withdrawing.function asset() external view returns (address assetTokenAddress);// Returns the total amount of the underlying asset that is “managed” by Vault.function totalAssets() external view returns (uint256 totalManagedAssets);// Returns the amount of shares that the Vault would exchange for the amount of assets provided, in an ideal scenario where all the conditions are met.function convertToShares(uint256 assets) external view returns (uint256 shares);// Returns the amount of assets that the Vault would exchange for the amount of shares provided, in an ideal cenario where all the conditions are met.function convertToAssets(uint256 shares) external view returns (uint256 assets);// Returns the maximum amount of the underlying asset that can be deposited into the Vault for the receiver, through a deposit call.function maxDeposit(address receiver) external view returns (uint256 maxAssets);// Allows an on-chain or off-chain user to simulate the effects of their deposit at the current block, given current on-chain conditions.function previewDeposit(uint256 assets) external view returns (uint256 shares);// Mints shares Vault shares to receiver by depositing exactly amount of underlying tokens.function deposit(uint256 assets, address receiver) external returns (uint256 shares);// Returns the maximum amount of the Vault shares that can be minted for the receiver, through a mint call.function maxMint(address receiver) external view returns (uint256 maxShares);// Allows an on-chain or off-chain user to simulate the effects of their mint at the current block, given current on-chain conditions.function previewMint(uint256 shares) external view returns (uint256 assets);// Mints exactly shares Vault shares to receiver by depositing amount of underlying tokens.function mint(uint256 shares, address receiver) external returns (uint256 assets);// Returns the maximum amount of the underlying asset that can be withdrawn from the owner balance in the Vault, through a withdraw call.function maxWithdraw(address owner) external view returns (uint256 maxAssets);// Allows an on-chain or off-chain user to simulate the effects of their withdrawal at the current block, given current on-chain conditions.function previewWithdraw(uint256 assets) external view returns (uint256 shares);// Burns shares from owner and sends exactly assets of underlying tokens to receiver.function withdraw(uint256 assets, address receiver, address owner) external returns (uint256 shares);// Returns the maximum amount of Vault shares that can be redeemed from the owner balance in the Vault, through a redeem call.function maxRedeem(address owner) external view returns (uint256 maxShares);// Allows an on-chain or off-chain user to simulate the effects of their redeemption at the current block, given current on-chain conditions.function previewRedeem(uint256 shares) external view returns (uint256 assets);// Burns exactly shares from owner and sends assets of underlying tokens to receiver.function redeem(uint256 shares, address receiver, address owner) external returns (uint256 assets);

接口还是挺丰富的,大多比较简单,可以分为 read 和 write 2 大类。

Write

写数据接口主要是 deposit、mint、withdraw、redeem。

  • deposit,存款,确定数量 assets 转入金库。同时铸造(mint) shares。可以使用 previewDeposit 方法提前查看可以铸造多少 shares。

function deposit(uint256 assets, address receiver) public virtual returns (uint256) { uint256 maxAssets = maxDeposit(receiver); if (assets > maxAssets) { revert ERC4626ExceededMaxDeposit(receiver, assets, maxAssets); }

uint256 shares = previewDeposit(assets); _deposit(_msgSender(), receiver, assets, shares);

return shares;}

function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal virtual { SafeERC20.safeTransferFrom(_asset, caller, address(this), assets); _mint(receiver, shares);

emit Deposit(caller, receiver, assets, shares);}

  • withdraw,取款,确定数量 assets 转出金库,同时销毁(burn) shares。可以使用 previewWithdraw 方法提前查看销毁了多少 shares。

function withdraw(uint256 assets, address receiver, address owner) public virtual returns (uint256) { uint256 maxAssets = maxWithdraw(owner); if (assets > maxAssets) { revert ERC4626ExceededMaxWithdraw(owner, assets, maxAssets); }

uint256 shares = previewWithdraw(assets); _withdraw(_msgSender(), receiver, owner, assets, shares);

return shares;}

function _withdraw( address caller, address receiver, address owner, uint256 assets, uint256 shares ) internal virtual { if (caller != owner) { _spendAllowance(owner, caller, shares); } _burn(owner, shares); SafeERC20.safeTransfer(_asset, receiver, assets);

emit Withdraw(caller, receiver, owner, assets, shares);}

  • mint ,铸造,使用 shares 参数,实际上这个方法等同于 deposit,以确定铸造的 shares 来计算需要存入的 assets。可以使用 previewMint 方法提前查看取出多少 assets。
  • redeem,赎回,使用 shares 参数,这个方法等同于 withdraw,以确定销毁的 shares 来计算需要转出的 assets。可以使用 previewRedeem 方法提前查看赎回多少 assets。

实际上由于滑点的存在,使用 preview 方法查看预计的数字,可能是不准确的,也是业界常见问题,可能会产生一些安全问题,后面会讲到。

Read

前面讲的几个 preview 方法,和公开的 convertToShares,convertToAssets,实际上内部都是调用了 _convertToShares、_convertToAssets 方法。

这 2 个核心方法就是计算资产和份额的比例关系的,这里面涉及到的变量有份额供应量、当前总资产、小数点位数、小数点取整方式。

function _convertToShares(uint256 assets, Math.Rounding rounding) internal view virtual returns (uint256) { return assets.mulDiv(totalSupply() + 10 ** _decimalsOffset(), totalAssets() + 1, rounding);}

function _convertToAssets(uint256 shares, Math.Rounding rounding) internal view virtual returns (uint256) { return shares.mulDiv(totalAssets() + 1, totalSupply() + 10 ** _decimalsOffset(), rounding);}

function mulDiv(uint256 x, uint256 y, uint256 denominator, Rounding rounding) internal pure returns (uint256) { uint256 result = mulDiv(x, y, denominator); if (unsignedRoundsUp(rounding) && mulmod(x, y, denominator) > 0) { result += 1; } return result;}

以上是 ERC-4626 抽象合约的基本实现,实际的金库合约要比它复杂的多。

对于金库合约,有 2 个比较重要的功能实现,一个是存取功能,assets 和 shares 的换算;另一个是获得收益的方式。下面我们会举例讲解。

# 04

生态和应用

类似其它一些热门的 EIP,ERC-4626 也有一个专门人维护的联盟生态(https://erc4626.info/),收集了目前市面上已经兼容 ERC-4626 的一些借贷协议和应用,另外还有新闻、开源库、安全等信息。如果你的金库适配了 ERC-4626,也可以在上面提交申请。

下面我们分析一个应用例子,Aladdin DAO 的 AladdinCRVV2 金库(https://concentrator.aladdin.club/vaults/)。Aladdin DAO 有很多个金库合约,这只是其中一个比较活跃的。

AladdinCRVV2 金库

该金库通过质押 cvxCRV 代币,来获得收益。

  • 该金库合约是一个可升级合约

https://etherscan.io/address/0x2b95A1Dcc3D405535f9ed33c219ab38E8d7e0884),通过github 代码可以查到之前版本是不兼容 ERC-4626 的

  • 该金库 assets 是cvxCRV

https://etherscan.io/address/0x62B9c7356A2Dc64a1969e19C23e4f579F9810Aa7)。cvxCRV 可以在 Curve 旗下的 Convex 上通过质押 CVX 获得,也可以使用 CRV 转换 cvxCRV(过程不可逆)

  • 初始化时,会设置一个 strategy
  • https://etherscan.io/address/0x94cc627db80253056b2130aac39abb252a75f345),用于存款时质押获得收益。strategy 可更改
  • 存款时,调用 strategy 进行质押,最后计算 shares。质押合约为(https://etherscan.io/address/0xaa0C3f5F7DFD688C6E646F66CD2a6B66ACdbE434)

function deposit(uint256 _assets, address _receiver) public override nonReentrant returns (uint256) { if (_assets == uint256(-1)) { _assets = IERC20Upgradeable(CVXCRV).balanceOf(msg.sender); }

address _strategy = strategy; IERC20Upgradeable(CVXCRV).safeTransferFrom(msg.sender, _strategy, _assets); IConcentratorStrategy(_strategy).deposit(_receiver, _assets);

return _deposit(_receiver, _assets);}

function _deposit(address _recipient, uint256 _amount) internal returns (uint256) { require(_amount > 0, “AladdinCRV: zero amount deposit”); uint256 _totalUnderlying = totalUnderlying; uint256 _totalSupply = totalSupply();

uint256 _shares; if (_totalSupply == 0) { _shares = _amount; } else { _shares = _amount.mul(_totalSupply) / _totalUnderlying; } _mint(_recipient, _shares); totalUnderlying = _totalUnderlying + _amount;

// legacy event from IAladdinCRV emit Deposit(msg.sender, _recipient, _amount);

emit Deposit(msg.sender, _recipient, _amount, _shares); return _shares;}

这里的质押调用流程比较长,也是这个金库收益的核心逻辑,下面是过程。

  1. AladdinCRVV2 合约的 deposit 方法中调用 IConcentratorStrategy(_strategy).deposit(_receiver, _assets) ;

2. strategy 合约(https://etherscan.io/address/0x94cc627db80253056b2130aac39abb252a75f345)的deposit 方法中调用  ICvxCrvStakingWrapper(wrapper).stake(_amount, address(this));

3. wrapper 合约

https://etherscan.io/address/0xaa0C3f5F7DFD688C6E646F66CD2a6B66ACdbE434)的 stake 方法中调用 IRewardStaking(cvxCrvStaking).stake(_amount);4. cvxCrvStaking 合约(https://etherscan.io/address/0x3Fe65692bfCD0e6CF84cB1E7d24108E434A7587e) stake 方法最终完成质押流程

  • 取款时,通过 strategy 进行取消质押操作,同时销毁 shares。取款有手续费。

function withdraw( address _recipient, uint256 _shares, uint256 _minimumOut, WithdrawOption _option) public override nonReentrant returns (uint256 _withdrawed) { if (_shares == uint256(-1)) { _shares = balanceOf(msg.sender); }

if (_option == WithdrawOption.Withdraw) { _withdrawed = _withdraw(_shares, _recipient, msg.sender); require(_withdrawed >= _minimumOut, “AladdinCRV: insufficient output”); } else { _withdrawed = _withdraw(_shares, address(this), msg.sender); _withdrawed = _withdrawAs(_recipient, _withdrawed, _minimumOut, _option); }

// legacy event from IAladdinCRV emit Withdraw(msg.sender, _recipient, _shares, _option);}

function _withdraw( uint256 _shares, address _receiver, address _owner) internal returns (uint256 _withdrawable) { require(_shares > 0, “AladdinCRV: zero share withdraw”); require(_shares <= balanceOf(_owner), “AladdinCRV: shares not enough”); uint256 _totalUnderlying = totalUnderlying; uint256 _amount = _shares.mul(_totalUnderlying) / totalSupply(); _burn(_owner, _shares);

if (totalSupply() == 0) { // If user is last to withdraw, harvest before exit // The first parameter is actually not used. _harvest(msg.sender, 0); _totalUnderlying = totalUnderlying; // `totalUnderlying` is updated in `_harvest`. _withdrawable = _totalUnderlying; IConcentratorStrategy(strategy).withdraw(_receiver, _withdrawable); } else { // Otherwise compute share and unstake _withdrawable = _amount; // Substract a small withdrawal fee to prevent users “timing” // the harvests. The fee stays staked and is therefore // redistributed to all remaining participants. uint256 _withdrawFeePercentage = getFeeRate(WITHDRAW_FEE_TYPE, _owner); uint256 _withdrawFee = (_withdrawable * _withdrawFeePercentage) / FEE_PRECISION; _withdrawable = _withdrawable – _withdrawFee; // never overflow here IConcentratorStrategy(strategy).withdraw(_receiver, _withdrawable); } totalUnderlying = _totalUnderlying – _withdrawable;

emit Withdraw(msg.sender, _receiver, _owner, _withdrawable, _shares);

return _withdrawable;}

  • 实际上存款和取款,有多种操作选择,还是挺方便的,节约 gas。代码太多,这里就不贴出来了。
  • 存款,默认将 cvxCRV 代币存入金库。另外还有 depositWithCRV 方便 CRV 也可以存款
  • 取款时,默认将 cvxCRV 代币取出,销毁 shares。另外还可以在取款时,自己再质押,将 cvxCRV 转换成 CVX ,将 cvxCRV 转换成 ETH

以上就是对该金库合约的基本解析,功能还是比较丰富的,它的本质就是 assets 质押生息,为什么要这样设计呢,主要还在在于 cvxCrvStaking 合约的设计,质押 cvxCRV 收益描述“By staking cvxCRV, you’re earning the usual rewards from veCRV (3crv governance fee distribution from Curve + any airdrop), plus a share of 10% of the Convex LPs’ boosted CRV earnings, and CVX tokens on top of that.”,而一起通过金库质押的代币数量越多时,获得的收益也越大。

安全

对于 ERC-4626 金库来说,最主要的安全问题在于防通货膨胀攻击(Inflation attack)。

当用户存入代币时,根据份额计算公式(shares = assets * totalSupply / totalAssets),计算结果是有小数点的,一般是向下取整。

通过下图可以看到,在用户存入 500 代币的资产时,小数取整所损失的资产取决于汇率(每股和代币资产对应关系)。如果汇率是橙色曲线的汇率,我们得到的不到 1 股,损失了100%。但是,如果汇率是绿色曲线的汇率,得到 5000 股,四舍五入损失限制在最多 0.02%。

DeFi那如果我们专注于将损失限制在最大 0.5%,我们需要获得至少 200 股。绿色汇率只需要 20 个代币,但橙色汇率需要 200000 个代币。

DeFi

通过几上例子可以分析出,蓝色和绿色曲线对比黄色和橙色曲线更安全,是设计更加安全的金库。

所以通货膨胀攻击的主要方式就是,通过某些手段将利率曲线向右移动,让少量存款者损失份额,达到攻击目的。

攻击方式

Inflation attack 主要是通过捐赠(donate)。

  1. 攻击者先存入 1 个代币到金库合约。这时他所获得的 shares 是 1 ,totalSupply 为 1。
  2. 攻击者直接向金库合约发送 1e5 个代币。这时 totalAssets 发生了变化,为 1e5 + 1,而 totalSupply 并没有发生变化。
  3. 在受害者存入少于 1e5 个代币时(x),所获得的 shares 为: x * 1 / (1e5 + 1),也就是说只要 x 小于 1e5,根据小数向下取整原则,受害者所获得的 shares 为 0 。即使存入的代币大于 1e5,因为攻击者之前所占份额为 100%,那么受害者所获得的 shares 也会大大减少。

抵御攻击

抵御攻击的措施有 3 种:

  1. 设置滑点。在前面我们介绍了滑点的概念,通过设置滑点容忍范围(slippage tolerance)如果它在某个滑点容忍范围内没有收到预期的数量,则撤销(revert)交易。这是处理滑点问题的标准范式。
  2. 向金库增加足够多的初始资产,增加攻击成本。这种方式笔者在 Blast 质押合约中见到过,初始化质押时,合约要求 ETH 和 USD 数量不少于 1000。
  3. 将“虚拟流动性”添加的金库,让价格计算行为跟金库中有足够多的资产一样。抵御方式分为 2 部分:
  • 在 shares 和 assets 之间做精度偏移。
  • 将虚拟 shares 和 虚拟 assets 纳入到汇率计算中。

具体实现就是重写由 OpenZeppelin 提供的标准库代码 _decimalsOffset() 方法,这种方式即不用设置滑点,也不用注入足够初始资金,是非常好的抵御通胀攻击方式。

DeFi

# 05扩展

RC-4626 作为一个比较基础的金库提案,不可能满足所有需求,有一些提案也对止做出了扩展,比如 ERC-7535、EIP-7540。

ERC-7535

前面提到 ERC-4626 只能使用 ERC-20 作为基础资产,这个提案主要是将原生资产(native asset)也可以作为基础资产(underlying asset),比如 ETH 在金库中使用。

EIP-7540

这个对 ERC-4626 的扩展引入了对异步存款和赎回过程(称为”请求”)的支持。它包括了用于启动和检查这些请求状态的新方法。现有的从 ERC-4626 中使用的方法,如存款、铸币、提取和赎回,被用于执行可认领的请求。关于是否添加存款、赎回或两者的异步流程,由实现者自行决定。

潜在用例:

  1. 异步存款和赎回流程:通过引入“请求”概念,可以实现异步的存款和赎回过程,提供更灵活的操作方式。
  2. 用户体验改善:该提案强调了用户体验的重要性,并建议引入标准的发现机制,帮助用户和前端应用程序更好地了解异步操作的持续时间和延迟。
  3. 功能扩展:EIP-7540 通过添加新的方法,扩展了 ERC-4626 的功能,使得可以异步请求存款和赎回,并能查看这些请求的状态。

# 06总结

以上,就是关于 ERC-4626 相关的全部解析。

因为历史原因,当前市面上有很多金库并不遵循 ERC-4626的,依然在运作,比如 dForce,但没法应用更广。也有一些金库已经升级为遵循 ERC-4626,比如 Aladdin DAO 的一些合约(https://github.com/AladdinDAO/deployments/blob/main/deployments.mainnet.md)。

金库应用除了质押生息外,还可以将 shares 做为抵押物借出或者再质押,从而产生收益。另外通过金库来募资也是一个比较好的应用场景,它的一些基础功能就可以提供很好的支持。

该提案的推出,本质是为了提升金库与 DeFi 生态集成效率,减少开发成本。而金库本身的作用,随着 DeFi 市场的增长,还有更多挖掘的空间。

转载声明:本文 由CoinON抓取收录,观点仅代表作者本人,不代表CoinON资讯立场,CoinON不对所包含内容的准确性、可靠性或完整性提供任何明示或暗示的保证。若以此作为投资依据,请自行承担全部责任。

声明:图文来源于网络,如有侵权请联系删除

风险提示:投资有风险,入市需谨慎。本资讯不作为投资理财建议。

(0)
打赏 微信扫一扫 微信扫一扫
上一篇 2024年2月18日 上午2:26
下一篇 2024年2月18日 上午2:26

相关推荐

全面解析 ERC-4626 与 DeFi

星期日 2024-02-18 2:26:40

ERC-4626 作为一个出现 2 年多的以太坊改进方案,被人们讨论的似乎并不多,远不如 ERC-20、ERC-721 等,原因在于它的应用场景相对来说不是很广泛,但笔者还是比较推荐学习它,理由如下:

  1. 它是 OpenZeppelin 为数不多有着完整代码实现的 ERC 之一,同时也是被以太坊官方列入的 token stanards(目前共有 5 个,另外几个分别是 ERC-20、ERC-721、ERC-777、ERC-1155) 之一,对于合约开发者和审计人员来说是非常重要的。
  2. 它是一个非常适合新手学习 DeFi 的 ERC,其 input token/output shares 的设计理念,在 liquidity protocol、staking 等 DeFi 特性中都有广泛的应用。
  3. 金库与 DeFi 借贷、质押业务联系是非常紧密的。

下面我们从提案内容、合约实现、应用生态、安全等几个方面来全面解析 ERC-4626。

DeFi

# 01什么是 ERC-4626

ERC-4626 是一个使用单一基础 ERC-20 的代币化金库。

Tokenized Vaults with a single underlying EIP-20 token.

首先,它是一个基于 ERC-20 的提案,并与之完全兼容。

其次,理解金库(vault)的概念,它不是国库(treasury)。现在市面上的国库基本上就是一个合约钱包,大多以 Gnosis Safe 为主,主要提供安全的资金出入功能。但是对于一个组织来说,除了资金出入外,还可以让资金流动产生收益。

该提案产生的动机:代币化的金库缺乏标准,导致市场上的很多金库实现细节不一样,比如借贷市场、聚合器、生息代币等。这使得在协议层面的聚合器和插件集成工作变得困难,容易出错和浪费开发资源。

该提案当前状态:Final,意味着是相对比较稳定的标准了。

# 02

规范

遵循 ERC-4626 的代币必须完全实现 ERC-20,用来表示份额(shares)。下面是几个简单的概念。

  • 资产(asset):由金库管理的基础代币(the underlying token),遵循 ERC-20 标准。
  • 份额(share):金库代币,也可以称为 vToken。它跟 asset 有一个比例关系。
  • 费用(fee):在资产或份额发生变化时,金库收取的一个金额。可以是 存款、收益、资产管理、取款等。
  • 滑点(slippage):份额存款和取款的公布价格和实际经济差。下面是关于 DeFi 领域滑点概念的更多解读。

滑点是指交易的预期价格与实际执行价格之间的差异。当下单交易与执行交易之间存在延迟,交易的资产价格发生变化时,滑点就会出现。

例如,你在 AMM 池中发现有 20 个 ETH 和 80 个 USDT,那么你预期的 ETH 价格为 4 USDT/ETH。然而,如果你计划花费 20 个 USDT 在池子里进行 swap,最终只会得到 4 个 ETH,而不是预期的 5 个 ETH,这意味着你遭受了 1 USDT/ETH 的滑点损失。你的实际购买价格将是 5 USDT,而不是预期的 4 USDT。

滑点在快速变化的市场或高波动性资产以及流动性受限的长尾资产中尤其常见。无论如何,它对交易表现有重大影响,在下单交易时考虑滑点非常重要。

# 03合约分析

合约代码来自 OpenZeppelin 智能合约代码库:

https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/extensions/ERC4626.sol

ERC-4626 合约继承自 ERC-20,这部分就不概述了,它本身也是一个抽象合约,该合约必须实现的接口如下:

// Returns the address of the underlying token used for the Vault for accounting, depositing, and withdrawing.function asset() external view returns (address assetTokenAddress);// Returns the total amount of the underlying asset that is “managed” by Vault.function totalAssets() external view returns (uint256 totalManagedAssets);// Returns the amount of shares that the Vault would exchange for the amount of assets provided, in an ideal scenario where all the conditions are met.function convertToShares(uint256 assets) external view returns (uint256 shares);// Returns the amount of assets that the Vault would exchange for the amount of shares provided, in an ideal cenario where all the conditions are met.function convertToAssets(uint256 shares) external view returns (uint256 assets);// Returns the maximum amount of the underlying asset that can be deposited into the Vault for the receiver, through a deposit call.function maxDeposit(address receiver) external view returns (uint256 maxAssets);// Allows an on-chain or off-chain user to simulate the effects of their deposit at the current block, given current on-chain conditions.function previewDeposit(uint256 assets) external view returns (uint256 shares);// Mints shares Vault shares to receiver by depositing exactly amount of underlying tokens.function deposit(uint256 assets, address receiver) external returns (uint256 shares);// Returns the maximum amount of the Vault shares that can be minted for the receiver, through a mint call.function maxMint(address receiver) external view returns (uint256 maxShares);// Allows an on-chain or off-chain user to simulate the effects of their mint at the current block, given current on-chain conditions.function previewMint(uint256 shares) external view returns (uint256 assets);// Mints exactly shares Vault shares to receiver by depositing amount of underlying tokens.function mint(uint256 shares, address receiver) external returns (uint256 assets);// Returns the maximum amount of the underlying asset that can be withdrawn from the owner balance in the Vault, through a withdraw call.function maxWithdraw(address owner) external view returns (uint256 maxAssets);// Allows an on-chain or off-chain user to simulate the effects of their withdrawal at the current block, given current on-chain conditions.function previewWithdraw(uint256 assets) external view returns (uint256 shares);// Burns shares from owner and sends exactly assets of underlying tokens to receiver.function withdraw(uint256 assets, address receiver, address owner) external returns (uint256 shares);// Returns the maximum amount of Vault shares that can be redeemed from the owner balance in the Vault, through a redeem call.function maxRedeem(address owner) external view returns (uint256 maxShares);// Allows an on-chain or off-chain user to simulate the effects of their redeemption at the current block, given current on-chain conditions.function previewRedeem(uint256 shares) external view returns (uint256 assets);// Burns exactly shares from owner and sends assets of underlying tokens to receiver.function redeem(uint256 shares, address receiver, address owner) external returns (uint256 assets);

接口还是挺丰富的,大多比较简单,可以分为 read 和 write 2 大类。

Write

写数据接口主要是 deposit、mint、withdraw、redeem。

  • deposit,存款,确定数量 assets 转入金库。同时铸造(mint) shares。可以使用 previewDeposit 方法提前查看可以铸造多少 shares。

function deposit(uint256 assets, address receiver) public virtual returns (uint256) { uint256 maxAssets = maxDeposit(receiver); if (assets > maxAssets) { revert ERC4626ExceededMaxDeposit(receiver, assets, maxAssets); }

uint256 shares = previewDeposit(assets); _deposit(_msgSender(), receiver, assets, shares);

return shares;}

function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal virtual { SafeERC20.safeTransferFrom(_asset, caller, address(this), assets); _mint(receiver, shares);

emit Deposit(caller, receiver, assets, shares);}

  • withdraw,取款,确定数量 assets 转出金库,同时销毁(burn) shares。可以使用 previewWithdraw 方法提前查看销毁了多少 shares。

function withdraw(uint256 assets, address receiver, address owner) public virtual returns (uint256) { uint256 maxAssets = maxWithdraw(owner); if (assets > maxAssets) { revert ERC4626ExceededMaxWithdraw(owner, assets, maxAssets); }

uint256 shares = previewWithdraw(assets); _withdraw(_msgSender(), receiver, owner, assets, shares);

return shares;}

function _withdraw( address caller, address receiver, address owner, uint256 assets, uint256 shares ) internal virtual { if (caller != owner) { _spendAllowance(owner, caller, shares); } _burn(owner, shares); SafeERC20.safeTransfer(_asset, receiver, assets);

emit Withdraw(caller, receiver, owner, assets, shares);}

  • mint ,铸造,使用 shares 参数,实际上这个方法等同于 deposit,以确定铸造的 shares 来计算需要存入的 assets。可以使用 previewMint 方法提前查看取出多少 assets。
  • redeem,赎回,使用 shares 参数,这个方法等同于 withdraw,以确定销毁的 shares 来计算需要转出的 assets。可以使用 previewRedeem 方法提前查看赎回多少 assets。

实际上由于滑点的存在,使用 preview 方法查看预计的数字,可能是不准确的,也是业界常见问题,可能会产生一些安全问题,后面会讲到。

Read

前面讲的几个 preview 方法,和公开的 convertToShares,convertToAssets,实际上内部都是调用了 _convertToShares、_convertToAssets 方法。

这 2 个核心方法就是计算资产和份额的比例关系的,这里面涉及到的变量有份额供应量、当前总资产、小数点位数、小数点取整方式。

function _convertToShares(uint256 assets, Math.Rounding rounding) internal view virtual returns (uint256) { return assets.mulDiv(totalSupply() + 10 ** _decimalsOffset(), totalAssets() + 1, rounding);}

function _convertToAssets(uint256 shares, Math.Rounding rounding) internal view virtual returns (uint256) { return shares.mulDiv(totalAssets() + 1, totalSupply() + 10 ** _decimalsOffset(), rounding);}

function mulDiv(uint256 x, uint256 y, uint256 denominator, Rounding rounding) internal pure returns (uint256) { uint256 result = mulDiv(x, y, denominator); if (unsignedRoundsUp(rounding) && mulmod(x, y, denominator) > 0) { result += 1; } return result;}

以上是 ERC-4626 抽象合约的基本实现,实际的金库合约要比它复杂的多。

对于金库合约,有 2 个比较重要的功能实现,一个是存取功能,assets 和 shares 的换算;另一个是获得收益的方式。下面我们会举例讲解。

# 04

生态和应用

类似其它一些热门的 EIP,ERC-4626 也有一个专门人维护的联盟生态(https://erc4626.info/),收集了目前市面上已经兼容 ERC-4626 的一些借贷协议和应用,另外还有新闻、开源库、安全等信息。如果你的金库适配了 ERC-4626,也可以在上面提交申请。

下面我们分析一个应用例子,Aladdin DAO 的 AladdinCRVV2 金库(https://concentrator.aladdin.club/vaults/)。Aladdin DAO 有很多个金库合约,这只是其中一个比较活跃的。

AladdinCRVV2 金库

该金库通过质押 cvxCRV 代币,来获得收益。

  • 该金库合约是一个可升级合约

https://etherscan.io/address/0x2b95A1Dcc3D405535f9ed33c219ab38E8d7e0884),通过github 代码可以查到之前版本是不兼容 ERC-4626 的

  • 该金库 assets 是cvxCRV

https://etherscan.io/address/0x62B9c7356A2Dc64a1969e19C23e4f579F9810Aa7)。cvxCRV 可以在 Curve 旗下的 Convex 上通过质押 CVX 获得,也可以使用 CRV 转换 cvxCRV(过程不可逆)

  • 初始化时,会设置一个 strategy
  • https://etherscan.io/address/0x94cc627db80253056b2130aac39abb252a75f345),用于存款时质押获得收益。strategy 可更改
  • 存款时,调用 strategy 进行质押,最后计算 shares。质押合约为(https://etherscan.io/address/0xaa0C3f5F7DFD688C6E646F66CD2a6B66ACdbE434)

function deposit(uint256 _assets, address _receiver) public override nonReentrant returns (uint256) { if (_assets == uint256(-1)) { _assets = IERC20Upgradeable(CVXCRV).balanceOf(msg.sender); }

address _strategy = strategy; IERC20Upgradeable(CVXCRV).safeTransferFrom(msg.sender, _strategy, _assets); IConcentratorStrategy(_strategy).deposit(_receiver, _assets);

return _deposit(_receiver, _assets);}

function _deposit(address _recipient, uint256 _amount) internal returns (uint256) { require(_amount > 0, “AladdinCRV: zero amount deposit”); uint256 _totalUnderlying = totalUnderlying; uint256 _totalSupply = totalSupply();

uint256 _shares; if (_totalSupply == 0) { _shares = _amount; } else { _shares = _amount.mul(_totalSupply) / _totalUnderlying; } _mint(_recipient, _shares); totalUnderlying = _totalUnderlying + _amount;

// legacy event from IAladdinCRV emit Deposit(msg.sender, _recipient, _amount);

emit Deposit(msg.sender, _recipient, _amount, _shares); return _shares;}

这里的质押调用流程比较长,也是这个金库收益的核心逻辑,下面是过程。

  1. AladdinCRVV2 合约的 deposit 方法中调用 IConcentratorStrategy(_strategy).deposit(_receiver, _assets) ;

2. strategy 合约(https://etherscan.io/address/0x94cc627db80253056b2130aac39abb252a75f345)的deposit 方法中调用  ICvxCrvStakingWrapper(wrapper).stake(_amount, address(this));

3. wrapper 合约

https://etherscan.io/address/0xaa0C3f5F7DFD688C6E646F66CD2a6B66ACdbE434)的 stake 方法中调用 IRewardStaking(cvxCrvStaking).stake(_amount);4. cvxCrvStaking 合约(https://etherscan.io/address/0x3Fe65692bfCD0e6CF84cB1E7d24108E434A7587e) stake 方法最终完成质押流程

  • 取款时,通过 strategy 进行取消质押操作,同时销毁 shares。取款有手续费。

function withdraw( address _recipient, uint256 _shares, uint256 _minimumOut, WithdrawOption _option) public override nonReentrant returns (uint256 _withdrawed) { if (_shares == uint256(-1)) { _shares = balanceOf(msg.sender); }

if (_option == WithdrawOption.Withdraw) { _withdrawed = _withdraw(_shares, _recipient, msg.sender); require(_withdrawed >= _minimumOut, “AladdinCRV: insufficient output”); } else { _withdrawed = _withdraw(_shares, address(this), msg.sender); _withdrawed = _withdrawAs(_recipient, _withdrawed, _minimumOut, _option); }

// legacy event from IAladdinCRV emit Withdraw(msg.sender, _recipient, _shares, _option);}

function _withdraw( uint256 _shares, address _receiver, address _owner) internal returns (uint256 _withdrawable) { require(_shares > 0, “AladdinCRV: zero share withdraw”); require(_shares <= balanceOf(_owner), “AladdinCRV: shares not enough”); uint256 _totalUnderlying = totalUnderlying; uint256 _amount = _shares.mul(_totalUnderlying) / totalSupply(); _burn(_owner, _shares);

if (totalSupply() == 0) { // If user is last to withdraw, harvest before exit // The first parameter is actually not used. _harvest(msg.sender, 0); _totalUnderlying = totalUnderlying; // `totalUnderlying` is updated in `_harvest`. _withdrawable = _totalUnderlying; IConcentratorStrategy(strategy).withdraw(_receiver, _withdrawable); } else { // Otherwise compute share and unstake _withdrawable = _amount; // Substract a small withdrawal fee to prevent users “timing” // the harvests. The fee stays staked and is therefore // redistributed to all remaining participants. uint256 _withdrawFeePercentage = getFeeRate(WITHDRAW_FEE_TYPE, _owner); uint256 _withdrawFee = (_withdrawable * _withdrawFeePercentage) / FEE_PRECISION; _withdrawable = _withdrawable – _withdrawFee; // never overflow here IConcentratorStrategy(strategy).withdraw(_receiver, _withdrawable); } totalUnderlying = _totalUnderlying – _withdrawable;

emit Withdraw(msg.sender, _receiver, _owner, _withdrawable, _shares);

return _withdrawable;}

  • 实际上存款和取款,有多种操作选择,还是挺方便的,节约 gas。代码太多,这里就不贴出来了。
  • 存款,默认将 cvxCRV 代币存入金库。另外还有 depositWithCRV 方便 CRV 也可以存款
  • 取款时,默认将 cvxCRV 代币取出,销毁 shares。另外还可以在取款时,自己再质押,将 cvxCRV 转换成 CVX ,将 cvxCRV 转换成 ETH

以上就是对该金库合约的基本解析,功能还是比较丰富的,它的本质就是 assets 质押生息,为什么要这样设计呢,主要还在在于 cvxCrvStaking 合约的设计,质押 cvxCRV 收益描述“By staking cvxCRV, you’re earning the usual rewards from veCRV (3crv governance fee distribution from Curve + any airdrop), plus a share of 10% of the Convex LPs’ boosted CRV earnings, and CVX tokens on top of that.”,而一起通过金库质押的代币数量越多时,获得的收益也越大。

安全

对于 ERC-4626 金库来说,最主要的安全问题在于防通货膨胀攻击(Inflation attack)。

当用户存入代币时,根据份额计算公式(shares = assets * totalSupply / totalAssets),计算结果是有小数点的,一般是向下取整。

通过下图可以看到,在用户存入 500 代币的资产时,小数取整所损失的资产取决于汇率(每股和代币资产对应关系)。如果汇率是橙色曲线的汇率,我们得到的不到 1 股,损失了100%。但是,如果汇率是绿色曲线的汇率,得到 5000 股,四舍五入损失限制在最多 0.02%。

DeFi那如果我们专注于将损失限制在最大 0.5%,我们需要获得至少 200 股。绿色汇率只需要 20 个代币,但橙色汇率需要 200000 个代币。

DeFi

通过几上例子可以分析出,蓝色和绿色曲线对比黄色和橙色曲线更安全,是设计更加安全的金库。

所以通货膨胀攻击的主要方式就是,通过某些手段将利率曲线向右移动,让少量存款者损失份额,达到攻击目的。

攻击方式

Inflation attack 主要是通过捐赠(donate)。

  1. 攻击者先存入 1 个代币到金库合约。这时他所获得的 shares 是 1 ,totalSupply 为 1。
  2. 攻击者直接向金库合约发送 1e5 个代币。这时 totalAssets 发生了变化,为 1e5 + 1,而 totalSupply 并没有发生变化。
  3. 在受害者存入少于 1e5 个代币时(x),所获得的 shares 为: x * 1 / (1e5 + 1),也就是说只要 x 小于 1e5,根据小数向下取整原则,受害者所获得的 shares 为 0 。即使存入的代币大于 1e5,因为攻击者之前所占份额为 100%,那么受害者所获得的 shares 也会大大减少。

抵御攻击

抵御攻击的措施有 3 种:

  1. 设置滑点。在前面我们介绍了滑点的概念,通过设置滑点容忍范围(slippage tolerance)如果它在某个滑点容忍范围内没有收到预期的数量,则撤销(revert)交易。这是处理滑点问题的标准范式。
  2. 向金库增加足够多的初始资产,增加攻击成本。这种方式笔者在 Blast 质押合约中见到过,初始化质押时,合约要求 ETH 和 USD 数量不少于 1000。
  3. 将“虚拟流动性”添加的金库,让价格计算行为跟金库中有足够多的资产一样。抵御方式分为 2 部分:
  • 在 shares 和 assets 之间做精度偏移。
  • 将虚拟 shares 和 虚拟 assets 纳入到汇率计算中。

具体实现就是重写由 OpenZeppelin 提供的标准库代码 _decimalsOffset() 方法,这种方式即不用设置滑点,也不用注入足够初始资金,是非常好的抵御通胀攻击方式。

DeFi

# 05扩展

RC-4626 作为一个比较基础的金库提案,不可能满足所有需求,有一些提案也对止做出了扩展,比如 ERC-7535、EIP-7540。

ERC-7535

前面提到 ERC-4626 只能使用 ERC-20 作为基础资产,这个提案主要是将原生资产(native asset)也可以作为基础资产(underlying asset),比如 ETH 在金库中使用。

EIP-7540

这个对 ERC-4626 的扩展引入了对异步存款和赎回过程(称为”请求”)的支持。它包括了用于启动和检查这些请求状态的新方法。现有的从 ERC-4626 中使用的方法,如存款、铸币、提取和赎回,被用于执行可认领的请求。关于是否添加存款、赎回或两者的异步流程,由实现者自行决定。

潜在用例:

  1. 异步存款和赎回流程:通过引入“请求”概念,可以实现异步的存款和赎回过程,提供更灵活的操作方式。
  2. 用户体验改善:该提案强调了用户体验的重要性,并建议引入标准的发现机制,帮助用户和前端应用程序更好地了解异步操作的持续时间和延迟。
  3. 功能扩展:EIP-7540 通过添加新的方法,扩展了 ERC-4626 的功能,使得可以异步请求存款和赎回,并能查看这些请求的状态。

# 06总结

以上,就是关于 ERC-4626 相关的全部解析。

因为历史原因,当前市面上有很多金库并不遵循 ERC-4626的,依然在运作,比如 dForce,但没法应用更广。也有一些金库已经升级为遵循 ERC-4626,比如 Aladdin DAO 的一些合约(https://github.com/AladdinDAO/deployments/blob/main/deployments.mainnet.md)。

金库应用除了质押生息外,还可以将 shares 做为抵押物借出或者再质押,从而产生收益。另外通过金库来募资也是一个比较好的应用场景,它的一些基础功能就可以提供很好的支持。

该提案的推出,本质是为了提升金库与 DeFi 生态集成效率,减少开发成本。而金库本身的作用,随着 DeFi 市场的增长,还有更多挖掘的空间。