ERC4626

ERC4626ERC20 的扩展,它为 token vaults 提出了一种标准接口。这种标准接口可以被广泛不同的合约使用(包括借贷市场、聚合器和内生计息的 token),这带来了一些微妙之处。解决这些潜在问题对于实现兼容且可组合的 token vault 至关重要。

我们提供了一个 ERC4626 的基础组件,该组件旨在允许开发者使用 traits 和 hooks 轻松地重新配置 vault 的行为,同时保持兼容性。在本指南中,我们将讨论一些影响 ERC4626 的安全考虑因素。我们还将讨论 vault 的常见自定义。

安全问题:通货膨胀攻击

可视化 vault

为了交换存入 ERC4626 vault 的资产,用户会收到 shares。这些 shares 稍后可以被销毁以赎回相应的底层资产。用户获得的 shares 数量取决于他们投入的资产数量和 vault 的汇率。该汇率由 vault 当前持有的流动性定义。

  • 如果一个 vault 有 100 个 tokens 支持 200 个 shares,那么每个 share 的价值为 0.5 个 assets。

  • 如果一个 vault 有 200 个 tokens 支持 100 个 shares,那么每个 share 的价值为 2.0 个 assets。

换句话说,汇率可以定义为穿过原点以及 vault 中当前资产和 shares 数量的直线的斜率。存款和取款使 vault 在这条线上移动。

线性尺度下的汇率

当以双对数刻度绘制时,汇率的定义类似,但显示方式不同(因为点 (0,0) 位于无限远处)。汇率由具有不同偏移量的“对角线”表示。

对数刻度下的汇率

在这种表示中,差异很大的汇率可以在同一张图中清晰可见。在线性刻度下则不会出现这种情况。

对数刻度下的更多汇率

攻击

当存入 tokens 时,用户获得的 shares 数量会四舍五入为零。这种四舍五入从用户那里夺走了价值,从而有利于 vault(即有利于所有当前的 shareholders)。由于涉及的金额,这种四舍五入通常可以忽略不计。如果您存入价值 1e9 shares 的 tokens,则四舍五入最多会导致您损失 0.0000001% 的存款。但是,如果您存入价值 10 shares 的 tokens,您可能会损失 10% 的存款。更糟糕的是,如果您存入价值不到 1 share 的 tokens,您将收到 0 shares,实际上是捐赠。

对于给定数量的 assets,您收到的 shares 越多,您就越安全。如果您想将损失限制在最多 1%,则需要至少收到 100 个 shares。

存入 assets

在图中,我们可以看到,对于给定的 500 assets 存款,我们获得的 shares 数量和相应的四舍五入损失取决于汇率。如果汇率是橙色曲线的汇率,我们获得的 shares 不到 1 个,因此我们损失了 100% 的存款。但是,如果汇率是绿色曲线的汇率,我们将获得 5000 个 shares,这将我们的四舍五入损失限制在最多 0.02%。

铸造 shares

对称地,如果我们专注于将损失限制在最多 0.5%,我们需要至少获得 200 个 shares。使用绿色汇率只需要 20 个 tokens,但使用橙色汇率则需要 200000 个 tokens。

我们可以清楚地看到,蓝色和绿色曲线对应于比黄色和橙色曲线更安全的 vaults。

通货膨胀攻击的想法是,攻击者可以向 vault 捐赠 assets 以将汇率曲线向右移动,并使 vault 不安全。

没有保护的通货膨胀攻击

图 6 显示了攻击者如何操纵空 vault 的汇率。首先,攻击者必须存入少量 tokens(1 个 token),然后直接向 vault 捐赠 1e5 个 tokens 以将汇率“向右”移动。这会将 vault 置于这样一种状态:任何小于 1e5 的存款都将完全损失给 vault。鉴于攻击者是唯一的 shareholder(通过他们的捐赠),攻击者将窃取所有存入的 tokens。

攻击者通常会等待用户进行首次存款到 vault 中,并使用上述攻击来抢先执行该操作。风险很低,并且操纵 vault 所需的“捐赠”大小相当于被攻击存款的大小。

