solidity学习之ERC4626

什么是ERC4626

ERC4626是对ERC20代币标准的扩展,用于实现收益金库的标准化,用户可以将资产质押到合约中,持有相应的shares凭证,通过凭证来享有合约后续的收益。

实现逻辑

ERC4626继承了ERC20,合约本身会发行一种shares代币,当用户存入指定的代币后,就会根据金库内的资产情况铸造响应的shares份额分发给用户,当用户想要赎回资产时,调用合约就会销毁shares份额,并将存入的代币和附加的收益返还给用户。

ERC4626提供了两种计算方式,用户可以根据存入的资产算出shares份额,也可以通过想要得到的份额计算出需要存入多少资产。

接口合约如下:

interface IERC4626 is IERC20, IERC20Metadata {
    /*//////////////////////////////////////////////////////////////
                                 事件
    //////////////////////////////////////////////////////////////*/
    // 存款时触发
    event Deposit(address indexed sender, address indexed owner, uint256 assets, uint256 shares);

    // 取款时触发
    event Withdraw(
        address indexed sender,
        address indexed receiver,
        address indexed owner,
        uint256 assets,
        uint256 shares
    );

    /*//////////////////////////////////////////////////////////////
                            元数据
    //////////////////////////////////////////////////////////////*/
    /**
     * @dev 返回金库的基础资产代币地址 (用于存款,取款)
     * - 必须是 ERC20 代币合约地址.
     * - 不能revert
     */
    function asset() external view returns (address assetTokenAddress);

    /*//////////////////////////////////////////////////////////////
                        存款/提款逻辑
    //////////////////////////////////////////////////////////////*/
    /**
     * @dev 存款函数: 用户向金库存入 assets 单位的基础资产,然后合约铸造 shares 单位的金库额度给 receiver 地址
     *
     * - 必须释放 Deposit 事件.
     * - 如果资产不能存入,必须revert,比如存款数额大大于上限等。
     */
    function deposit(uint256 assets, address receiver) external returns (uint256 shares);

    /**
     * @dev 铸造函数: 用户需要存入 assets 单位的基础资产,然后合约给 receiver 地址铸造 share 数量的金库额度
     * - 必须释放 Deposit 事件.
     * - 如果全部金库额度不能铸造,必须revert,比如铸造数额大大于上限等。
     */
    function mint(uint256 shares, address receiver) external returns (uint256 assets);

    /**
     * @dev 提款函数: owner 地址销毁 share 单位的金库额度,然后合约将 assets 单位的基础资产发送给 receiver 地址
     * - 释放 Withdraw 事件
     * - 如果全部基础资产不能提取,将revert
     */
    function withdraw(uint256 assets, address receiver, address owner) external returns (uint256 shares);

    /**
     * @dev 赎回函数: owner 地址销毁 shares 数量的金库额度,然后合约将 assets 单位的基础资产发给 receiver 地址
     * - 释放 Withdraw 事件
     * - 如果金库额度不能全部销毁,则revert
     */
    function redeem(uint256 shares, address receiver, address owner) external returns (uint256 assets);

    /*//////////////////////////////////////////////////////////////
                            会计逻辑
    //////////////////////////////////////////////////////////////*/

    /**
     * @dev 返回金库中管理的基础资产代币总额
     * - 要包含利息
     * - 要包含费用
     * - 不能revert
     */
    function totalAssets() external view returns (uint256 totalManagedAssets);

    /**
     * @dev 返回利用一定数额基础资产可以换取的金库额度
     * - 不要包含费用
     * - 不包含滑点
     * - 不能revert
     */
    function convertToShares(uint256 assets) external view returns (uint256 shares);

    /**
     * @dev 返回利用一定数额金库额度可以换取的基础资产
     * - 不要包含费用
     * - 不包含滑点
     * - 不能revert
     */
    function convertToAssets(uint256 shares) external view returns (uint256 assets);

