diff --git a/CHANGELOG.md b/CHANGELOG.md index eb6b5c0a32a..6fe15e5bccc 100644 --- a/CHANGELOG.md +++ b/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)) diff --git a/contracts/interfaces/IERC4626.sol b/contracts/interfaces/IERC4626.sol new file mode 100644 index 00000000000..167a402296a --- /dev/null +++ b/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); +} diff --git a/contracts/mocks/ERC20TokenizedVaultMock.sol b/contracts/mocks/ERC20TokenizedVaultMock.sol new file mode 100644 index 00000000000..1aabd94efac --- /dev/null +++ b/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); + } +} diff --git a/contracts/mocks/MathMock.sol b/contracts/mocks/MathMock.sol index c651b6bb1fd..3fac5e76903 100644 --- a/contracts/mocks/MathMock.sol +++ b/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); + } } diff --git a/contracts/token/ERC20/README.adoc b/contracts/token/ERC20/README.adoc index df20c17a2c1..dd1845f0771 100644 --- a/contracts/token/ERC20/README.adoc +++ b/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. diff --git a/contracts/token/ERC20/extensions/ERC20TokenizedVault.sol b/contracts/token/ERC20/extensions/ERC20TokenizedVault.sol new file mode 100644 index 00000000000..e1aaab83e6b --- /dev/null +++ b/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; + } +} diff --git a/contracts/utils/math/Math.sol b/contracts/utils/math/Math.sol index 291d257b0d8..150138f7601 100644 --- a/contracts/utils/math/Math.sol +++ b/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; } } diff --git a/test/helpers/enums.js b/test/helpers/enums.js index de5cdc5708e..26825de337e 100644 --- a/test/helpers/enums.js +++ b/test/helpers/enums.js @@ -21,4 +21,9 @@ module.exports = { 'For', 'Abstain', ), + Rounding: Enum( + 'Down', + 'Up', + 'Zero', + ), }; diff --git a/test/token/ERC20/extensions/ERC20TokenizedVault.test.js b/test/token/ERC20/extensions/ERC20TokenizedVault.test.js new file mode 100644 index 00000000000..0c20570d77e --- /dev/null +++ b/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'); + } + }); +}); diff --git a/test/utils/math/Math.test.js b/test/utils/math/Math.test.js index 7e194dec713..c931658040a 100644 --- a/test/utils/math/Math.test.js +++ b/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); + }); + }); + }); });