用数学表示为:

  • \(a_0\) 攻击者存款

  • \(a_1\) 攻击者捐赠

  • \(u\) 用户存款

Assets Shares 汇率

初始

\(0\)

\(0\)

-

攻击者存款后

\(a_0\)

\(a_0\)

\(1\)

攻击者捐赠后

\(a_0+a_1\)

\(a_0\)

\(\frac{a_0}{a_0+a_1}\)

这意味着 \(u\) 的存款将获得 \(\frac{u \times a_0}{a_0 + a_1}\) 个 shares。

为了让攻击者将该存款稀释为 0 shares,导致用户损失所有存款,攻击者必须确保

\[\frac{u \times a_0}{a_0+a_1} < 1 \iff u < 1 + \frac{a_1}{a_0}\]

使用 \(a_0 = 1\) 和 \(a_1 = u\) 就足够了。因此,攻击者只需要 \(u+1\) 个 assets 就可以成功发起攻击。

很容易将上述结果推广到攻击者想要获得用户存款较小比例的场景。为了瞄准 \(\frac{u}{n}\),用户需要遭受类似比例的四舍五入,这意味着用户最多必须收到 \(n\) 个 shares。这导致:

\[\frac{u \times a_0}{a_0+a_1} < n \iff \frac{u}{n} < 1 + \frac{a_1}{a_0}\]

在这种情况下,攻击的威力(在窃取多少方面)是原来的 \(n\) 分之一,并且执行成本也降低到原来的 \(n\) 分之一。在这两种情况下,攻击者需要投入的资金量都相当于其潜在收益。

使用虚拟偏移量进行防御

我们提出的防御基于 YieldBox 中使用的方法。它由两部分组成:

  • 使用 shares 和 assets 的表示“精度”之间的偏移量。换句话说,我们使用比底层 token 表示 assets 更多的小数位来表示 shares。

  • 在汇率计算中包括虚拟 shares 和虚拟 assets。这些虚拟 assets 在 vault 为空时强制执行转换率。

这两个部分协同工作以确保 vault 的安全性。首先,增加的精度对应于高汇率,我们看到高汇率更安全,因为它减少了计算 shares 数量时的四舍五入误差。其次,虚拟 assets 和 shares(除了简化大量计算之外)捕获了部分捐赠,使得执行攻击无利可图。

按照之前的数学定义,我们有:

  • \(\delta\) vault 偏移量

  • \(a_0\) 攻击者存款

  • \(a_1\) 攻击者捐赠

  • \(u\) 用户存款

Assets Shares 汇率

初始

\(1\)

\(10^\delta\)

\(10^\delta\)

攻击者存款后

\(1+a_0\)

\(10^\delta \times (1+a_0)\)

\(10^\delta\)

攻击者捐赠后

\(1+a_0+a_1\)

\(10^\delta \times (1+a_0)\)

\(10^\delta \times \frac{1+a_0}{1+a_0+a_1}\)

需要注意的重要一点是,攻击者只拥有 \(\frac{a_0}{1 + a_0}\) 部分的 shares,因此在进行捐赠时,他只能收回 \(\frac{a_1 \times a_0}{1 + a_0}\) 部分的捐赠。剩余的 \(\frac{a_1}{1+a_0}\) 部分由 vault 捕获。

\[\mathit{loss} = \frac{a_1}{1+a_0}\]

当用户存入 \(u\) 时,他收到

\[10^\delta \times u \times \frac{1+a_0}{1+a_0+a_1}\]

为了让攻击者将该存款稀释为 0 shares,导致用户损失所有存款,攻击者必须确保

\[10^\delta \times u \times \frac{1+a_0}{1+a_0+a_1} < 1\]
\[\iff 10^\delta \times u < \frac{1+a_0+a_1}{1+a_0}\]
\[\iff 10^\delta \times u < 1 + \frac{a_1}{1+a_0}\]
\[\iff 10^\delta \times u \le \mathit{loss}\]
  • 如果偏移量为 0,则攻击者的损失至少等于用户的存款。

  • 如果偏移量大于 0,则攻击者将遭受比可能从用户那里窃取的价值大几个数量级的损失。