    /**
     * @dev 用于链上和链下用户在当前链上环境模拟存款一定数额的基础资产能够获得的金库额度
     * - 返回值要接近且不大于在同一交易进行存款得到的金库额度
     * - 不要考虑 maxDeposit 等限制,假设用户的存款交易会成功
     * - 要考虑费用
     * - 不能revert
     * NOTE: 可以利用 convertToAssets 和 previewDeposit 返回值的差值来计算滑点
     */
    function previewDeposit(uint256 assets) external view returns (uint256 shares);

    /**
     * @dev 用于链上和链下用户在当前链上环境模拟铸造 shares 数额的金库额度需要存款的基础资产数量
     * - 返回值要接近且不小于在同一交易进行铸造一定数额金库额度所需的存款数量
     * - 不要考虑 maxMint 等限制,假设用户的存款交易会成功
     * - 要考虑费用
     * - 不能revert
     */
    function previewMint(uint256 shares) external view returns (uint256 assets);

    /**
     * @dev 用于链上和链下用户在当前链上环境模拟提款 assets 数额的基础资产需要赎回的金库份额
     * - 返回值要接近且不大于在同一交易进行提款一定数额基础资产所需赎回的金库份额
     * - 不要考虑 maxWithdraw 等限制,假设用户的提款交易会成功
     * - 要考虑费用
     * - 不能revert
     */
    function previewWithdraw(uint256 assets) external view returns (uint256 shares);

    /**
     * @dev 用于链上和链下用户在当前链上环境模拟销毁 shares 数额的金库额度能够赎回的基础资产数量
     * - 返回值要接近且不小于在同一交易进行销毁一定数额的金库额度所能赎回的基础资产数量
     * - 不要考虑 maxRedeem 等限制,假设用户的赎回交易会成功
     * - 要考虑费用
     * - 不能revert.
     */
    function previewRedeem(uint256 shares) external view returns (uint256 assets);

    /*//////////////////////////////////////////////////////////////
                     存款/提款限额逻辑
    //////////////////////////////////////////////////////////////*/
    /**
     * @dev 返回某个用户地址单次存款可存的最大基础资产数额。
     * - 如果有存款上限,那么返回值应该是个有限值
     * - 返回值不能超过 2 ** 256 - 1 
     * - 不能revert
     */
    function maxDeposit(address receiver) external view returns (uint256 maxAssets);

    /**
     * @dev 返回某个用户地址单次铸造可以铸造的最大金库额度
     * - 如果有铸造上限,那么返回值应该是个有限值
     * - 返回值不能超过 2 ** 256 - 1 
     * - 不能revert
     */
    function maxMint(address receiver) external view returns (uint256 maxShares);

    /**
     * @dev 返回某个用户地址单次取款可以提取的最大基础资产额度
     * - 返回值应该是个有限值
     * - 不能revert
     */
    function maxWithdraw(address owner) external view returns (uint256 maxAssets);

    /**
     * @dev 返回某个用户地址单次赎回可以销毁的最大金库额度
     * - 返回值应该是个有限值
     * - 如果没有其他限制,返回值应该是 balanceOf(owner)
     * - 不能revert
     */
    function maxRedeem(address owner) external view returns (uint256 maxShares);
}

逻辑分为四部分:

  1. 元数据,asset()方法,返回的是基础资产,也就是存入资产的token地址。
  2. 存款提款逻辑,存款和提款各有两个方法,分别以asset和share为基准进行计算。
  3. 会计逻辑,用来计算asset和share互相转换的方法,既是存款提款的基础方法,也可以对外提供方便用户进行模拟计算。此处可以增加额外的逻辑,用来实现更复杂的算法。
  4. 限额逻辑,计算用户可存取的限额,与合约的设置以及用户的余额有关。

具体实现

  function totalAssets() public view override returns (uint256 totalManagedAssets){
      return _asset.balanceOf(address(this));
  }

