Browse Source

ERC4626 inflation attack mitigation (#3979)

Co-authored-by: Francisco <fg@frang.io>
Hadrien Croubois 2 years ago
parent
commit
d64d7aa5d1

+ 5 - 0
.changeset/perfect-insects-listen.md

@@ -0,0 +1,5 @@
+---
+'openzeppelin-solidity': minor
+---
+
+`ERC4626`: Add mitigation to the inflation attack through virtual shares and assets.

+ 0 - 33
contracts/mocks/token/ERC4626DecimalsMock.sol

@@ -1,33 +0,0 @@
-// SPDX-License-Identifier: MIT
-
-pragma solidity ^0.8.0;
-
-import "../../token/ERC20/extensions/ERC4626.sol";
-
-abstract contract ERC4626DecimalsMock is ERC4626 {
-    using Math for uint256;
-
-    uint8 private immutable _decimals;
-
-    constructor(uint8 decimals_) {
-        _decimals = decimals_;
-    }
-
-    function decimals() public view virtual override returns (uint8) {
-        return _decimals;
-    }
-
-    function _initialConvertToShares(
-        uint256 assets,
-        Math.Rounding rounding
-    ) internal view virtual override returns (uint256 shares) {
-        return assets.mulDiv(10 ** decimals(), 10 ** super.decimals(), rounding);
-    }
-
-    function _initialConvertToAssets(
-        uint256 shares,
-        Math.Rounding rounding
-    ) internal view virtual override returns (uint256 assets) {
-        return shares.mulDiv(10 ** super.decimals(), 10 ** decimals(), rounding);
-    }
-}

+ 17 - 0
contracts/mocks/token/ERC4626OffsetMock.sol

@@ -0,0 +1,17 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.0;
+
+import "../../token/ERC20/extensions/ERC4626.sol";
+
+abstract contract ERC4626OffsetMock is ERC4626 {
+    uint8 private immutable _offset;
+
+    constructor(uint8 offset_) {
+        _offset = offset_;
+    }
+
+    function _decimalsOffset() internal view virtual override returns (uint8) {
+        return _offset;
+    }
+}

+ 31 - 44
contracts/token/ERC20/extensions/ERC4626.sol

@@ -17,28 +17,48 @@ import "../../../utils/math/Math.sol";
  * 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.
  *
- * CAUTION: When the vault is empty or nearly empty, deposits are at high risk of being stolen through frontrunning with
- * a "donation" to the vault that inflates the price of a share. This is variously known as a donation or inflation
+ * [CAUTION]
+ * ====
+ * In empty (or nearly empty) ERC-4626 vaults, deposits are at high risk of being stolen through frontrunning
+ * with a "donation" to the vault that inflates the price of a share. This is variously known as a donation or inflation
  * attack and is essentially a problem of slippage. Vault deployers can protect against this attack by making an initial
  * deposit of a non-trivial amount of the asset, such that price manipulation becomes infeasible. Withdrawals may
- * similarly be affected by slippage. Users can protect against this attack as well unexpected slippage in general by
+ * similarly be affected by slippage. Users can protect against this attack as well as unexpected slippage in general by
  * verifying the amount received is as expected, using a wrapper that performs these checks such as
  * https://github.com/fei-protocol/ERC4626#erc4626router-and-base[ERC4626Router].
  *
+ * Since v4.9, this implementation uses virtual assets and shares to mitigate that risk. The `_decimalsOffset()`
+ * corresponds to an offset in the decimal representation between the underlying asset's decimals and the vault
+ * decimals. This offset also determines the rate of virtual shares to virtual assets in the vault, which itself
+ * determines the initial exchange rate. While not fully preventing the attack, analysis shows that the default offset
+ * (0) makes it non-profitable, as a result of the value being captured by the virtual shares (out of the attacker's
+ * donation) matching the attacker's expected gains. With a larger offset, the attack becomes orders of magnitude more
+ * expensive than it is profitable. More details about the underlying math can be found
+ * xref:erc4626.adoc#inflation-attack[here].
+ *
+ * The drawback of this approach is that the virtual shares do capture (a very small) part of the value being accrued
+ * to the vault. Also, if the vault experiences losses, the users try to exit the vault, the virtual shares and assets
+ * will cause the first user to exit to experience reduced losses in detriment to the last users that will experience
+ * bigger losses. Developers willing to revert back to the pre-v4.9 behavior just need to override the
+ * `_convertToShares` and `_convertToAssets` functions.
+ *
+ * To learn more, check out our xref:ROOT:erc4626.adoc[ERC-4626 guide].
+ * ====
+ *
  * _Available since v4.7._
  */
 abstract contract ERC4626 is ERC20, IERC4626 {
     using Math for uint256;
 
     IERC20 private immutable _asset;
-    uint8 private immutable _decimals;
+    uint8 private immutable _underlyingDecimals;
 
     /**
      * @dev Set the underlying asset contract. This must be an ERC20-compatible contract (ERC20 or ERC777).
      */
     constructor(IERC20 asset_) {
         (bool success, uint8 assetDecimals) = _tryGetAssetDecimals(asset_);
-        _decimals = success ? assetDecimals : super.decimals();
+        _underlyingDecimals = success ? assetDecimals : 18;
         _asset = asset_;
     }
 
@@ -65,7 +85,7 @@ abstract contract ERC4626 is ERC20, IERC4626 {
      * See {IERC20Metadata-decimals}.
      */
     function decimals() public view virtual override(IERC20Metadata, ERC20) returns (uint8) {
-        return _decimals;
+        return _underlyingDecimals + _decimalsOffset();
     }
 
     /** @dev See {IERC4626-asset}. */
@@ -90,7 +110,7 @@ abstract contract ERC4626 is ERC20, IERC4626 {
 
     /** @dev See {IERC4626-maxDeposit}. */
     function maxDeposit(address) public view virtual override returns (uint256) {
-        return _isVaultHealthy() ? type(uint256).max : 0;
+        return type(uint256).max;
     }
 
     /** @dev See {IERC4626-maxMint}. */
@@ -179,44 +199,14 @@ abstract contract ERC4626 is ERC20, IERC4626 {
      * would represent an infinite amount of shares.
      */
     function _convertToShares(uint256 assets, Math.Rounding rounding) internal view virtual returns (uint256) {
-        uint256 supply = totalSupply();
-        return
-            (assets == 0 || supply == 0)
-                ? _initialConvertToShares(assets, rounding)
-                : assets.mulDiv(supply, totalAssets(), rounding);
-    }
-
-    /**
-     * @dev Internal conversion function (from assets to shares) to apply when the vault is empty.
-     *
-     * NOTE: Make sure to keep this function consistent with {_initialConvertToAssets} when overriding it.
-     */
-    function _initialConvertToShares(
-        uint256 assets,
-        Math.Rounding /*rounding*/
-    ) internal view virtual returns (uint256 shares) {
-        return assets;
+        return assets.mulDiv(totalSupply() + 10 ** _decimalsOffset(), totalAssets() + 1, rounding);
     }
 
     /**
      * @dev Internal conversion function (from shares to assets) with support for rounding direction.
      */
     function _convertToAssets(uint256 shares, Math.Rounding rounding) internal view virtual returns (uint256) {
-        uint256 supply = totalSupply();
-        return
-            (supply == 0) ? _initialConvertToAssets(shares, rounding) : shares.mulDiv(totalAssets(), supply, rounding);
-    }
-
-    /**
-     * @dev Internal conversion function (from shares to assets) to apply when the vault is empty.
-     *
-     * NOTE: Make sure to keep this function consistent with {_initialConvertToShares} when overriding it.
-     */
-    function _initialConvertToAssets(
-        uint256 shares,
-        Math.Rounding /*rounding*/
-    ) internal view virtual returns (uint256) {
-        return shares;
+        return shares.mulDiv(totalAssets() + 1, totalSupply() + 10 ** _decimalsOffset(), rounding);
     }
 
     /**
@@ -262,10 +252,7 @@ abstract contract ERC4626 is ERC20, IERC4626 {
         emit Withdraw(caller, receiver, owner, assets, shares);
     }
 
-    /**
-     * @dev Checks if vault is "healthy" in the sense of having assets backing the circulating shares.
-     */
-    function _isVaultHealthy() private view returns (bool) {
-        return totalAssets() > 0 || totalSupply() == 0;
+    function _decimalsOffset() internal view virtual returns (uint8) {
+        return 0;
     }
 }

BIN
docs/modules/ROOT/images/erc4626-attack-3a.png


BIN
docs/modules/ROOT/images/erc4626-attack-3b.png


BIN
docs/modules/ROOT/images/erc4626-attack-6.png


BIN
docs/modules/ROOT/images/erc4626-attack.png


BIN
docs/modules/ROOT/images/erc4626-deposit.png


BIN
docs/modules/ROOT/images/erc4626-mint.png


BIN
docs/modules/ROOT/images/erc4626-rate-linear.png


BIN
docs/modules/ROOT/images/erc4626-rate-loglog.png


BIN
docs/modules/ROOT/images/erc4626-rate-loglogext.png


+ 1 - 0
docs/modules/ROOT/nav.adoc

@@ -13,6 +13,7 @@
 ** xref:erc721.adoc[ERC721]
 ** xref:erc777.adoc[ERC777]
 ** xref:erc1155.adoc[ERC1155]
+** xref:erc4626.adoc[ERC4626]
 
 * xref:governance.adoc[Governance]
 

+ 193 - 0
docs/modules/ROOT/pages/erc4626.adoc

@@ -0,0 +1,193 @@
+= ERC4626
+:stem: latexmath
+
+https://eips.ethereum.org/EIPS/eip-4626[ERC4626] is an extension of xref:erc20.adoc[ERC20] that proposes a standard interface for token vaults. This standard interface can be used by widely different contracts (including lending markets, aggregators, and intrinsically interest bearing tokens), which brings a number of subtleties. Navigating these potential issues is essential to implementing a compliant and composable token vault.
+
+We provide a base implementation of ERC4626 that includes a simple vault. This contract is designed in a way that allows developers to easily re-configure the vault's behavior, with minimal overrides, while staying compliant. In this guide, we will discuss some security considerations that affect ERC4626. We will also discuss common customizations of the vault.
+
+[[inflation-attack]]
+== Security concern: Inflation attack
+
+=== Visualizing the vault
+
+In exchange for the assets deposited into an ERC4626 vault, a user receives shares. These shares can later be burned to redeem the corresponding underlying assets. The number of shares a user gets depends on the amount of assets they put in and on the exchange rate of the vault. This exchange rate is defined by the current liquidity held by the vault.
+
+- If a vault has 100 tokens to back 200 shares, then each share is worth 0.5 assets.
+- If a vault has 200 tokens to back 100 shares, then each share is worth 2.0 assets.
+
+In other words, the exchange rate can be defined as the slope of the line that passes through the origin and the current number of assets and shares in the vault. Deposits and withdrawals move the vault in this line.
+
+image::erc4626-rate-linear.png[Exchange rates in linear scale]
+
+When plotted in log-log scale, the rate is defined similarly, but appears differently (because the point (0,0) is infinitely far away). Rates are represented by "diagonal" lines with different offsets.
+
+image::erc4626-rate-loglog.png[Exchange rates in logarithmic scale]
+
+In such a reprentation, widely different rates can be clearly visible in the same graph. This wouldn't be the case in linear scale.
+
+image::erc4626-rate-loglogext.png[More exchange rates in logarithmic scale]
+
+=== The attack
+
+When depositing tokens, the number of shares a user gets is rounded down. This rounding takes away value from the user in favor or the vault (i.e. in favor of all the current share holders). This rounding is often negligible because of the amount at stake. If you deposit 1e9 shares worth of tokens, the rounding will have you lose at most 0.0000001% of your deposit. However if you deposit 10 shares worth of tokens, you could lose 10% of your deposit. Even worse, if you deposit <1 share worth of tokens, then you get 0 shares, and you basically made a donation.
+
+For a given amount of assets, the more shares you receive the safer you are. If you want to limit your losses to at most 1%, you need to receive at least 100 shares.
+
+image::erc4626-deposit.png[Depositing assets]
+
+In the figure we can see that for a given deposit of 500 assets, the number of shares we get and the corresponding rounding losses depend on the exchange rate. If the exchange rate is that of the orange curve, we are getting less than a share, so we lose 100% of our deposit. However, if the exchange rate is that of the green curve, we get 5000 shares, which limits our rounding losses to at most 0.02%.
+
+image::erc4626-mint.png[Minting shares]
+
+Symmetrically, if we focus on limiting our losses to a maximum of 0.5%, we need to get at least 200 shares. With the green exchange rate that requires just 20 tokens, but with the orange rate that requires 200000 tokens.
+
+We can clearly see that that the blue and green curves correspond to vaults that are safer than the yellow and orange curves.
+
+The idea of an inflation attack is that an attacker can donate assets to the vault to move the rate curve to the right, and make the vault unsafe.
+
+image::erc4626-attack.png[Inflation attack without protection]
+
+Figure 6 shows how an attacker can manipulate the rate of an empty vault. First the attacker must deposit a small amount of tokens (1 token) and follow up with a donation of 1e5 tokens directly to the vault to move the exchange rate "right". This puts the vault in a state where any deposit smaller than 1e5 would be completely lost to the vault. Given that the attacker is the only share holder (from their donation), the attacker would steal all the tokens deposited.
+
+An attacker would typically wait for a user to do the first deposit into the vault, and would frontrun that operation with the attack described above. The risk is low, and the size of the "donation" required to manipulate the vault is equivalent to the size of the deposit that is being attacked.
+
+In math that gives:
+
+- stem:[a_0] the attacker deposit
+- stem:[a_1] the attacker donation
+- stem:[u] the user deposit
+
+[%header,cols=4*]
+|===
+|
+| Assets
+| Shares
+| Rate
+
+| initial
+| stem:[0]
+| stem:[0]
+| -
+
+| after attacker's deposit
+| stem:[a_0]
+| stem:[a_0]
+| stem:[1]
+
+| after attacker's donation
+| stem:[a_0+a_1]
+| stem:[a_0]
+| stem:[\frac{a_0}{a_0+a_1}]
+|===
+
+This means a deposit of stem:[u] will give stem:[\frac{u \times a_0}{a_0 + a_1}] shares.
+
+For the attacker to dilute that deposit to 0 shares, causing the user to lose all its deposit, it must ensure that
+
+[stem]
+++++
+\frac{u \times a_0}{a_0+a_1} < 1 \iff u < 1 + \frac{a_1}{a_0}
+++++
+
+Using stem:[a_0 = 1] and stem:[a_1 = u] is enough. So the attacker only needs stem:[u+1] assets to perform a successful attack.
+
+It is easy to generalize the above results to scenarios where the attacker is going after a smaller fraction of the user's deposit. In order to target stem:[\frac{u}{n}], the user needs to suffer rounding of a similar fraction, which means the user must receive at most stem:[n] shares. This results in:
+
+[stem]
+++++
+\frac{u \times a_0}{a_0+a_1} < n \iff \frac{u}{n} < 1 + \frac{a_1}{a_0}
+++++
+
+In this scenario, the attack is stem:[n] times less powerful (in how much it is stealing) and costs stem:[n] times less to execute. In both cases, the amount of funds the attacker needs to commit is equivalent to its potential earnings.
+
+=== Defending with a virtual offset
+
+The defense we propose consists of two parts:
+
+- Use an offset between the "precision" of the representation of shares and assets. Said otherwise, we use more decimal places to represent the shares than the underlying token does to represent the assets.
+- Include virtual shares and virtual assets in the exchange rate computation. These virtual assets enforce the conversion rate when the vault is empty.
+
+These two parts work together in enforcing the security of the vault. First, the increased precision corresponds to a high rate, which we saw is safer as it reduces the rounding error when computing the amount of shares. Second, the virtual assets and shares (in addition to simplifying a lot of the computations) capture part of the donation, making it unprofitable for a developer to perform an attack.
+
+
+Following the previous math definitions, we have:
+
+- stem:[\delta] the vault offset
+- stem:[a_0] the attacker deposit
+- stem:[a_1] the attacker donation
+- stem:[u] the user deposit
+
+[%header,cols=4*]
+|===
+|
+| Assets
+| Shares
+| Rate
+
+| initial
+| stem:[1]
+| stem:[10^\delta]
+| stem:[10^\delta]
+
+| after attacker's deposit
+| stem:[1+a_0]
+| stem:[10^\delta \times (1+a_0)]
+| stem:[10^\delta]
+
+| after attacker's donation
+| stem:[1+a_0+a_1]
+| stem:[10^\delta \times (1+a_0)]
+| stem:[10^\delta \times \frac{1+a_0}{1+a_0+a_1}]
+|===
+
+One important thing to note is that the attacker only owns a fraction stem:[\frac{a_0}{1 + a_0}] of the shares, so when doing the donation, he will only be able to recover that fraction stem:[\frac{a_1 \times a_0}{1 + a_0}] of the donation. The remaining stem:[\frac{a_1}{1+a_0}] are captured by the vault.
+
+[stem]
+++++
+\mathit{loss} = \frac{a_1}{1+a_0}
+++++
+
+When the user deposits stem:[u], he receives
+
+[stem]
+++++
+10^\delta \times u \times \frac{1+a_0}{1+a_0+a_1}
+++++
+
+For the attacker to dilute that deposit to 0 shares, causing the user to lose all its deposit, it must ensure that
+
+[stem]
+++++
+10^\delta \times u \times \frac{1+a_0}{1+a_0+a_1} < 1
+++++
+
+[stem]
+++++
+\iff 10^\delta \times u < \frac{1+a_0+a_1}{1+a_0}
+++++
+
+[stem]
+++++
+\iff 10^\delta \times u < 1 + \frac{a_1}{1+a_0}
+++++
+
+[stem]
+++++
+\iff 10^\delta \times u \le \mathit{loss}
+++++
+
+- If the offset is 0, the attacker loss is at least equal to the user's deposit.
+- If the offset is greater than 0, the attacker will have to suffer losses that are orders of magnitude bigger than the amount of value that can hypothetically be stolen from the user.
+
+This shows that even with an offset of 0, the virtual shares and assets make this attack non profitable for the attacker. Bigger offsets increase the security even further by making any attack on the user extremely wasteful.
+
+The following figure shows how the offset impacts the initial rate and limits the ability of an attacker with limited funds to inflate it effectively.
+
+image::erc4626-attack-3a.png[Inflation attack without offset=3]
+stem:[\delta = 3], stem:[a_0 = 1], stem:[a_1 = 10^5]
+
+image::erc4626-attack-3b.png[Inflation attack without offset=3 and an attacker deposit that limits its losses]
+stem:[\delta = 3], stem:[a_0 = 100], stem:[a_1 = 10^5]
+
+image::erc4626-attack-6.png[Inflation attack without offset=6]
+stem:[\delta = 6], stem:[a_0 = 1], stem:[a_1 = 10^5]

+ 0 - 18
test/token/ERC20/extensions/ERC4626.t.sol

@@ -15,22 +15,4 @@ contract ERC4626StdTest is ERC4626Test {
         _vaultMayBeEmpty = false;
         _unlimitedAmount = true;
     }
-
-    // solhint-disable-next-line func-name-mixedcase
-    function test_RT_mint_withdraw(ERC4626Test.Init memory init, uint256 shares) public override {
-        // There is an edge case where we currently behave different than the property tests,
-        // when all assets are lost to negative yield.
-
-        // Sum all initially deposited assets.
-        int256 initAssets = 0;
-        for (uint256 i = 0; i < init.share.length; i++) {
-            vm.assume(init.share[i] <= uint256(type(int256).max - initAssets));
-            initAssets += SafeCast.toInt256(init.share[i]);
-        }
-
-        // Reject tests where the yield loses all assets from the vault.
-        vm.assume(init.yield > -initAssets);
-
-        super.test_RT_mint_withdraw(init, shares);
-    }
 }

+ 385 - 397
test/token/ERC20/extensions/ERC4626.test.js

@@ -1,34 +1,16 @@
-const { BN, constants, expectEvent, expectRevert } = require('@openzeppelin/test-helpers');
+const { constants, expectEvent, expectRevert } = require('@openzeppelin/test-helpers');
 const { expect } = require('chai');
 
 const ERC20Decimals = artifacts.require('$ERC20DecimalsMock');
 const ERC4626 = artifacts.require('$ERC4626');
-const ERC4626Decimals = artifacts.require('$ERC4626DecimalsMock');
-
-const parseToken = token => new BN(token).mul(new BN('1000000000000'));
-const parseShare = share => new BN(share).mul(new BN('1000000000000000000'));
+const ERC4626OffsetMock = artifacts.require('$ERC4626OffsetMock');
 
 contract('ERC4626', function (accounts) {
   const [holder, recipient, spender, other, user1, user2] = accounts;
 
   const name = 'My Token';
   const symbol = 'MTKN';
-
-  beforeEach(async function () {
-    this.token = await ERC20Decimals.new(name, symbol, 12);
-    this.vault = await ERC4626Decimals.new(name + ' Vault', symbol + 'V', this.token.address, 18);
-
-    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.decimals()).to.be.bignumber.equal('18');
-    expect(await this.vault.asset()).to.be.equal(this.token.address);
-  });
+  const decimals = web3.utils.toBN(18);
 
   it('inherit decimals if from asset', async function () {
     for (const decimals of [0, 9, 12, 18, 36].map(web3.utils.toBN)) {
@@ -38,372 +20,378 @@ contract('ERC4626', function (accounts) {
     }
   });
 
-  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 });
-
-      await expectEvent.inTransaction(tx, this.token, 'Transfer', {
-        from: holder,
-        to: this.vault.address,
-        value: parseToken(1),
-      });
-
-      await 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 });
-
-      await expectEvent.inTransaction(tx, this.token, 'Transfer', {
-        from: holder,
-        to: this.vault.address,
-        value: parseToken(1),
-      });
-
-      await 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 });
-
-      await expectEvent.inTransaction(tx, this.token, 'Transfer', {
-        from: this.vault.address,
-        to: recipient,
-        value: '0',
-      });
-
-      await 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 });
-
-      await expectEvent.inTransaction(tx, this.token, 'Transfer', {
-        from: this.vault.address,
-        to: recipient,
-        value: '0',
-      });
-
-      await 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 });
-
-      await expectEvent.inTransaction(tx, this.token, 'Transfer', {
-        from: holder,
-        to: this.vault.address,
-        value: parseToken(1),
-      });
-
-      await 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 });
-
-      await expectEvent.inTransaction(tx, this.token, 'Transfer', {
-        from: holder,
-        to: this.vault.address,
-        value: parseToken(1),
-      });
-
-      await 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 });
-
-      await expectEvent.inTransaction(tx, this.token, 'Transfer', {
-        from: this.vault.address,
-        to: recipient,
-        value: '0',
-      });
-
-      await 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 });
-
-      await expectEvent.inTransaction(tx, this.token, 'Transfer', {
-        from: this.vault.address,
-        to: recipient,
-        value: '0',
-      });
-
-      await 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.$_mint(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 });
-
-      await expectEvent.inTransaction(tx, this.token, 'Transfer', {
-        from: holder,
-        to: this.vault.address,
-        value: '0',
-      });
-
-      await 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 }),
-        'ERC4626: 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 });
-
-      await expectEvent.inTransaction(tx, this.token, 'Transfer', {
-        from: holder,
-        to: this.vault.address,
-        value: '0',
-      });
-
-      await 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 });
-
-      await expectEvent.inTransaction(tx, this.token, 'Transfer', {
-        from: this.vault.address,
-        to: recipient,
-        value: '0',
-      });
-
-      await 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 });
-
-      await expectEvent.inTransaction(tx, this.token, 'Transfer', {
-        from: this.vault.address,
-        to: recipient,
-        value: '0',
-      });
-
-      await 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.$_mint(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 });
-
-      await expectEvent.inTransaction(tx, this.token, 'Transfer', {
-        from: holder,
-        to: this.vault.address,
-        value: parseToken(1),
-      });
-
-      await 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 });
-
-      await expectEvent.inTransaction(tx, this.token, 'Transfer', {
-        from: holder,
-        to: this.vault.address,
-        value: parseToken(1).divn(100),
-      });
-
-      await expectEvent.inTransaction(tx, this.vault, 'Transfer', {
-        from: constants.ZERO_ADDRESS,
-        to: recipient,
-        value: parseShare(1),
+  for (const offset of [0, 6, 18].map(web3.utils.toBN)) {
+    const parseToken = token => web3.utils.toBN(10).pow(decimals).muln(token);
+    const parseShare = share => web3.utils.toBN(10).pow(decimals.add(offset)).muln(share);
+
+    const virtualAssets = web3.utils.toBN(1);
+    const virtualShares = web3.utils.toBN(10).pow(offset);
+
+    describe(`offset: ${offset}`, function () {
+      beforeEach(async function () {
+        this.token = await ERC20Decimals.new(name, symbol, decimals);
+        this.vault = await ERC4626OffsetMock.new(name + ' Vault', symbol + 'V', this.token.address, offset);
+
+        await this.token.$_mint(holder, constants.MAX_INT256); // 50% of maximum
+        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.decimals()).to.be.bignumber.equal(decimals.add(offset));
+        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 });
+
+          await expectEvent.inTransaction(tx, this.token, 'Transfer', {
+            from: holder,
+            to: this.vault.address,
+            value: parseToken(1),
+          });
+
+          await 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 });
+
+          await expectEvent.inTransaction(tx, this.token, 'Transfer', {
+            from: holder,
+            to: this.vault.address,
+            value: parseToken(1),
+          });
+
+          await 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 });
+
+          await expectEvent.inTransaction(tx, this.token, 'Transfer', {
+            from: this.vault.address,
+            to: recipient,
+            value: '0',
+          });
+
+          await 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 });
+
+          await expectEvent.inTransaction(tx, this.token, 'Transfer', {
+            from: this.vault.address,
+            to: recipient,
+            value: '0',
+          });
+
+          await expectEvent.inTransaction(tx, this.vault, 'Transfer', {
+            from: holder,
+            to: constants.ZERO_ADDRESS,
+            value: '0',
+          });
+        });
+      });
+
+      describe('inflation attack: offset price by direct deposit of assets', function () {
+        beforeEach(async function () {
+          // Donate 1 token to the vault to offset the price
+          await this.token.$_mint(this.vault.address, parseToken(1));
+        });
+
+        it('status', async function () {
+          expect(await this.vault.totalSupply()).to.be.bignumber.equal('0');
+          expect(await this.vault.totalAssets()).to.be.bignumber.equal(parseToken(1));
+        });
+
+        /**
+         * | offset | deposited assets     | redeemable assets    |
+         * |--------|----------------------|----------------------|
+         * | 0      | 1.000000000000000000 | 0.                   |
+         * | 6      | 1.000000000000000000 | 0.999999000000000000 |
+         * | 18     | 1.000000000000000000 | 0.999999999999999999 |
+         *
+         * Attack is possible, but made difficult by the offset. For the attack to be successful
+         * the attacker needs to frontrun a deposit 10**offset times bigger than what the victim
+         * was trying to deposit
+         */
+        it('deposit', async function () {
+          const effectiveAssets = await this.vault.totalAssets().then(x => x.add(virtualAssets));
+          const effectiveShares = await this.vault.totalSupply().then(x => x.add(virtualShares));
+
+          const depositAssets = parseToken(1);
+          const expectedShares = depositAssets.mul(effectiveShares).div(effectiveAssets);
+
+          expect(await this.vault.maxDeposit(holder)).to.be.bignumber.equal(constants.MAX_UINT256);
+          expect(await this.vault.previewDeposit(depositAssets)).to.be.bignumber.equal(expectedShares);
+
+          const { tx } = await this.vault.deposit(depositAssets, recipient, { from: holder });
+
+          await expectEvent.inTransaction(tx, this.token, 'Transfer', {
+            from: holder,
+            to: this.vault.address,
+            value: depositAssets,
+          });
+
+          await expectEvent.inTransaction(tx, this.vault, 'Transfer', {
+            from: constants.ZERO_ADDRESS,
+            to: recipient,
+            value: expectedShares,
+          });
+        });
+
+        /**
+         * | offset | deposited assets     | redeemable assets    |
+         * |--------|----------------------|----------------------|
+         * | 0      | 1000000000000000001. | 1000000000000000001. |
+         * | 6      | 1000000000000000001. | 1000000000000000001. |
+         * | 18     | 1000000000000000001. | 1000000000000000001. |
+         *
+         * Using mint protects against inflation attack, but makes minting shares very expensive.
+         * The ER20 allowance for the underlying asset is needed to protect the user from (too)
+         * large deposits.
+         */
+        it('mint', async function () {
+          const effectiveAssets = await this.vault.totalAssets().then(x => x.add(virtualAssets));
+          const effectiveShares = await this.vault.totalSupply().then(x => x.add(virtualShares));
+
+          const mintShares = parseShare(1);
+          const expectedAssets = mintShares.mul(effectiveAssets).div(effectiveShares);
+
+          expect(await this.vault.maxMint(holder)).to.be.bignumber.equal(constants.MAX_UINT256);
+          expect(await this.vault.previewMint(mintShares)).to.be.bignumber.equal(expectedAssets);
+
+          const { tx } = await this.vault.mint(mintShares, recipient, { from: holder });
+
+          await expectEvent.inTransaction(tx, this.token, 'Transfer', {
+            from: holder,
+            to: this.vault.address,
+            value: expectedAssets,
+          });
+
+          await expectEvent.inTransaction(tx, this.vault, 'Transfer', {
+            from: constants.ZERO_ADDRESS,
+            to: recipient,
+            value: mintShares,
+          });
+        });
+
+        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 });
+
+          await expectEvent.inTransaction(tx, this.token, 'Transfer', {
+            from: this.vault.address,
+            to: recipient,
+            value: '0',
+          });
+
+          await 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 });
+
+          await expectEvent.inTransaction(tx, this.token, 'Transfer', {
+            from: this.vault.address,
+            to: recipient,
+            value: '0',
+          });
+
+          await expectEvent.inTransaction(tx, this.vault, 'Transfer', {
+            from: holder,
+            to: constants.ZERO_ADDRESS,
+            value: '0',
+          });
+        });
+      });
+
+      describe('full vault: assets & shares', function () {
+        beforeEach(async function () {
+          // Add 1 token of underlying asset and 100 shares to the vault
+          await this.token.$_mint(this.vault.address, parseToken(1));
+          await this.vault.$_mint(holder, parseShare(100));
+        });
+
+        it('status', async function () {
+          expect(await this.vault.totalSupply()).to.be.bignumber.equal(parseShare(100));
+          expect(await this.vault.totalAssets()).to.be.bignumber.equal(parseToken(1));
+        });
+
+        /**
+         * | offset | deposited assets     | redeemable assets    |
+         * |--------|--------------------- |----------------------|
+         * | 0      | 1.000000000000000000 | 0.999999999999999999 |
+         * | 6      | 1.000000000000000000 | 0.999999999999999999 |
+         * | 18     | 1.000000000000000000 | 0.999999999999999999 |
+         *
+         * Virtual shares & assets captures part of the value
+         */
+        it('deposit', async function () {
+          const effectiveAssets = await this.vault.totalAssets().then(x => x.add(virtualAssets));
+          const effectiveShares = await this.vault.totalSupply().then(x => x.add(virtualShares));
+
+          const depositAssets = parseToken(1);
+          const expectedShares = depositAssets.mul(effectiveShares).div(effectiveAssets);
+
+          expect(await this.vault.maxDeposit(holder)).to.be.bignumber.equal(constants.MAX_UINT256);
+          expect(await this.vault.previewDeposit(depositAssets)).to.be.bignumber.equal(expectedShares);
+
+          const { tx } = await this.vault.deposit(depositAssets, recipient, { from: holder });
+
+          await expectEvent.inTransaction(tx, this.token, 'Transfer', {
+            from: holder,
+            to: this.vault.address,
+            value: depositAssets,
+          });
+
+          await expectEvent.inTransaction(tx, this.vault, 'Transfer', {
+            from: constants.ZERO_ADDRESS,
+            to: recipient,
+            value: expectedShares,
+          });
+        });
+
+        /**
+         * | offset | deposited assets     | redeemable assets    |
+         * |--------|--------------------- |----------------------|
+         * | 0      | 0.010000000000000001 | 0.010000000000000000 |
+         * | 6      | 0.010000000000000001 | 0.010000000000000000 |
+         * | 18     | 0.010000000000000001 | 0.010000000000000000 |
+         *
+         * Virtual shares & assets captures part of the value
+         */
+        it('mint', async function () {
+          const effectiveAssets = await this.vault.totalAssets().then(x => x.add(virtualAssets));
+          const effectiveShares = await this.vault.totalSupply().then(x => x.add(virtualShares));
+
+          const mintShares = parseShare(1);
+          const expectedAssets = mintShares.mul(effectiveAssets).div(effectiveShares).addn(1); // add for the rounding
+
+          expect(await this.vault.maxMint(holder)).to.be.bignumber.equal(constants.MAX_UINT256);
+          expect(await this.vault.previewMint(mintShares)).to.be.bignumber.equal(expectedAssets);
+
+          const { tx } = await this.vault.mint(mintShares, recipient, { from: holder });
+
+          await expectEvent.inTransaction(tx, this.token, 'Transfer', {
+            from: holder,
+            to: this.vault.address,
+            value: expectedAssets,
+          });
+
+          await expectEvent.inTransaction(tx, this.vault, 'Transfer', {
+            from: constants.ZERO_ADDRESS,
+            to: recipient,
+            value: mintShares,
+          });
+        });
+
+        it('withdraw', async function () {
+          const effectiveAssets = await this.vault.totalAssets().then(x => x.add(virtualAssets));
+          const effectiveShares = await this.vault.totalSupply().then(x => x.add(virtualShares));
+
+          const withdrawAssets = parseToken(1);
+          const expectedShares = withdrawAssets.mul(effectiveShares).div(effectiveAssets).addn(1); // add for the rounding
+
+          expect(await this.vault.maxWithdraw(holder)).to.be.bignumber.equal(withdrawAssets);
+          expect(await this.vault.previewWithdraw(withdrawAssets)).to.be.bignumber.equal(expectedShares);
+
+          const { tx } = await this.vault.withdraw(withdrawAssets, recipient, holder, { from: holder });
+
+          await expectEvent.inTransaction(tx, this.token, 'Transfer', {
+            from: this.vault.address,
+            to: recipient,
+            value: withdrawAssets,
+          });
+
+          await expectEvent.inTransaction(tx, this.vault, 'Transfer', {
+            from: holder,
+            to: constants.ZERO_ADDRESS,
+            value: expectedShares,
+          });
+        });
+
+        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 });
+
+          await expectEvent.inTransaction(tx, this.token, 'Transfer', {
+            from: this.vault.address,
+            to: recipient,
+            value: parseToken(1),
+          });
+
+          await 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 });
+        });
       });
     });