这表明,即使偏移量为 0,虚拟 shares 和 assets 也会使这种攻击对攻击者来说无利可图。更大的偏移量会通过使用户的任何攻击都极其浪费来进一步提高安全性。

下图显示了偏移量如何影响初始汇率,并限制了资金有限的攻击者有效膨胀汇率的能力。

erc4626 attack 3a

\(\delta = 3\), \(a_0 = 1\), \(a_1 = 10^5\)

erc4626 attack 3b

\(\delta = 3\), \(a_0 = 100\), \(a_1 = 10^5\)

erc4626 attack 6

\(\delta = 6\), \(a_0 = 1\), \(a_1 = 10^5\)

用法

自定义行为:向 vault 添加费用

在 ERC4626 vaults 中,费用可以在 deposit/mint 期间和/或 withdraw/redeem 步骤期间收取。 在这两种情况下,必须保持符合 ERC4626 关于预览函数的要求。

例如,如果调用 deposit(100, receiver),则调用者应存入 100 个底层 tokens,包括费用,并且接收者应收到与 preview_deposit(100) 返回的值匹配的 shares 数量。 类似地,preview_mint 应该考虑用户将不得不支付的 shares 成本之上的费用。

至于 Deposit 事件,虽然这在 EIP 规范本身中不太清楚, 但似乎已达成共识,它应该包括用户支付的 assets 数量,包括费用。

另一方面,当提取 assets 时,用户给出的数字应该对应于用户收到的数字。 任何费用都应添加到 preview_withdraw 执行的报价(以 shares 为单位)中。

Withdraw 事件应该包括用户销毁的 shares 数量(包括费用)和用户实际收到的 assets 数量(扣除费用后)。

这种设计的结果是 DepositWithdraw 事件都将描述两种汇率。 “买入”价格和“退出”价格之间的差额对应于 vault 收取的费用。

以下示例描述了如何实现与存入/提取金额成比例的费用:

