Please explain ERC4626Fees math

Two questions regarding the ERC4626Fees implementation in the Openzeppelin docs

  1. 1 basis point equals 0.01% = 0.01 / 100 = 10^4 = 1e4. Why divide with 1e5 instead if fee variables are called feeBasePoint?

  2. I don't get the formula in _feeOnTotal(). If fee is a percentage of deposit amount the formula should be amount * feeBps / 1e4. Why do you add feeBps in the denominator in amount * feeBps / (1e4 + feeBps)? This gives a different graph as opposed to a plain percentage fee.

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";

abstract contract ERC4626Fees is ERC4626 {
    using Math for uint256;

    /** @dev See {IERC4626-previewDeposit}. */
    function previewDeposit(uint256 assets) public view virtual override returns (uint256) {
        uint256 fee = _feeOnTotal(assets, _entryFeeBasePoint());
        return super.previewDeposit(assets - fee);
    }

    /** @dev See {IERC4626-previewMint}. */
    function previewMint(uint256 shares) public view virtual override returns (uint256) {
        uint256 assets = super.previewMint(shares);
        return assets + _feeOnRaw(assets, _entryFeeBasePoint());
    }

    /** @dev See {IERC4626-previewWithdraw}. */
    function previewWithdraw(uint256 assets) public view virtual override returns (uint256) {
        uint256 fee = _feeOnRaw(assets, _exitFeeBasePoint());
        return super.previewWithdraw(assets + fee);
    }

    /** @dev See {IERC4626-previewRedeem}. */
    function previewRedeem(uint256 shares) public view virtual override returns (uint256) {
        uint256 assets = super.previewRedeem(shares);
        return assets - _feeOnTotal(assets, _exitFeeBasePoint());
    }

    /** @dev See {IERC4626-_deposit}. */
    function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal virtual override {
        uint256 fee = _feeOnTotal(assets, _entryFeeBasePoint());
        address recipient = _entryFeeRecipient();

        super._deposit(caller, receiver, assets, shares);

        if (fee > 0 && recipient != address(this)) {
            SafeERC20.safeTransfer(IERC20(asset()), recipient, fee);
        }
    }

    /** @dev See {IERC4626-_deposit}. */
    function _withdraw(
        address caller,
        address receiver,
        address owner,
        uint256 assets,
        uint256 shares
    ) internal virtual override {
        uint256 fee = _feeOnRaw(assets, _exitFeeBasePoint());
        address recipient = _exitFeeRecipient();

        super._withdraw(caller, receiver, owner, assets, shares);

        if (fee > 0 && recipient != address(this)) {
            SafeERC20.safeTransfer(IERC20(asset()), recipient, fee);
        }
    }

    function _entryFeeBasePoint() internal view virtual returns (uint256) {
        return 0;
    }

    function _entryFeeRecipient() internal view virtual returns (address) {
        return address(0);
    }

    function _exitFeeBasePoint() internal view virtual returns (uint256) {
        return 0;
    }

    function _exitFeeRecipient() internal view virtual returns (address) {
        return address(0);
    }

    function _feeOnRaw(uint256 assets, uint256 feeBasePoint) private pure returns (uint256) {
        return assets.mulDiv(feeBasePoint, 1e5, Math.Rounding.Up);
    }

    function _feeOnTotal(uint256 assets, uint256 feeBasePoint) private pure returns (uint256) {
        return assets.mulDiv(feeBasePoint, feeBasePoint + 1e5, Math.Rounding.Up);
    }
}

:1234: Code to reproduce

NA

:computer: Environment

NA

Where exactly is this mentioned within the web page that you've linked?

BTW, the notion of 0.01 / 100 = 10^4 is obviously wrong, as 0.01 / 100 = 10^-4.

Hello @rollchad

First things first:

The naming is indeed confusing. The contract is not operating using bp, but pcm (which I believe is more common in DeFi). We should do one of the following:

  • rename the functions _entryFeeBasePoint and _exitFeeBasePoint to _entryFeePCM and _exitFeePCM.
  • replace 1e5 with 1e4 in _feeOnRaw and _feeOnTotal

Than there is the other thing, that deserves a "long" explanation

  • feeOnRaw is used in mint and withdraw opeartions. This is when we have a value without fees (in the case of mint the amount of share we want to get), and we add the fees on top of that (fees are a percentage of that value).
    For example, in the case of mint, lets say you want to mint 10 shares.

    • the super.previewMint will determine how much that cost you, let say each share is worth 3 tokens, that will thus return 30 tokens
    • on top of that we add the entry fee, lets say that is 10% (10000pcm), you'll end up with a fee of 3, pushing the total mint cost to 33.
    • In that case, fee = raw * pcm / 1e5 i.e. total = raw + fee = raw * (1 + pcm / 1e5) (rounded up, because any discount could be used to attack the vault.
  • feeOnTotal is used in deposit and redeem operations. This is when we know the total price (with the fee) and you want to know how much of that is actually available when fees are removed.
    For example, in the case of deposit (same number as before):

    • I'm depositing 33 tokens
    • feeOnTotal must discover what part of the 33 tokens are fees, and what part is actuall deposit that gives right to shares. For that we reuse the total = raw + fee = raw * (1 + pcm / 1e5) formula.
      • This gives us raw = total / (1 + pcm / 1e5) so fee = raw * (pcm / 1e5) = total * (pcm / 1e5) / (1 + pcm / 1e5). If you multiply both sides of the division by 1e5 you get fee = total * pcm / (1e5 + pcm) ... which indeed is what _feeOnTotal() computes
    • this allows us to compute raw = total - fees and put that into super.previewDeposit

I hope things are clearer with that explanation.

Do you have any examples to back this up?

I think I remember seeing 1e5, but I have no link to back that claim right now.


EDIT:

  • I searched for / 1e5 and / 1e4 in the smart contract sanctuary. 1e5 gives 252 result and 1e4 gives 373.

  • when looking for 1e5 and 1e4 (with a space to not match longer hex strings such as addresses or typehash) I get 825 and 1964

So I guess BP is possibly more common than PCM, but not by a huge margin.