Hadrien Croubois 3 years ago
parent
commit
5e00787199

+ 2 - 0
CHANGELOG.md

@@ -6,6 +6,8 @@
  * `TimelockController`: Migrate `_call` to `_execute` and allow inheritance and overriding similar to `Governor`. ([#3317](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/3317))
  * `CrossChainEnabledPolygonChild`: replace the `require` statement with the custom error `NotCrossChainCall`. ([#3380](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/3380))
  * `ERC20FlashMint`: Add customizable flash fee receiver. ([#3327](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/3327))
+ * `ERC20TokenizedVault`: add an extension of `ERC20` that implements the ERC4626 Tokenized Vault Standard. ([#3171](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/3171))
+ * `Math`: add a `mulDiv` function that can round the result either up or down. ([#3171](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/3171))
  * `Strings`: add a new overloaded function `toHexString` that converts an `address` with fixed length of 20 bytes to its not checksummed ASCII `string` hexadecimal representation. ([#3403](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/3403))
  * `EnumerableMap`: add new `UintToUintMap` map type. ([#3338](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/3338))
  * `EnumerableMap`: add new `Bytes32ToUintMap` map type. ([#3416](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/3416))

+ 239 - 0
contracts/interfaces/IERC4626.sol

@@ -0,0 +1,239 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.0;
+
+import "../token/ERC20/IERC20.sol";
+import "../token/ERC20/extensions/IERC20Metadata.sol";
+
+/**
+ * @dev Interface of the ERC4626 "Tokenized Vault Standard", as defined in
+ * https://eips.ethereum.org/EIPS/eip-4626[ERC-4626].
+ *
+ * _Available since v4.7._
+ */
+interface IERC4626 is IERC20, IERC20Metadata {
+    event Deposit(address indexed caller, address indexed owner, uint256 assets, uint256 shares);
+
+    event Withdraw(
+        address indexed caller,
+        address indexed receiver,
+        address indexed owner,
+        uint256 assets,
+        uint256 shares
+    );
+
+    /**
+     * @dev Returns the address of the underlying token used for the Vault for accounting, depositing, and withdrawing.
+     *
+     * - MUST be an ERC-20 token contract.
+     * - MUST NOT revert.
+     */
+    function asset() external view returns (address assetTokenAddress);
+
+    /**
+     * @dev Returns the total amount of the underlying asset that is “managed” by Vault.
+     *
+     * - SHOULD include any compounding that occurs from yield.
+     * - MUST be inclusive of any fees that are charged against assets in the Vault.
+     * - MUST NOT revert.
+     */
+    function totalAssets() external view returns (uint256 totalManagedAssets);
+
+    /**
+     * @dev 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.
+     *
+     * - MUST NOT be inclusive of any fees that are charged against assets in the Vault.
+     * - MUST NOT show any variations depending on the caller.
+     * - MUST NOT reflect slippage or other on-chain conditions, when performing the actual exchange.
+     * - MUST NOT revert.
+     *
+     * NOTE: This calculation MAY NOT reflect the “per-user” price-per-share, and instead should reflect the
+     * “average-user’s” price-per-share, meaning what the average user should expect to see when exchanging to and
+     * from.
+     */
+    function convertToShares(uint256 assets) external view returns (uint256 shares);
+
+    /**
+     * @dev Returns the amount of assets that the Vault would exchange for the amount of shares provided, in an ideal
+     * scenario where all the conditions are met.
+     *
+     * - MUST NOT be inclusive of any fees that are charged against assets in the Vault.
+     * - MUST NOT show any variations depending on the caller.
+     * - MUST NOT reflect slippage or other on-chain conditions, when performing the actual exchange.
+     * - MUST NOT revert.
+     *
+     * NOTE: This calculation MAY NOT reflect the “per-user” price-per-share, and instead should reflect the
+     * “average-user’s” price-per-share, meaning what the average user should expect to see when exchanging to and
+     * from.
+     */
+    function convertToAssets(uint256 shares) external view returns (uint256 assets);
+
+    /**
+     * @dev Returns the maximum amount of the underlying asset that can be deposited into the Vault for the receiver,
+     * through a deposit call.
+     *
+     * - MUST return a limited value if receiver is subject to some deposit limit.
+     * - MUST return 2 ** 256 - 1 if there is no limit on the maximum amount of assets that may be deposited.
+     * - MUST NOT revert.
+     */
+    function maxDeposit(address receiver) external view returns (uint256 maxAssets);
+
+    /**
+     * @dev Allows an on-chain or off-chain user to simulate the effects of their deposit at the current block, given
+     * current on-chain conditions.
+     *
+     * - MUST return as close to and no more than the exact amount of Vault shares that would be minted in a deposit
+     *   call in the same transaction. I.e. deposit should return the same or more shares as previewDeposit if called
+     *   in the same transaction.
+     * - MUST NOT account for deposit limits like those returned from maxDeposit and should always act as though the
+     *   deposit would be accepted, regardless if the user has enough tokens approved, etc.
+     * - MUST be inclusive of deposit fees. Integrators should be aware of the existence of deposit fees.
+     * - MUST NOT revert.
+     *
+     * NOTE: any unfavorable discrepancy between convertToShares and previewDeposit SHOULD be considered slippage in
+     * share price or some other type of condition, meaning the depositor will lose assets by depositing.
+     */
+    function previewDeposit(uint256 assets) external view returns (uint256 shares);
+
+    /**
+     * @dev Mints shares Vault shares to receiver by depositing exactly amount of underlying tokens.
+     *
+     * - MUST emit the Deposit event.
+     * - MAY support an additional flow in which the underlying tokens are owned by the Vault contract before the
+     *   deposit execution, and are accounted for during deposit.
+     * - MUST revert if all of assets cannot be deposited (due to deposit limit being reached, slippage, the user not
+     *   approving enough underlying tokens to the Vault contract, etc).
+     *
+     * NOTE: most implementations will require pre-approval of the Vault with the Vault’s underlying asset token.
+     */
+    function deposit(uint256 assets, address receiver) external returns (uint256 shares);
+
+    /**
+     * @dev Returns the maximum amount of the Vault shares that can be minted for the receiver, through a mint call.
+     * - MUST return a limited value if receiver is subject to some mint limit.
+     * - MUST return 2 ** 256 - 1 if there is no limit on the maximum amount of shares that may be minted.
+     * - MUST NOT revert.
+     */
+    function maxMint(address receiver) external view returns (uint256 maxShares);
+
+    /**
+     * @dev Allows an on-chain or off-chain user to simulate the effects of their mint at the current block, given
+     * current on-chain conditions.
+     *
+     * - MUST return as close to and no fewer than the exact amount of assets that would be deposited in a mint call
+     *   in the same transaction. I.e. mint should return the same or fewer assets as previewMint if called in the
+     *   same transaction.
+     * - MUST NOT account for mint limits like those returned from maxMint and should always act as though the mint
+     *   would be accepted, regardless if the user has enough tokens approved, etc.
+     * - MUST be inclusive of deposit fees. Integrators should be aware of the existence of deposit fees.
+     * - MUST NOT revert.
+     *
+     * NOTE: any unfavorable discrepancy between convertToAssets and previewMint SHOULD be considered slippage in
+     * share price or some other type of condition, meaning the depositor will lose assets by minting.
+     */
+    function previewMint(uint256 shares) external view returns (uint256 assets);
+
+    /**
+     * @dev Mints exactly shares Vault shares to receiver by depositing amount of underlying tokens.
+     *
+     * - MUST emit the Deposit event.
+     * - MAY support an additional flow in which the underlying tokens are owned by the Vault contract before the mint
+     *   execution, and are accounted for during mint.
+     * - MUST revert if all of shares cannot be minted (due to deposit limit being reached, slippage, the user not
+     *   approving enough underlying tokens to the Vault contract, etc).
+     *
+     * NOTE: most implementations will require pre-approval of the Vault with the Vault’s underlying asset token.
+     */
+    function mint(uint256 shares, address receiver) external returns (uint256 assets);
+
+    /**
+     * @dev Returns the maximum amount of the underlying asset that can be withdrawn from the owner balance in the
+     * Vault, through a withdraw call.
+     *
+     * - MUST return a limited value if owner is subject to some withdrawal limit or timelock.
+     * - MUST NOT revert.
+     */
+    function maxWithdraw(address owner) external view returns (uint256 maxAssets);
+
+    /**
+     * @dev Allows an on-chain or off-chain user to simulate the effects of their withdrawal at the current block,
+     * given current on-chain conditions.
+     *
+     * - MUST return as close to and no fewer than the exact amount of Vault shares that would be burned in a withdraw
+     *   call in the same transaction. I.e. withdraw should return the same or fewer shares as previewWithdraw if
+     *   called
+     *   in the same transaction.
+     * - MUST NOT account for withdrawal limits like those returned from maxWithdraw and should always act as though
+     *   the withdrawal would be accepted, regardless if the user has enough shares, etc.
+     * - MUST be inclusive of withdrawal fees. Integrators should be aware of the existence of withdrawal fees.
+     * - MUST NOT revert.
+     *
+     * NOTE: any unfavorable discrepancy between convertToShares and previewWithdraw SHOULD be considered slippage in
+     * share price or some other type of condition, meaning the depositor will lose assets by depositing.
+     */
+    function previewWithdraw(uint256 assets) external view returns (uint256 shares);
+
+    /**
+     * @dev Burns shares from owner and sends exactly assets of underlying tokens to receiver.
+     *
+     * - MUST emit the Withdraw event.
+     * - MAY support an additional flow in which the underlying tokens are owned by the Vault contract before the
+     *   withdraw execution, and are accounted for during withdraw.
+     * - MUST revert if all of assets cannot be withdrawn (due to withdrawal limit being reached, slippage, the owner
+     *   not having enough shares, etc).
+     *
+     * Note that some implementations will require pre-requesting to the Vault before a withdrawal may be performed.
+     * Those methods should be performed separately.
+     */
+    function withdraw(
+        uint256 assets,
+        address receiver,
+        address owner
+    ) external returns (uint256 shares);
+
+    /**
+     * @dev Returns the maximum amount of Vault shares that can be redeemed from the owner balance in the Vault,
+     * through a redeem call.
+     *
+     * - MUST return a limited value if owner is subject to some withdrawal limit or timelock.
+     * - MUST return balanceOf(owner) if owner is not subject to any withdrawal limit or timelock.
+     * - MUST NOT revert.
+     */
+    function maxRedeem(address owner) external view returns (uint256 maxShares);
+
+    /**
+     * @dev Allows an on-chain or off-chain user to simulate the effects of their redeemption at the current block,
+     * given current on-chain conditions.
+     *
+     * - MUST return as close to and no more than the exact amount of assets that would be withdrawn in a redeem call
+     *   in the same transaction. I.e. redeem should return the same or more assets as previewRedeem if called in the
+     *   same transaction.
+     * - MUST NOT account for redemption limits like those returned from maxRedeem and should always act as though the
+     *   redemption would be accepted, regardless if the user has enough shares, etc.
+     * - MUST be inclusive of withdrawal fees. Integrators should be aware of the existence of withdrawal fees.
+     * - MUST NOT revert.
+     *
+     * NOTE: any unfavorable discrepancy between convertToAssets and previewRedeem SHOULD be considered slippage in
+     * share price or some other type of condition, meaning the depositor will lose assets by redeeming.
+     */
+    function previewRedeem(uint256 shares) external view returns (uint256 assets);
+
+    /**
+     * @dev Burns exactly shares from owner and sends assets of underlying tokens to receiver.
+     *
+     * - MUST emit the Withdraw event.
+     * - MAY support an additional flow in which the underlying tokens are owned by the Vault contract before the
+     *   redeem execution, and are accounted for during redeem.
+     * - MUST revert if all of shares cannot be redeemed (due to withdrawal limit being reached, slippage, the owner
+     *   not having enough shares, etc).
+     *
+     * NOTE: some implementations will require pre-requesting to the Vault before a withdrawal may be performed.
+     * Those methods should be performed separately.
+     */
+    function redeem(
+        uint256 shares,
+        address receiver,
+        address owner
+    ) external returns (uint256 assets);
+}

+ 22 - 0
contracts/mocks/ERC20TokenizedVaultMock.sol

@@ -0,0 +1,22 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.0;
+
+import "../token/ERC20/extensions/ERC20TokenizedVault.sol";
+
+// mock class using ERC20
+contract ERC20TokenizedVaultMock is ERC20TokenizedVault {
+    constructor(
+        IERC20Metadata asset,
+        string memory name,
+        string memory symbol
+    ) ERC20(name, symbol) ERC20TokenizedVault(asset) {}
+
+    function mockMint(address account, uint256 amount) public {
+        _mint(account, amount);
+    }
+
+    function mockBurn(address account, uint256 amount) public {
+        _burn(account, amount);
+    }
+}

+ 9 - 0
contracts/mocks/MathMock.sol

@@ -20,4 +20,13 @@ contract MathMock {
     function ceilDiv(uint256 a, uint256 b) public pure returns (uint256) {
         return Math.ceilDiv(a, b);
     }
+
+    function mulDiv(
+        uint256 a,
+        uint256 b,
+        uint256 denominator,
+        Math.Rounding direction
+    ) public pure returns (uint256) {
+        return Math.mulDiv(a, b, denominator, direction);
+    }
 }

+ 3 - 0
contracts/token/ERC20/README.adoc

@@ -24,6 +24,7 @@ Additionally there are multiple custom extensions, including:
 * {ERC20Votes}: support for voting and vote delegation.
 * {ERC20VotesComp}: support for voting and vote delegation (compatible with Compound's token, with uint96 restrictions).
 * {ERC20Wrapper}: wrapper to create an ERC20 backed by another ERC20, with deposit and withdraw methods. Useful in conjunction with {ERC20Votes}.
+* {ERC20TokenizedVault}: tokenized vault that manages shares (represented as ERC20) that are backed by assets (another ERC20).
 
 Finally, there are some utilities to interact with ERC20 contracts in various ways.
 
@@ -62,6 +63,8 @@ NOTE: This core set of contracts is designed to be unopinionated, allowing devel
 
 {{ERC20FlashMint}}
 
+{{ERC20TokenizedVault}}
+
 == Draft EIPs
 
 The following EIPs are still in Draft status. Due to their nature as drafts, the details of these contracts may change and we cannot guarantee their xref:ROOT:releases-stability.adoc[stability]. Minor releases of OpenZeppelin Contracts may contain breaking changes for the contracts in this directory, which will be duly announced in the https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/CHANGELOG.md[changelog]. The EIPs included here are used by projects in production and this may make them less likely to change significantly.

+ 217 - 0
contracts/token/ERC20/extensions/ERC20TokenizedVault.sol

@@ -0,0 +1,217 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.0;
+
+import "../ERC20.sol";
+import "../utils/SafeERC20.sol";
+import "../../../interfaces/IERC4626.sol";
+import "../../../utils/math/Math.sol";
+
+/**
+ * @dev Implementation of the ERC4626 "Tokenized Vault Standard" as defined in
+ * https://eips.ethereum.org/EIPS/eip-4626[EIP-4626].
+ *
+ * This extension allows the minting and burning of "shares" (represented using the ERC20 inheritance) in exchange for
+ * underlying "assets" through standardized {deposit}, {mint}, {redeem} and {burn} workflows. This contract extends
+ * the ERC20 standard. Any additional extensions included along it would affect the "shares" token represented by this
+ * contract and not the "assets" token which is an independent contract.
+ *
+ * _Available since v4.7._
+ */
+abstract contract ERC20TokenizedVault is ERC20, IERC4626 {
+    using Math for uint256;
+
+    IERC20Metadata private immutable _asset;
+
+    /**
+     * @dev Set the underlying asset contract. This must be an ERC20-compatible contract (ERC20 or ERC777).
+     */
+    constructor(IERC20Metadata asset_) {
+        _asset = asset_;
+    }
+
+    /** @dev See {IERC4262-asset} */
+    function asset() public view virtual override returns (address) {
+        return address(_asset);
+    }
+
+    /** @dev See {IERC4262-totalAssets} */
+    function totalAssets() public view virtual override returns (uint256) {
+        return _asset.balanceOf(address(this));
+    }
+
+    /** @dev See {IERC4262-convertToShares} */
+    function convertToShares(uint256 assets) public view virtual override returns (uint256 shares) {
+        return _convertToShares(assets, Math.Rounding.Down);
+    }
+
+    /** @dev See {IERC4262-convertToAssets} */
+    function convertToAssets(uint256 shares) public view virtual override returns (uint256 assets) {
+        return _convertToAssets(shares, Math.Rounding.Down);
+    }
+
+    /** @dev See {IERC4262-maxDeposit} */
+    function maxDeposit(address) public view virtual override returns (uint256) {
+        return _isVaultCollateralized() ? type(uint256).max : 0;
+    }
+
+    /** @dev See {IERC4262-maxMint} */
+    function maxMint(address) public view virtual override returns (uint256) {
+        return type(uint256).max;
+    }
+
+    /** @dev See {IERC4262-maxWithdraw} */
+    function maxWithdraw(address owner) public view virtual override returns (uint256) {
+        return _convertToAssets(balanceOf(owner), Math.Rounding.Down);
+    }
+
+    /** @dev See {IERC4262-maxRedeem} */
+    function maxRedeem(address owner) public view virtual override returns (uint256) {
+        return balanceOf(owner);
+    }
+
+    /** @dev See {IERC4262-previewDeposit} */
+    function previewDeposit(uint256 assets) public view virtual override returns (uint256) {
+        return _convertToShares(assets, Math.Rounding.Down);
+    }
+
+    /** @dev See {IERC4262-previewMint} */
+    function previewMint(uint256 shares) public view virtual override returns (uint256) {
+        return _convertToAssets(shares, Math.Rounding.Up);
+    }
+
+    /** @dev See {IERC4262-previewWithdraw} */
+    function previewWithdraw(uint256 assets) public view virtual override returns (uint256) {
+        return _convertToShares(assets, Math.Rounding.Up);
+    }
+
+    /** @dev See {IERC4262-previewRedeem} */
+    function previewRedeem(uint256 shares) public view virtual override returns (uint256) {
+        return _convertToAssets(shares, Math.Rounding.Down);
+    }
+
+    /** @dev See {IERC4262-deposit} */
+    function deposit(uint256 assets, address receiver) public virtual override returns (uint256) {
+        require(assets <= maxDeposit(receiver), "ERC20TokenizedVault: deposit more than max");
+
+        uint256 shares = previewDeposit(assets);
+        _deposit(_msgSender(), receiver, assets, shares);
+
+        return shares;
+    }
+
+    /** @dev See {IERC4262-mint} */
+    function mint(uint256 shares, address receiver) public virtual override returns (uint256) {
+        require(shares <= maxMint(receiver), "ERC20TokenizedVault: mint more than max");
+
+        uint256 assets = previewMint(shares);
+        _deposit(_msgSender(), receiver, assets, shares);
+
+        return assets;
+    }
+
+    /** @dev See {IERC4262-withdraw} */
+    function withdraw(
+        uint256 assets,
+        address receiver,
+        address owner
+    ) public virtual override returns (uint256) {
+        require(assets <= maxWithdraw(owner), "ERC20TokenizedVault: withdraw more than max");
+
+        uint256 shares = previewWithdraw(assets);
+        _withdraw(_msgSender(), receiver, owner, assets, shares);
+
+        return shares;
+    }
+
+    /** @dev See {IERC4262-redeem} */
+    function redeem(
+        uint256 shares,
+        address receiver,
+        address owner
+    ) public virtual override returns (uint256) {
+        require(shares <= maxRedeem(owner), "ERC20TokenizedVault: redeem more than max");
+
+        uint256 assets = previewRedeem(shares);
+        _withdraw(_msgSender(), receiver, owner, assets, shares);
+
+        return assets;
+    }
+
+    /**
+     * @dev Internal convertion function (from assets to shares) with support for rounding direction
+     *
+     * Will revert if assets > 0, totalSupply > 0 and totalAssets = 0. That corresponds to a case where any asset
+     * would represent an infinite amout of shares.
+     */
+    function _convertToShares(uint256 assets, Math.Rounding rounding) internal view virtual returns (uint256 shares) {
+        uint256 supply = totalSupply();
+        return
+            (assets == 0 || supply == 0)
+                ? assets.mulDiv(10**decimals(), 10**_asset.decimals(), rounding)
+                : assets.mulDiv(supply, totalAssets(), rounding);
+    }
+
+    /**
+     * @dev Internal convertion function (from shares to assets) with support for rounding direction
+     */
+    function _convertToAssets(uint256 shares, Math.Rounding rounding) internal view virtual returns (uint256 assets) {
+        uint256 supply = totalSupply();
+        return
+            (supply == 0)
+                ? shares.mulDiv(10**_asset.decimals(), 10**decimals(), rounding)
+                : shares.mulDiv(totalAssets(), supply, rounding);
+    }
+
+    /**
+     * @dev Deposit/mint common workflow
+     */
+    function _deposit(
+        address caller,
+        address receiver,
+        uint256 assets,
+        uint256 shares
+    ) private {
+        // If _asset is ERC777, `transferFrom` can trigger a reenterancy BEFORE the transfer happens through the
+        // `tokensToSend` hook. On the other hand, the `tokenReceived` hook, that is triggered after the transfer,
+        // calls the vault, which is assumed not malicious.
+        //
+        // Conclusion: we need to do the transfer before we mint so that any reentrancy would happen before the
+        // assets are transfered and before the shares are minted, which is a valid state.
+        // slither-disable-next-line reentrancy-no-eth
+        SafeERC20.safeTransferFrom(_asset, caller, address(this), assets);
+        _mint(receiver, shares);
+
+        emit Deposit(caller, receiver, assets, shares);
+    }
+
+    /**
+     * @dev Withdraw/redeem common workflow
+     */
+    function _withdraw(
+        address caller,
+        address receiver,
+        address owner,
+        uint256 assets,
+        uint256 shares
+    ) private {
+        if (caller != owner) {
+            _spendAllowance(owner, caller, shares);
+        }
+
+        // If _asset is ERC777, `transfer` can trigger trigger a reentrancy AFTER the transfer happens through the
+        // `tokensReceived` hook. On the other hand, the `tokensToSend` hook, that is triggered before the transfer,
+        // calls the vault, which is assumed not malicious.
+        //
+        // Conclusion: we need to do the transfer after the burn so that any reentrancy would happen after the
+        // shares are burned and after the assets are transfered, which is a valid state.
+        _burn(owner, shares);
+        SafeERC20.safeTransfer(_asset, receiver, assets);
+
+        emit Withdraw(caller, receiver, owner, assets, shares);
+    }
+
+    function _isVaultCollateralized() private view returns (bool) {
+        return totalAssets() > 0 || totalSupply() == 0;
+    }
+}

+ 110 - 1
contracts/utils/math/Math.sol

@@ -7,6 +7,12 @@ pragma solidity ^0.8.0;
  * @dev Standard math utilities missing in the Solidity language.
  */
 library Math {
+    enum Rounding {
+        Down, // Toward negative infinity
+        Up, // Toward infinity
+        Zero // Toward zero
+    }
+
     /**
      * @dev Returns the largest of two numbers.
      */
@@ -38,6 +44,109 @@ library Math {
      */
     function ceilDiv(uint256 a, uint256 b) internal pure returns (uint256) {
         // (a + b - 1) / b can overflow on addition, so we distribute.
-        return a / b + (a % b == 0 ? 0 : 1);
+        return a == 0 ? 0 : (a - 1) / b + 1;
+    }
+
+    /**
+     * @notice Calculates floor(x * y / denominator) with full precision. Throws if result overflows a uint256 or denominator == 0
+     * @dev Original credit to Remco Bloemen under MIT license (https://xn--2-umb.com/21/muldiv)
+     * with further edits by Uniswap Labs also under MIT license.
+     */
+    function mulDiv(
+        uint256 x,
+        uint256 y,
+        uint256 denominator
+    ) internal pure returns (uint256 result) {
+        unchecked {
+            // 512-bit multiply [prod1 prod0] = x * y. Compute the product mod 2^256 and mod 2^256 - 1, then use
+            // use the Chinese Remainder Theorem to reconstruct the 512 bit result. The result is stored in two 256
+            // variables such that product = prod1 * 2^256 + prod0.
+            uint256 prod0; // Least significant 256 bits of the product
+            uint256 prod1; // Most significant 256 bits of the product
+            assembly {
+                let mm := mulmod(x, y, not(0))
+                prod0 := mul(x, y)
+                prod1 := sub(sub(mm, prod0), lt(mm, prod0))
+            }
+
+            // Handle non-overflow cases, 256 by 256 division.
+            if (prod1 == 0) {
+                return prod0 / denominator;
+            }
+
+            // Make sure the result is less than 2^256. Also prevents denominator == 0.
+            require(denominator > prod1);
+
+            ///////////////////////////////////////////////
+            // 512 by 256 division.
+            ///////////////////////////////////////////////
+
+            // Make division exact by subtracting the remainder from [prod1 prod0].
+            uint256 remainder;
+            assembly {
+                // Compute remainder using mulmod.
+                remainder := mulmod(x, y, denominator)
+
+                // Subtract 256 bit number from 512 bit number.
+                prod1 := sub(prod1, gt(remainder, prod0))
+                prod0 := sub(prod0, remainder)
+            }
+
+            // Factor powers of two out of denominator and compute largest power of two divisor of denominator. Always >= 1.
+            // See https://cs.stackexchange.com/q/138556/92363.
+
+            // Does not overflow because the denominator cannot be zero at this stage in the function.
+            uint256 twos = denominator & (~denominator + 1);
+            assembly {
+                // Divide denominator by twos.
+                denominator := div(denominator, twos)
+
+                // Divide [prod1 prod0] by twos.
+                prod0 := div(prod0, twos)
+
+                // Flip twos such that it is 2^256 / twos. If twos is zero, then it becomes one.
+                twos := add(div(sub(0, twos), twos), 1)
+            }
+
+            // Shift in bits from prod1 into prod0.
+            prod0 |= prod1 * twos;
+
+            // Invert denominator mod 2^256. Now that denominator is an odd number, it has an inverse modulo 2^256 such
+            // that denominator * inv = 1 mod 2^256. Compute the inverse by starting with a seed that is correct for
+            // four bits. That is, denominator * inv = 1 mod 2^4.
+            uint256 inverse = (3 * denominator) ^ 2;
+
+            // Use the Newton-Raphson iteration to improve the precision. Thanks to Hensel's lifting lemma, this also works
+            // in modular arithmetic, doubling the correct bits in each step.
+            inverse *= 2 - denominator * inverse; // inverse mod 2^8
+            inverse *= 2 - denominator * inverse; // inverse mod 2^16
+            inverse *= 2 - denominator * inverse; // inverse mod 2^32
+            inverse *= 2 - denominator * inverse; // inverse mod 2^64
+            inverse *= 2 - denominator * inverse; // inverse mod 2^128
+            inverse *= 2 - denominator * inverse; // inverse mod 2^256
+
+            // Because the division is now exact we can divide by multiplying with the modular inverse of denominator.
+            // This will give us the correct result modulo 2^256. Since the preconditions guarantee that the outcome is
+            // less than 2^256, this is the final result. We don't need to compute the high bits of the result and prod1
+            // is no longer required.
+            result = prod0 * inverse;
+            return result;
+        }
+    }
+
+    /**
+     * @notice Calculates x * y / denominator with full precision, following the selected rounding direction.
+     */
+    function mulDiv(
+        uint256 x,
+        uint256 y,
+        uint256 denominator,
+        Rounding rounding
+    ) internal pure returns (uint256) {
+        uint256 result = mulDiv(x, y, denominator);
+        if (rounding == Rounding.Up && mulmod(x, y, denominator) > 0) {
+            result += 1;
+        }
+        return result;
     }
 }

+ 5 - 0
test/helpers/enums.js

@@ -21,4 +21,9 @@ module.exports = {
     'For',
     'Abstain',
   ),
+  Rounding: Enum(
+    'Down',
+    'Up',
+    'Zero',
+  ),
 };

+ 612 - 0
test/token/ERC20/extensions/ERC20TokenizedVault.test.js

@@ -0,0 +1,612 @@
+const { BN, constants, expectEvent, expectRevert } = require('@openzeppelin/test-helpers');
+const { expect } = require('chai');
+
+const ERC20DecimalsMock = artifacts.require('ERC20DecimalsMock');
+const ERC20TokenizedVaultMock = artifacts.require('ERC20TokenizedVaultMock');
+
+const parseToken = (token) => (new BN(token)).mul(new BN('1000000000000'));
+const parseShare = (share) => (new BN(share)).mul(new BN('1000000000000000000'));
+
+contract('ERC20TokenizedVault', function (accounts) {
+  const [ holder, recipient, spender, other, user1, user2 ] = accounts;
+
+  const name = 'My Token';
+  const symbol = 'MTKN';
+
+  beforeEach(async function () {
+    this.token = await ERC20DecimalsMock.new(name, symbol, 12);
+    this.vault = await ERC20TokenizedVaultMock.new(this.token.address, name + ' Vault', symbol + 'V');
+
+    await this.token.mint(holder, web3.utils.toWei('100'));
+    await this.token.approve(this.vault.address, constants.MAX_UINT256, { from: holder });
+    await this.vault.approve(spender, constants.MAX_UINT256, { from: holder });
+  });
+
+  it('metadata', async function () {
+    expect(await this.vault.name()).to.be.equal(name + ' Vault');
+    expect(await this.vault.symbol()).to.be.equal(symbol + 'V');
+    expect(await this.vault.asset()).to.be.equal(this.token.address);
+  });
+
+  describe('empty vault: no assets & no shares', function () {
+    it('status', async function () {
+      expect(await this.vault.totalAssets()).to.be.bignumber.equal('0');
+    });
+
+    it('deposit', async function () {
+      expect(await this.vault.maxDeposit(holder)).to.be.bignumber.equal(constants.MAX_UINT256);
+      expect(await this.vault.previewDeposit(parseToken(1))).to.be.bignumber.equal(parseShare(1));
+
+      const { tx } = await this.vault.deposit(parseToken(1), recipient, { from: holder });
+
+      expectEvent.inTransaction(tx, this.token, 'Transfer', {
+        from: holder,
+        to: this.vault.address,
+        value: parseToken(1),
+      });
+
+      expectEvent.inTransaction(tx, this.vault, 'Transfer', {
+        from: constants.ZERO_ADDRESS,
+        to: recipient,
+        value: parseShare(1),
+      });
+    });
+
+    it('mint', async function () {
+      expect(await this.vault.maxMint(holder)).to.be.bignumber.equal(constants.MAX_UINT256);
+      expect(await this.vault.previewMint(parseShare(1))).to.be.bignumber.equal(parseToken(1));
+
+      const { tx } = await this.vault.mint(parseShare(1), recipient, { from: holder });
+
+      expectEvent.inTransaction(tx, this.token, 'Transfer', {
+        from: holder,
+        to: this.vault.address,
+        value: parseToken(1),
+      });
+
+      expectEvent.inTransaction(tx, this.vault, 'Transfer', {
+        from: constants.ZERO_ADDRESS,
+        to: recipient,
+        value: parseShare(1),
+      });
+    });
+
+    it('withdraw', async function () {
+      expect(await this.vault.maxWithdraw(holder)).to.be.bignumber.equal('0');
+      expect(await this.vault.previewWithdraw('0')).to.be.bignumber.equal('0');
+
+      const { tx } = await this.vault.withdraw('0', recipient, holder, { from: holder });
+
+      expectEvent.inTransaction(tx, this.token, 'Transfer', {
+        from: this.vault.address,
+        to: recipient,
+        value: '0',
+      });
+
+      expectEvent.inTransaction(tx, this.vault, 'Transfer', {
+        from: holder,
+        to: constants.ZERO_ADDRESS,
+        value: '0',
+      });
+    });
+
+    it('redeem', async function () {
+      expect(await this.vault.maxRedeem(holder)).to.be.bignumber.equal('0');
+      expect(await this.vault.previewRedeem('0')).to.be.bignumber.equal('0');
+
+      const { tx } = await this.vault.redeem('0', recipient, holder, { from: holder });
+
+      expectEvent.inTransaction(tx, this.token, 'Transfer', {
+        from: this.vault.address,
+        to: recipient,
+        value: '0',
+      });
+
+      expectEvent.inTransaction(tx, this.vault, 'Transfer', {
+        from: holder,
+        to: constants.ZERO_ADDRESS,
+        value: '0',
+      });
+    });
+  });
+
+  describe('partially empty vault: assets & no shares', function () {
+    beforeEach(async function () {
+      await this.token.mint(this.vault.address, parseToken(1)); // 1 token
+    });
+
+    it('status', async function () {
+      expect(await this.vault.totalAssets()).to.be.bignumber.equal(parseToken(1));
+    });
+
+    it('deposit', async function () {
+      expect(await this.vault.maxDeposit(holder)).to.be.bignumber.equal(constants.MAX_UINT256);
+      expect(await this.vault.previewDeposit(parseToken(1))).to.be.bignumber.equal(parseShare(1));
+
+      const { tx } = await this.vault.deposit(parseToken(1), recipient, { from: holder });
+
+      expectEvent.inTransaction(tx, this.token, 'Transfer', {
+        from: holder,
+        to: this.vault.address,
+        value: parseToken(1),
+      });
+
+      expectEvent.inTransaction(tx, this.vault, 'Transfer', {
+        from: constants.ZERO_ADDRESS,
+        to: recipient,
+        value: parseShare(1),
+      });
+    });
+
+    it('mint', async function () {
+      expect(await this.vault.maxMint(holder)).to.be.bignumber.equal(constants.MAX_UINT256);
+      expect(await this.vault.previewMint(parseShare(1))).to.be.bignumber.equal(parseToken(1));
+
+      const { tx } = await this.vault.mint(parseShare(1), recipient, { from: holder });
+
+      expectEvent.inTransaction(tx, this.token, 'Transfer', {
+        from: holder,
+        to: this.vault.address,
+        value: parseToken(1),
+      });
+
+      expectEvent.inTransaction(tx, this.vault, 'Transfer', {
+        from: constants.ZERO_ADDRESS,
+        to: recipient,
+        value: parseShare(1),
+      });
+    });
+
+    it('withdraw', async function () {
+      expect(await this.vault.maxWithdraw(holder)).to.be.bignumber.equal('0');
+      expect(await this.vault.previewWithdraw('0')).to.be.bignumber.equal('0');
+
+      const { tx } = await this.vault.withdraw('0', recipient, holder, { from: holder });
+
+      expectEvent.inTransaction(tx, this.token, 'Transfer', {
+        from: this.vault.address,
+        to: recipient,
+        value: '0',
+      });
+
+      expectEvent.inTransaction(tx, this.vault, 'Transfer', {
+        from: holder,
+        to: constants.ZERO_ADDRESS,
+        value: '0',
+      });
+    });
+
+    it('redeem', async function () {
+      expect(await this.vault.maxRedeem(holder)).to.be.bignumber.equal('0');
+      expect(await this.vault.previewRedeem('0')).to.be.bignumber.equal('0');
+
+      const { tx } = await this.vault.redeem('0', recipient, holder, { from: holder });
+
+      expectEvent.inTransaction(tx, this.token, 'Transfer', {
+        from: this.vault.address,
+        to: recipient,
+        value: '0',
+      });
+
+      expectEvent.inTransaction(tx, this.vault, 'Transfer', {
+        from: holder,
+        to: constants.ZERO_ADDRESS,
+        value: '0',
+      });
+    });
+  });
+
+  describe('partially empty vault: shares & no assets', function () {
+    beforeEach(async function () {
+      await this.vault.mockMint(holder, parseShare(1)); // 1 share
+    });
+
+    it('status', async function () {
+      expect(await this.vault.totalAssets()).to.be.bignumber.equal('0');
+    });
+
+    it('deposit', async function () {
+      expect(await this.vault.maxDeposit(holder)).to.be.bignumber.equal('0');
+
+      // Can deposit 0 (max deposit)
+      const { tx } = await this.vault.deposit(0, recipient, { from: holder });
+
+      expectEvent.inTransaction(tx, this.token, 'Transfer', {
+        from: holder,
+        to: this.vault.address,
+        value: 0,
+      });
+
+      expectEvent.inTransaction(tx, this.vault, 'Transfer', {
+        from: constants.ZERO_ADDRESS,
+        to: recipient,
+        value: 0,
+      });
+
+      // Cannot deposit more than 0
+      await expectRevert.unspecified(this.vault.previewDeposit(parseToken(1)));
+      await expectRevert(
+        this.vault.deposit(parseToken(1), recipient, { from: holder }),
+        'ERC20TokenizedVault: deposit more than max',
+      );
+    });
+
+    it('mint', async function () {
+      expect(await this.vault.maxMint(holder)).to.be.bignumber.equal(constants.MAX_UINT256);
+      expect(await this.vault.previewMint(parseShare(1))).to.be.bignumber.equal('0');
+
+      const { tx } = await this.vault.mint(parseShare(1), recipient, { from: holder });
+
+      expectEvent.inTransaction(tx, this.token, 'Transfer', {
+        from: holder,
+        to: this.vault.address,
+        value: '0',
+      });
+
+      expectEvent.inTransaction(tx, this.vault, 'Transfer', {
+        from: constants.ZERO_ADDRESS,
+        to: recipient,
+        value: parseShare(1),
+      });
+    });
+
+    it('withdraw', async function () {
+      expect(await this.vault.maxWithdraw(holder)).to.be.bignumber.equal('0');
+      expect(await this.vault.previewWithdraw('0')).to.be.bignumber.equal('0');
+      await expectRevert.unspecified(this.vault.previewWithdraw('1'));
+
+      const { tx } = await this.vault.withdraw('0', recipient, holder, { from: holder });
+
+      expectEvent.inTransaction(tx, this.token, 'Transfer', {
+        from: this.vault.address,
+        to: recipient,
+        value: '0',
+      });
+
+      expectEvent.inTransaction(tx, this.vault, 'Transfer', {
+        from: holder,
+        to: constants.ZERO_ADDRESS,
+        value: '0',
+      });
+    });
+
+    it('redeem', async function () {
+      expect(await this.vault.maxRedeem(holder)).to.be.bignumber.equal(parseShare(1));
+      expect(await this.vault.previewRedeem(parseShare(1))).to.be.bignumber.equal('0');
+
+      const { tx } = await this.vault.redeem(parseShare(1), recipient, holder, { from: holder });
+
+      expectEvent.inTransaction(tx, this.token, 'Transfer', {
+        from: this.vault.address,
+        to: recipient,
+        value: '0',
+      });
+
+      expectEvent.inTransaction(tx, this.vault, 'Transfer', {
+        from: holder,
+        to: constants.ZERO_ADDRESS,
+        value: parseShare(1),
+      });
+    });
+  });
+
+  describe('full vault: assets & shares', function () {
+    beforeEach(async function () {
+      await this.token.mint(this.vault.address, parseToken(1)); // 1 tokens
+      await this.vault.mockMint(holder, parseShare(100)); // 100 share
+    });
+
+    it('status', async function () {
+      expect(await this.vault.totalAssets()).to.be.bignumber.equal(parseToken(1));
+    });
+
+    it('deposit', async function () {
+      expect(await this.vault.maxDeposit(holder)).to.be.bignumber.equal(constants.MAX_UINT256);
+      expect(await this.vault.previewDeposit(parseToken(1))).to.be.bignumber.equal(parseShare(100));
+
+      const { tx } = await this.vault.deposit(parseToken(1), recipient, { from: holder });
+
+      expectEvent.inTransaction(tx, this.token, 'Transfer', {
+        from: holder,
+        to: this.vault.address,
+        value: parseToken(1),
+      });
+
+      expectEvent.inTransaction(tx, this.vault, 'Transfer', {
+        from: constants.ZERO_ADDRESS,
+        to: recipient,
+        value: parseShare(100),
+      });
+    });
+
+    it('mint', async function () {
+      expect(await this.vault.maxMint(holder)).to.be.bignumber.equal(constants.MAX_UINT256);
+      expect(await this.vault.previewMint(parseShare(1))).to.be.bignumber.equal(parseToken(1).divn(100));
+
+      const { tx } = await this.vault.mint(parseShare(1), recipient, { from: holder });
+
+      expectEvent.inTransaction(tx, this.token, 'Transfer', {
+        from: holder,
+        to: this.vault.address,
+        value: parseToken(1).divn(100),
+      });
+
+      expectEvent.inTransaction(tx, this.vault, 'Transfer', {
+        from: constants.ZERO_ADDRESS,
+        to: recipient,
+        value: parseShare(1),
+      });
+    });
+
+    it('withdraw', async function () {
+      expect(await this.vault.maxWithdraw(holder)).to.be.bignumber.equal(parseToken(1));
+      expect(await this.vault.previewWithdraw(parseToken(1))).to.be.bignumber.equal(parseShare(100));
+
+      const { tx } = await this.vault.withdraw(parseToken(1), recipient, holder, { from: holder });
+
+      expectEvent.inTransaction(tx, this.token, 'Transfer', {
+        from: this.vault.address,
+        to: recipient,
+        value: parseToken(1),
+      });
+
+      expectEvent.inTransaction(tx, this.vault, 'Transfer', {
+        from: holder,
+        to: constants.ZERO_ADDRESS,
+        value: parseShare(100),
+      });
+    });
+
+    it('withdraw with approval', async function () {
+      await expectRevert(
+        this.vault.withdraw(parseToken(1), recipient, holder, { from: other }),
+        'ERC20: insufficient allowance',
+      );
+
+      await this.vault.withdraw(parseToken(1), recipient, holder, { from: spender });
+    });
+
+    it('redeem', async function () {
+      expect(await this.vault.maxRedeem(holder)).to.be.bignumber.equal(parseShare(100));
+      expect(await this.vault.previewRedeem(parseShare(100))).to.be.bignumber.equal(parseToken(1));
+
+      const { tx } = await this.vault.redeem(parseShare(100), recipient, holder, { from: holder });
+
+      expectEvent.inTransaction(tx, this.token, 'Transfer', {
+        from: this.vault.address,
+        to: recipient,
+        value: parseToken(1),
+      });
+
+      expectEvent.inTransaction(tx, this.vault, 'Transfer', {
+        from: holder,
+        to: constants.ZERO_ADDRESS,
+        value: parseShare(100),
+      });
+    });
+
+    it('redeem with approval', async function () {
+      await expectRevert(
+        this.vault.redeem(parseShare(100), recipient, holder, { from: other }),
+        'ERC20: insufficient allowance',
+      );
+
+      await this.vault.redeem(parseShare(100), recipient, holder, { from: spender });
+    });
+  });
+
+  /// Scenario inspired by solmate ERC4626 tests:
+  /// https://github.com/Rari-Capital/solmate/blob/main/src/test/ERC4626.t.sol
+  it('multiple mint, deposit, redeem & withdrawal', async function () {
+    // test designed with both asset using similar decimals
+    this.token = await ERC20DecimalsMock.new(name, symbol, 18);
+    this.vault = await ERC20TokenizedVaultMock.new(this.token.address, name + ' Vault', symbol + 'V');
+
+    await this.token.mint(user1, 4000);
+    await this.token.mint(user2, 7001);
+    await this.token.approve(this.vault.address, 4000, { from: user1 });
+    await this.token.approve(this.vault.address, 7001, { from: user2 });
+
+    // 1. Alice mints 2000 shares (costs 2000 tokens)
+    {
+      const { tx } = await this.vault.mint(2000, user1, { from: user1 });
+      expectEvent.inTransaction(tx, this.token, 'Transfer', {
+        from: user1,
+        to: this.vault.address,
+        value: '2000',
+      });
+      expectEvent.inTransaction(tx, this.vault, 'Transfer', {
+        from: constants.ZERO_ADDRESS,
+        to: user1,
+        value: '2000',
+      });
+
+      expect(await this.vault.previewDeposit(2000)).to.be.bignumber.equal('2000');
+      expect(await this.vault.balanceOf(user1)).to.be.bignumber.equal('2000');
+      expect(await this.vault.balanceOf(user2)).to.be.bignumber.equal('0');
+      expect(await this.vault.convertToAssets(await this.vault.balanceOf(user1))).to.be.bignumber.equal('2000');
+      expect(await this.vault.convertToAssets(await this.vault.balanceOf(user2))).to.be.bignumber.equal('0');
+      expect(await this.vault.totalSupply()).to.be.bignumber.equal('2000');
+      expect(await this.vault.totalAssets()).to.be.bignumber.equal('2000');
+    }
+
+    // 2. Bob deposits 4000 tokens (mints 4000 shares)
+    {
+      const { tx } = await this.vault.mint(4000, user2, { from: user2 });
+      expectEvent.inTransaction(tx, this.token, 'Transfer', {
+        from: user2,
+        to: this.vault.address,
+        value: '4000',
+      });
+      expectEvent.inTransaction(tx, this.vault, 'Transfer', {
+        from: constants.ZERO_ADDRESS,
+        to: user2,
+        value: '4000',
+      });
+
+      expect(await this.vault.previewDeposit(4000)).to.be.bignumber.equal('4000');
+      expect(await this.vault.balanceOf(user1)).to.be.bignumber.equal('2000');
+      expect(await this.vault.balanceOf(user2)).to.be.bignumber.equal('4000');
+      expect(await this.vault.convertToAssets(await this.vault.balanceOf(user1))).to.be.bignumber.equal('2000');
+      expect(await this.vault.convertToAssets(await this.vault.balanceOf(user2))).to.be.bignumber.equal('4000');
+      expect(await this.vault.totalSupply()).to.be.bignumber.equal('6000');
+      expect(await this.vault.totalAssets()).to.be.bignumber.equal('6000');
+    }
+
+    // 3. Vault mutates by +3000 tokens (simulated yield returned from strategy)
+    await this.token.mint(this.vault.address, 3000);
+
+    expect(await this.vault.balanceOf(user1)).to.be.bignumber.equal('2000');
+    expect(await this.vault.balanceOf(user2)).to.be.bignumber.equal('4000');
+    expect(await this.vault.convertToAssets(await this.vault.balanceOf(user1))).to.be.bignumber.equal('3000');
+    expect(await this.vault.convertToAssets(await this.vault.balanceOf(user2))).to.be.bignumber.equal('6000');
+    expect(await this.vault.totalSupply()).to.be.bignumber.equal('6000');
+    expect(await this.vault.totalAssets()).to.be.bignumber.equal('9000');
+
+    // 4. Alice deposits 2000 tokens (mints 1333 shares)
+    {
+      const { tx } = await this.vault.deposit(2000, user1, { from: user1 });
+      expectEvent.inTransaction(tx, this.token, 'Transfer', {
+        from: user1,
+        to: this.vault.address,
+        value: '2000',
+      });
+      expectEvent.inTransaction(tx, this.vault, 'Transfer', {
+        from: constants.ZERO_ADDRESS,
+        to: user1,
+        value: '1333',
+      });
+
+      expect(await this.vault.balanceOf(user1)).to.be.bignumber.equal('3333');
+      expect(await this.vault.balanceOf(user2)).to.be.bignumber.equal('4000');
+      expect(await this.vault.convertToAssets(await this.vault.balanceOf(user1))).to.be.bignumber.equal('4999');
+      expect(await this.vault.convertToAssets(await this.vault.balanceOf(user2))).to.be.bignumber.equal('6000');
+      expect(await this.vault.totalSupply()).to.be.bignumber.equal('7333');
+      expect(await this.vault.totalAssets()).to.be.bignumber.equal('11000');
+    }
+
+    // 5. Bob mints 2000 shares (costs 3001 assets)
+    // NOTE: Bob's assets spent got rounded up
+    // NOTE: Alices's vault assets got rounded up
+    {
+      const { tx } = await this.vault.mint(2000, user2, { from: user2 });
+      expectEvent.inTransaction(tx, this.token, 'Transfer', {
+        from: user2,
+        to: this.vault.address,
+        value: '3001',
+      });
+      expectEvent.inTransaction(tx, this.vault, 'Transfer', {
+        from: constants.ZERO_ADDRESS,
+        to: user2,
+        value: '2000',
+      });
+
+      expect(await this.vault.balanceOf(user1)).to.be.bignumber.equal('3333');
+      expect(await this.vault.balanceOf(user2)).to.be.bignumber.equal('6000');
+      expect(await this.vault.convertToAssets(await this.vault.balanceOf(user1))).to.be.bignumber.equal('5000');
+      expect(await this.vault.convertToAssets(await this.vault.balanceOf(user2))).to.be.bignumber.equal('9000');
+      expect(await this.vault.totalSupply()).to.be.bignumber.equal('9333');
+      expect(await this.vault.totalAssets()).to.be.bignumber.equal('14001');
+    }
+
+    // 6. Vault mutates by +3000 tokens
+    // NOTE: Vault holds 17001 tokens, but sum of assetsOf() is 17000.
+    await this.token.mint(this.vault.address, 3000);
+
+    expect(await this.vault.balanceOf(user1)).to.be.bignumber.equal('3333');
+    expect(await this.vault.balanceOf(user2)).to.be.bignumber.equal('6000');
+    expect(await this.vault.convertToAssets(await this.vault.balanceOf(user1))).to.be.bignumber.equal('6071');
+    expect(await this.vault.convertToAssets(await this.vault.balanceOf(user2))).to.be.bignumber.equal('10929');
+    expect(await this.vault.totalSupply()).to.be.bignumber.equal('9333');
+    expect(await this.vault.totalAssets()).to.be.bignumber.equal('17001');
+
+    // 7. Alice redeem 1333 shares (2428 assets)
+    {
+      const { tx } = await this.vault.redeem(1333, user1, user1, { from: user1 });
+      expectEvent.inTransaction(tx, this.vault, 'Transfer', {
+        from: user1,
+        to: constants.ZERO_ADDRESS,
+        value: '1333',
+      });
+      expectEvent.inTransaction(tx, this.token, 'Transfer', {
+        from: this.vault.address,
+        to: user1,
+        value: '2428',
+      });
+
+      expect(await this.vault.balanceOf(user1)).to.be.bignumber.equal('2000');
+      expect(await this.vault.balanceOf(user2)).to.be.bignumber.equal('6000');
+      expect(await this.vault.convertToAssets(await this.vault.balanceOf(user1))).to.be.bignumber.equal('3643');
+      expect(await this.vault.convertToAssets(await this.vault.balanceOf(user2))).to.be.bignumber.equal('10929');
+      expect(await this.vault.totalSupply()).to.be.bignumber.equal('8000');
+      expect(await this.vault.totalAssets()).to.be.bignumber.equal('14573');
+    }
+
+    // 8. Bob withdraws 2929 assets (1608 shares)
+    {
+      const { tx } = await this.vault.withdraw(2929, user2, user2, { from: user2 });
+      expectEvent.inTransaction(tx, this.vault, 'Transfer', {
+        from: user2,
+        to: constants.ZERO_ADDRESS,
+        value: '1608',
+      });
+      expectEvent.inTransaction(tx, this.token, 'Transfer', {
+        from: this.vault.address,
+        to: user2,
+        value: '2929',
+      });
+
+      expect(await this.vault.balanceOf(user1)).to.be.bignumber.equal('2000');
+      expect(await this.vault.balanceOf(user2)).to.be.bignumber.equal('4392');
+      expect(await this.vault.convertToAssets(await this.vault.balanceOf(user1))).to.be.bignumber.equal('3643');
+      expect(await this.vault.convertToAssets(await this.vault.balanceOf(user2))).to.be.bignumber.equal('8000');
+      expect(await this.vault.totalSupply()).to.be.bignumber.equal('6392');
+      expect(await this.vault.totalAssets()).to.be.bignumber.equal('11644');
+    }
+
+    // 9. Alice withdraws 3643 assets (2000 shares)
+    // NOTE: Bob's assets have been rounded back up
+    {
+      const { tx } = await this.vault.withdraw(3643, user1, user1, { from: user1 });
+      expectEvent.inTransaction(tx, this.vault, 'Transfer', {
+        from: user1,
+        to: constants.ZERO_ADDRESS,
+        value: '2000',
+      });
+      expectEvent.inTransaction(tx, this.token, 'Transfer', {
+        from: this.vault.address,
+        to: user1,
+        value: '3643',
+      });
+
+      expect(await this.vault.balanceOf(user1)).to.be.bignumber.equal('0');
+      expect(await this.vault.balanceOf(user2)).to.be.bignumber.equal('4392');
+      expect(await this.vault.convertToAssets(await this.vault.balanceOf(user1))).to.be.bignumber.equal('0');
+      expect(await this.vault.convertToAssets(await this.vault.balanceOf(user2))).to.be.bignumber.equal('8001');
+      expect(await this.vault.totalSupply()).to.be.bignumber.equal('4392');
+      expect(await this.vault.totalAssets()).to.be.bignumber.equal('8001');
+    }
+
+    // 10. Bob redeem 4392 shares (8001 tokens)
+    {
+      const { tx } = await this.vault.redeem(4392, user2, user2, { from: user2 });
+      expectEvent.inTransaction(tx, this.vault, 'Transfer', {
+        from: user2,
+        to: constants.ZERO_ADDRESS,
+        value: '4392',
+      });
+      expectEvent.inTransaction(tx, this.token, 'Transfer', {
+        from: this.vault.address,
+        to: user2,
+        value: '8001',
+      });
+
+      expect(await this.vault.balanceOf(user1)).to.be.bignumber.equal('0');
+      expect(await this.vault.balanceOf(user2)).to.be.bignumber.equal('0');
+      expect(await this.vault.convertToAssets(await this.vault.balanceOf(user1))).to.be.bignumber.equal('0');
+      expect(await this.vault.convertToAssets(await this.vault.balanceOf(user2))).to.be.bignumber.equal('0');
+      expect(await this.vault.totalSupply()).to.be.bignumber.equal('0');
+      expect(await this.vault.totalAssets()).to.be.bignumber.equal('0');
+    }
+  });
+});

+ 98 - 1
test/utils/math/Math.test.js

@@ -1,12 +1,15 @@
-const { BN, constants } = require('@openzeppelin/test-helpers');
+const { BN, constants, expectRevert } = require('@openzeppelin/test-helpers');
 const { expect } = require('chai');
 const { MAX_UINT256 } = constants;
+const { Rounding } = require('../../helpers/enums.js');
 
 const MathMock = artifacts.require('MathMock');
 
 contract('Math', function (accounts) {
   const min = new BN('1234');
   const max = new BN('5678');
+  const MAX_UINT256_SUB1 = MAX_UINT256.sub(new BN('1'));
+  const MAX_UINT256_SUB2 = MAX_UINT256.sub(new BN('2'));
 
   beforeEach(async function () {
     this.math = await MathMock.new();
@@ -85,4 +88,98 @@ contract('Math', function (accounts) {
       expect(await this.math.ceilDiv(MAX_UINT256, b)).to.be.bignumber.equal(MAX_UINT256);
     });
   });
+
+  describe('muldiv', function () {
+    it('divide by 0', async function () {
+      await expectRevert.unspecified(this.math.mulDiv(1, 1, 0, Rounding.Down));
+    });
+
+    describe('does round down', async function () {
+      it('small values', async function () {
+        expect(await this.math.mulDiv('3', '4', '5', Rounding.Down)).to.be.bignumber.equal('2');
+        expect(await this.math.mulDiv('3', '5', '5', Rounding.Down)).to.be.bignumber.equal('3');
+      });
+
+      it('large values', async function () {
+        expect(await this.math.mulDiv(
+          new BN('42'),
+          MAX_UINT256_SUB1,
+          MAX_UINT256,
+          Rounding.Down,
+        )).to.be.bignumber.equal(new BN('41'));
+
+        expect(await this.math.mulDiv(
+          new BN('17'),
+          MAX_UINT256,
+          MAX_UINT256,
+          Rounding.Down,
+        )).to.be.bignumber.equal(new BN('17'));
+
+        expect(await this.math.mulDiv(
+          MAX_UINT256_SUB1,
+          MAX_UINT256_SUB1,
+          MAX_UINT256,
+          Rounding.Down,
+        )).to.be.bignumber.equal(MAX_UINT256_SUB2);
+
+        expect(await this.math.mulDiv(
+          MAX_UINT256,
+          MAX_UINT256_SUB1,
+          MAX_UINT256,
+          Rounding.Down,
+        )).to.be.bignumber.equal(MAX_UINT256_SUB1);
+
+        expect(await this.math.mulDiv(
+          MAX_UINT256,
+          MAX_UINT256,
+          MAX_UINT256,
+          Rounding.Down,
+        )).to.be.bignumber.equal(MAX_UINT256);
+      });
+    });
+
+    describe('does round up', async function () {
+      it('small values', async function () {
+        expect(await this.math.mulDiv('3', '4', '5', Rounding.Up)).to.be.bignumber.equal('3');
+        expect(await this.math.mulDiv('3', '5', '5', Rounding.Up)).to.be.bignumber.equal('3');
+      });
+
+      it('large values', async function () {
+        expect(await this.math.mulDiv(
+          new BN('42'),
+          MAX_UINT256_SUB1,
+          MAX_UINT256,
+          Rounding.Up,
+        )).to.be.bignumber.equal(new BN('42'));
+
+        expect(await this.math.mulDiv(
+          new BN('17'),
+          MAX_UINT256,
+          MAX_UINT256,
+          Rounding.Up,
+        )).to.be.bignumber.equal(new BN('17'));
+
+        expect(await this.math.mulDiv(
+          MAX_UINT256_SUB1,
+          MAX_UINT256_SUB1,
+          MAX_UINT256,
+          Rounding.Up,
+        )).to.be.bignumber.equal(MAX_UINT256_SUB1);
+
+        expect(await this.math.mulDiv(
+          MAX_UINT256,
+          MAX_UINT256_SUB1,
+          MAX_UINT256,
+          Rounding.Up,
+        )).to.be.bignumber.equal(MAX_UINT256_SUB1);
+
+        expect(await this.math.mulDiv(
+          MAX_UINT256,
+          MAX_UINT256,
+          MAX_UINT256,
+          Rounding.Up,
+        )).to.be.bignumber.equal(MAX_UINT256);
+      });
+    });
+  });
 });