/// mock 合约以 assets 而不是 shares 计算费用。
/// 这意味着费用是根据正在存入或提取的 assets 数量计算的,
/// 而不是根据正在铸造或赎回的 shares 数量计算的。
/// 这是出于测试目的而做出的主观设计决定。
/// 不要在生产环境中使用
#[starknet::contract]
pub mod ERC4626Fees {
    use openzeppelin_token::erc20::extensions::erc4626::ERC4626Component;
    use openzeppelin_token::erc20::extensions::erc4626::ERC4626Component::FeeConfigTrait;
    use openzeppelin_token::erc20::extensions::erc4626::ERC4626Component::InternalTrait as ERC4626InternalTrait;
    use openzeppelin_token::erc20::extensions::erc4626::{DefaultConfig, ERC4626DefaultLimits};
    use openzeppelin_token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait};
    use openzeppelin_token::erc20::{ERC20Component, ERC20HooksEmptyImpl};
    use openzeppelin_utils::math;
    use openzeppelin_utils::math::Rounding;
    use starknet::ContractAddress;
    use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};

    component!(path: ERC4626Component, storage: erc4626, event: ERC4626Event);
    component!(path: ERC20Component, storage: erc20, event: ERC20Event);

    // ERC4626
    #[abi(embed_v0)]
    impl ERC4626ComponentImpl = ERC4626Component::ERC4626Impl<ContractState>;
    // ERC4626MetadataImpl 是 IERC20Metadata 的自定义实现
    #[abi(embed_v0)]
    impl ERC4626MetadataImpl = ERC4626Component::ERC4626MetadataImpl<ContractState>;

    // ERC20
    #[abi(embed_v0)]
    impl ERC20Impl = ERC20Component::ERC20Impl<ContractState>;
    #[abi(embed_v0)]
    impl ERC20CamelOnlyImpl = ERC20Component::ERC20CamelOnlyImpl<ContractState>;

    impl ERC4626InternalImpl = ERC4626Component::InternalImpl<ContractState>;
    impl ERC20InternalImpl = ERC20Component::InternalImpl<ContractState>;

    #[storage]
    pub struct Storage {
        #[substorage(v0)]
        pub erc4626: ERC4626Component::Storage,
        #[substorage(v0)]
        pub erc20: ERC20Component::Storage,
        pub entry_fee_basis_point_value: u256,
        pub entry_fee_recipient: ContractAddress,
        pub exit_fee_basis_point_value: u256,
        pub exit_fee_recipient: ContractAddress,
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        #[flat]
        ERC4626Event: ERC4626Component::Event,
        #[flat]
        ERC20Event: ERC20Component::Event,
    }

    const _BASIS_POINT_SCALE: u256 = 10_000;

    ///
    /// Hooks
    ///

    impl ERC4626HooksImpl of ERC4626Component::ERC4626HooksTrait<ContractState> {
        fn after_deposit(
            ref self: ERC4626Component::ComponentState<ContractState>, assets: u256, shares: u256,
        ) {
            let mut contract_state = self.get_contract_mut();
            let entry_basis_points = contract_state.entry_fee_basis_point_value.read();
            let fee = contract_state.fee_on_total(assets, entry_basis_points);
            let recipient = contract_state.entry_fee_recipient.read();

            if fee > 0 && recipient != starknet::get_contract_address() {
                contract_state.transfer_fees(recipient, fee);
            }
        }

        fn before_withdraw(
            ref self: ERC4626Component::ComponentState<ContractState>, assets: u256, shares: u256,
        ) {
            let mut contract_state = self.get_contract_mut();
            let exit_basis_points = contract_state.exit_fee_basis_point_value.read();
            let fee = contract_state.fee_on_raw(assets, exit_basis_points);
            let recipient = contract_state.exit_fee_recipient.read();

            if fee > 0 && recipient != starknet::get_contract_address() {
                contract_state.transfer_fees(recipient, fee);
            }
        }
    }

    /// 调整费用
    impl AdjustFeesImpl of FeeConfigTrait<ContractState> {
        fn adjust_deposit(
            self: @ERC4626Component::ComponentState<ContractState>, assets: u256,
        ) -> u256 {
            let contract_state = self.get_contract();
            contract_state.remove_fee_from_deposit(assets)
        }

        fn adjust_mint(
            self: @ERC4626Component::ComponentState<ContractState>, assets: u256,
        ) -> u256 {
            let contract_state = self.get_contract();
            contract_state.add_fee_to_mint(assets)
        }

        fn adjust_withdraw(
            self: @ERC4626Component::ComponentState<ContractState>, assets: u256,
        ) -> u256 {
            let contract_state = self.get_contract();
            contract_state.add_fee_to_withdraw(assets)
        }

        fn adjust_redeem(
            self: @ERC4626Component::ComponentState<ContractState>, assets: u256,
        ) -> u256 {
            let contract_state = self.get_contract();
            contract_state.remove_fee_from_redeem(assets)
        }
    }

    #[constructor]
    fn constructor(
        ref self: ContractState,
        name: ByteArray,
        symbol: ByteArray,
        underlying_asset: ContractAddress,
        initial_supply: u256,
        recipient: ContractAddress,
        entry_fee: u256,
        entry_treasury: ContractAddress,
        exit_fee: u256,
        exit_treasury: ContractAddress,
    ) {
        self.erc20.initializer(name, symbol);
        self.erc20.mint(recipient, initial_supply);
        self.erc4626.initializer(underlying_asset);

        self.entry_fee_basis_point_value.write(entry_fee);
        self.entry_fee_recipient.write(entry_treasury);
        self.exit_fee_basis_point_value.write(exit_fee);
        self.exit_fee_recipient.write(exit_treasury);
    }

    #[generate_trait]
    pub impl InternalImpl of InternalTrait {
        fn transfer_fees(ref self: ContractState, recipient: ContractAddress, fee: u256) {
            let asset_address = self.asset();
            let asset_dispatcher = IERC20Dispatcher { contract_address: asset_address };
            assert(asset_dispatcher.transfer(recipient, fee), 'Fee transfer failed');
        }

        fn remove_fee_from_deposit(self: @ContractState, assets: u256) -> u256 {
            let fee = self.fee_on_total(assets, self.entry_fee_basis_point_value.read());
            assets - fee
        }

        fn add_fee_to_mint(self: @ContractState, assets: u256) -> u256 {
            assets + self.fee_on_raw(assets, self.entry_fee_basis_point_value.read())
        }

        fn add_fee_to_withdraw(self: @ContractState, assets: u256) -> u256 {
            let fee = self.fee_on_raw(assets, self.exit_fee_basis_point_value.read());
            assets + fee
        }

        fn remove_fee_from_redeem(self: @ContractState, assets: u256) -> u256 {
            assets - self.fee_on_total(assets, self.exit_fee_basis_point_value.read())
        }

        ///
        /// 费用操作
        ///

        /// 计算应添加到尚未包含费用的金额 `assets` 中的费用。
        /// 用于 IERC4626::mint 和 IERC4626::withdraw 操作。
        fn fee_on_raw(self: @ContractState, assets: u256, fee_basis_points: u256) -> u256 {
            math::u256_mul_div(assets, fee_basis_points, _BASIS_POINT_SCALE, Rounding::Ceil)
        }

        /// 计算已包含费用的金额 `assets` 的费用部分。
        /// 用于 IERC4626::deposit 和 IERC4626::redeem 操作。
        fn fee_on_total(self: @ContractState, assets: u256, fee_basis_points: u256) -> u256 {
            math::u256_mul_div(
                assets, fee_basis_points, fee_basis_points + _BASIS_POINT_SCALE, Rounding::Ceil,
            )
        }
    }
}