-
-    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 });
-
-      await expectEvent.inTransaction(tx, this.token, 'Transfer', {
-        from: this.vault.address,
-        to: recipient,
-        value: parseToken(1),
-      });
-
-      await 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 });
-
-      await expectEvent.inTransaction(tx, this.token, 'Transfer', {
-        from: this.vault.address,
-        to: recipient,
-        value: parseToken(1),
-      });
-
-      await 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/transmissions11/solmate/blob/main/src/test/ERC4626.t.sol
@@ -468,8 +456,8 @@ contract('ERC4626', function (accounts) {
 
     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.convertToAssets(await this.vault.balanceOf(user1))).to.be.bignumber.equal('2999'); // used to be 3000, but virtual assets/shares captures part of the yield
+    expect(await this.vault.convertToAssets(await this.vault.balanceOf(user2))).to.be.bignumber.equal('5999'); // used to be 6000, but virtual assets/shares captures part of the yield
     expect(await this.vault.totalSupply()).to.be.bignumber.equal('6000');
     expect(await this.vault.totalAssets()).to.be.bignumber.equal('9000');
 
@@ -503,7 +491,7 @@ contract('ERC4626', function (accounts) {
       await expectEvent.inTransaction(tx, this.token, 'Transfer', {
         from: user2,
         to: this.vault.address,
-        value: '3001',
+        value: '3000', // used to be 3001
       });
       await expectEvent.inTransaction(tx, this.vault, 'Transfer', {
         from: constants.ZERO_ADDRESS,
@@ -513,10 +501,10 @@ contract('ERC4626', function (accounts) {
 
       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(user1))).to.be.bignumber.equal('4999'); // used to be 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');
+      expect(await this.vault.totalAssets()).to.be.bignumber.equal('14000'); // used to be 14001
     }
 
     // 6. Vault mutates by +3000 tokens
@@ -525,10 +513,10 @@ contract('ERC4626', function (accounts) {
 
     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.convertToAssets(await this.vault.balanceOf(user1))).to.be.bignumber.equal('6070'); // used to be 6071
+    expect(await this.vault.convertToAssets(await this.vault.balanceOf(user2))).to.be.bignumber.equal('10928'); // used to be 10929
     expect(await this.vault.totalSupply()).to.be.bignumber.equal('9333');
-    expect(await this.vault.totalAssets()).to.be.bignumber.equal('17001');
+    expect(await this.vault.totalAssets()).to.be.bignumber.equal('17000'); // used to be 17001
 
     // 7. Alice redeem 1333 shares (2428 assets)
     {
@@ -541,7 +529,7 @@ contract('ERC4626', function (accounts) {
       await expectEvent.inTransaction(tx, this.token, 'Transfer', {
         from: this.vault.address,
         to: user1,
-        value: '2428',
+        value: '2427', // used to be 2428
       });
 
       expect(await this.vault.balanceOf(user1)).to.be.bignumber.equal('2000');
@@ -592,7 +580,7 @@ contract('ERC4626', function (accounts) {
       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.convertToAssets(await this.vault.balanceOf(user2))).to.be.bignumber.equal('8000'); // used to be 8001
       expect(await this.vault.totalSupply()).to.be.bignumber.equal('4392');
       expect(await this.vault.totalAssets()).to.be.bignumber.equal('8001');
     }
@@ -608,7 +596,7 @@ contract('ERC4626', function (accounts) {
       await expectEvent.inTransaction(tx, this.token, 'Transfer', {
         from: this.vault.address,
         to: user2,
-        value: '8001',
+        value: '8000', // used to be 8001
       });
 
       expect(await this.vault.balanceOf(user1)).to.be.bignumber.equal('0');
@@ -616,7 +604,7 @@ contract('ERC4626', function (accounts) {
       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');
+      expect(await this.vault.totalAssets()).to.be.bignumber.equal('1'); // used to be 0
     }
   });
 });

+ 1 - 1
test/utils/math/Math.t.sol

@@ -177,7 +177,7 @@ contract MathTest is Test {
     }
 
     // Helpers
-    function _asRounding(uint8 r) private returns (Math.Rounding) {
+    function _asRounding(uint8 r) private pure returns (Math.Rounding) {
         vm.assume(r < uint8(type(Math.Rounding).max));
         return Math.Rounding(r);
     }