totalAssets就是简单地将当前合约下的asset返回。

  function convertToShares(uint256 assets) public view override returns (uint256 shares){
      uint256 supply = totalSupply();

      return supply == 0 ? assets: assets * supply / totalAssets();
  }

  function convertToAssets(uint256 shares) public view override returns (uint256 assets){
      uint256 supply = totalSupply();

      return supply == 0? shares: shares * totalAssets() / supply;
  }

convertToSharesconvertToAssets是share和asset互相转换的方法,其中当supply为0时,也就是此时合约中还未有任何资产,也未有任何share,那么assetshare按照1:1的比例进行兑换。如果此时已经有资产,那么supply/totalAssets()就是每份asset可以兑换的share数量,反之亦然。

这里有两个思考点:

  1. 按这种计算方式,那么assetshare始终都是1:1的,那么会出现不相等的情况是因为合约中是会有收益产生的(依赖于其他逻辑),此时asset增加而share不变,那么比例就发生了变化。
  2. 按理来说supply=0时,share也应该为0,应该没有任何可兑换的asset,但此处兼容了这一场景,是为了防止revert的出现。
  function previewDeposit(uint256 assets) public view override returns (uint256 shares){
      return convertToShares(assets);
  }
  
  function previewMint(uint256 shares) public view returns (uint256 assets) {
      return convertToAssets(shares);
  }

  function previewWithdraw(uint256 assets) public view returns (uint256 shares){
      return  convertToShares(assets);
  }

  function previewRedeem(uint256 shares) public view returns (uint256 assets) {
      return convertToAssets(shares);
  }

四个会计方法都简单地用了convert的调用,因为这里实现的是简版的合约,没有其他逻辑。

  function maxDeposit(address receiver) external view returns (uint256 maxAssets){
      return type(uint256).max;
  }

  function maxMint(address receiver) external view returns (uint256 maxShares){
      return type(uint256).max;
  }

  function maxWithdraw(address owner) external view returns (uint256 maxAssets){
      return convertToAssets(balanceOf(owner));
  }

  function maxRedeem(address owner) external view returns (uint256 maxShares){
      return balanceOf(owner);
  }

同样,限额逻辑也是简单地实现,并没有做过多地限制。

function deposit(uint256 assets, address receiver) external override returns (uint256 shares){
    shares = previewDeposit(assets);

    _asset.transferFrom(msg.sender, address(this), assets);
    _mint(receiver, shares);
    emit Deposit(msg.sender, receiver, assets, shares);

}

function mint(uint256 shares, address receiver) external returns (uint256 assets){
    // 利用 previewMint() 计算需要存款的基础资产数额
    assets = previewMint(shares);

    // 先 transfer 后 mint,防止重入
    _asset.transferFrom(msg.sender, address(this), assets);
    _mint(receiver, shares);

    // 释放 Deposit 事件
    emit Deposit(msg.sender, receiver, assets, shares);
}

function withdraw(uint256 assets, address receiver, address owner) external returns (uint256 shares){
    shares = previewWithdraw(assets);

    if(msg.sender != owner){
        _spendAllowance(owner, msg.sender, shares);
    }
    _burn(owner, shares);
    _asset.transfer(receiver, assets);

    emit Withdraw(msg.sender, receiver, owner, assets, shares);
}

function redeem(uint256 shares, address receiver, address owner) external returns (uint256 assets){
    assets = previewRedeem(shares);

    if(msg.sender != owner){
        _spendAllowance(owner, msg.sender, shares);
    }
    _burn(owner, shares);
    _asset.transfer(receiver, assets);

    emit Withdraw(msg.sender, receiver, owner, assets, shares);
}

可以看到depositmintwithdrawredeem的实现都是基本一致的,只在于数量计算的区别。

有一个值得注意的地方就是depositmint只支持存入msg.sender自己的资产,但是withdrawredeem是支持通过授权的形式由他人来调用的,所以当msg.sender不等于owner的时候,需要使用授权额度。

posted @ 2025-08-11 17:03  Felix07  阅读(56)  评论(0)    收藏  举报