接口

以下接口代表 Contracts for Cairo ERC4626Component 的完整 ABI。 完整接口包括 IERC4626IERC20IERC20Metadata 接口。 请注意,实现 IERC20Metadata 接口是 IERC4626 的一项要求。

#[starknet::interface]
pub trait ERC4626ABI {
    // IERC4626
    fn asset() -> ContractAddress;
    fn total_assets() -> u256;
    fn convert_to_shares(assets: u256) -> u256;
    fn convert_to_assets(shares: u256) -> u256;
    fn max_deposit(receiver: ContractAddress) -> u256;
    fn preview_deposit(assets: u256) -> u256;
    fn deposit(assets: u256, receiver: ContractAddress) -> u256;
    fn max_mint(receiver: ContractAddress) -> u256;
    fn preview_mint(shares: u256) -> u256;
    fn mint(shares: u256, receiver: ContractAddress) -> u256;
    fn max_withdraw(owner: ContractAddress) -> u256;
    fn preview_withdraw(assets: u256) -> u256;
    fn withdraw(
        assets: u256, receiver: ContractAddress, owner: ContractAddress,
    ) -> u256;
    fn max_redeem(owner: ContractAddress) -> u256;
    fn preview_redeem(shares: u256) -> u256;
    fn redeem(
        shares: u256, receiver: ContractAddress, owner: ContractAddress,
    ) -> u256;

    // IERC20
    fn total_supply() -> u256;
    fn balance_of(account: ContractAddress) -> u256;
    fn allowance(owner: ContractAddress, spender: ContractAddress) -> u256;
    fn transfer(recipient: ContractAddress, amount: u256) -> bool;
    fn transfer_from(
        sender: ContractAddress, recipient: ContractAddress, amount: u256,
    ) -> bool;
    fn approve(spender: ContractAddress, amount: u256) -> bool;

    // IERC20Metadata
    fn name() -> ByteArray;
    fn symbol() -> ByteArray;
    fn decimals() -> u8;

    // IERC20CamelOnly
    fn totalSupply() -> u256;
    fn balanceOf(account: ContractAddress) -> u256;
    fn transferFrom(
        sender: ContractAddress, recipient: ContractAddress, amount: u256,
    ) -> bool;
}