Implement an ERC4626 Token Vault

·

12 min read

Implement an ERC4626 Token Vault

Setting Up a Profit Sharing Vault

A common pattern in Defi is to create a vault that holds assets and distributes profits to the vault's token holders. Users will pool their assets in the vault and receive a corresponding token representing their share of the vault's assets. When the assets in the vault grow from share creation, each share's corresponding value remains the same. When the assets in the vault grow from profit accumulation, each share's corresponding value grows proportionally.

Here we will demonstrate the basic implementation of such a vault, utilizing the ERC4626 standard.

ERC4626 is tokenized vault standard which itself extends an ERC20 token. This means that the vault contract includes an ERC20 token to be used as the vault's shares. Further, the vault will have the deployer define an asset token to be used as a deposit asset exchanged for shares in the vault.

Utilizing the openzeppelin library we can implement a simple version of this contract incredibly easily. Everything we need to get started with the vault comes fully functional, completely out of the box.

once we implement a simple vault, we'll explore some of its functionality through testing.

Setting Up the Vault Contract

First, we can start with an empty foundry project, install the openzeppelin library and implement our basic vault through inheritance.

src/SharesVault.sol

import { ERC4626 } from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import { ERC20, IERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract SharesVault is ERC4626 {
    constructor(address _depositToken) ERC4626(IERC20(_depositToken)) ERC20("Shares Vault", "SV") {

    }
}

That's it! That's all we need to get started, simply import the ERC4626 and ERC20 contracts from the openzeppelin library and inherit them in our contract. Then in the constructor we pass the address of our asset token which will get passed along to the ERC4626 constructor as an ERC20 token. We now have a fully functional and ERC4626 compliant shares vault. Later on in this post we will see how to add some functionality to this implementation specific to our business logic.

Setting Up the Tests

Now we can set up some tests to see how it works. The standard foundry test setup should do fine for now. but we will also need to generate a Mock token to act as our asset token. So, import ERC20 at the top and at the bottom of the test file we will implement a simple token.

test/SharesVault.t.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;

import {Test, console2} from "forge-std/Test.sol";
import { SharesVault } from "../src/SharesVault.sol";
import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract TestSharesVault is Test {
    function setUp() public {

    }
}

contract MockERC20 is ERC20 {
    constructor (string memory name_, string memory symbol_) ERC20(name_, symbol_) {}

    function mint(address to, uint256 amount) external {
        _mint(to, amount);
    }  
}

Now that we have the basic scaffolding of our test setup we can implement the setup and deployment of our vault. This is a simple setup which only requires us to deploy an instance of the SharesVault contract with our MockERC20 token passed to the constructor as its deposit token. We can then mint some depositToken to our test users. We have named our vault shares here for better readability when making token transactions. remember our SharesVault instance is also our shares token

contract TestSharesVault is Test {
    address OwnerWallet;
    address user1;
    address user2;
    address user3;
    SharesVault shares;
    MockERC20 depositToken; // assets token

    function setUp() public {
        OwnerWallet = address(69);

        user1 = address(420);
        user2 = address(666);
        user3 = address(777);

        vm.prank(OwnerWallet);
        depositToken = new MockERC20("Deposit Token", "DT");
        depositToken.mint(user1, 10 ether);
        depositToken.mint(user2, 10 ether);
        depositToken.mint(OwnerWallet, 10 ether);
        shares = new SharesVault(address(depositToken));
        vm.stopPrank();
    }
}

Testing the Share Creation Mechanism

Once We have the vault deployed we can start utilizing its functions. The ERC4626 standard includes two function which handle the use case of a user depositing assets and receiving shares in return.

The deposit(uint256 assets, address receiver) function which allows the user to define the amount of asset they want to deposit and receive a corresponding amount of shares.

The mint(uint256 shares, address receiver) function which allows the user to define the amount of shares they want to mint and the vault will pull the corresponding amount of assets from the user in exchange for those minted shares.

We can see these functions in action by first implementing an approval from the user to the vault and then calling the deposit or mint function. After that we can check our balance of shares and the balance of the deposit tokens in the vault.


    function test_deposit() public {
        vm.startPrank(user1);
        depositToken.approve(address(shares), 1 ether);
        shares.deposit(1 ether, user1);
        assertEq(shares.balanceOf(user1), 1 ether);
        assertEq(depositToken.balanceOf(address(shares)), 1 ether);
        vm.stopPrank();
    }

    function test_mint() public {
        vm.startPrank(user1);
        depositToken.approve(address(shares), 1 ether);
        shares.mint(1 ether, user1);
        assertEq(shares.balanceOf(user1), 1 ether);
        assertEq(depositToken.balanceOf(address(shares)), 1 ether);
        vm.stopPrank();
    }

We can see that, since our test user1 is the only depositor to the vault, we have been minted a share in a 1:1 ratio with the deposit token.

Testing the Share Redemption Mechanism

Now, we can have a look at the share redemption mechanism included in our ERC4626 contract. The standard includes two functions for exchanging our shares for the underlying assets of the vault.

The withdraw(uint256 assets, address receiver, address owner) function which allows the user to define the amount of assets they want to withdraw and the vault will pull and burn the corresponding amount of shares from the user in exchange for those assets.

The burn(uint256 shares, address receiver, address owner) function which allows the user to define the amount of shares they want to burn and receive a corresponding amount of assets.

Both of these functions require the msg.sender of the transaction to be the owner of the shares being burned but the withdrawn assets can be sent to any receiver the owner defines via the address receiver parameter.

Our tests will look very similar to the share creation tests, expect that we will first run test_mint() so that our tests start off with a user who has some shares and assets in the vault.

    function test_withdraw() public {
        test_mint();
        vm.startPrank(user1);
        shares.withdraw(1 ether, user1, user1);
        assertEq(shares.balanceOf(user1), 0);
        assertEq(depositToken.balanceOf(address(shares)), 0);
        assertEq(depositToken.balanceOf(user1), 10 ether);
        vm.stopPrank();
    }
    function test_redeem() public {
        test_mint();
        vm.startPrank(user1);
        shares.redeem(1 ether, user1, user1);
        assertEq(shares.balanceOf(user1), 0);
        assertEq(depositToken.balanceOf(address(shares)), 0);
        assertEq(depositToken.balanceOf(user1), 10 ether);
        vm.stopPrank();
    }

We can see here that our user is able to use either of these functions to redeem their shares in exchange for the underlying assets of the vault to which they are entitled. Which function they use is simply determined by which parameter they wish to define. Either, the amount of shares to redeem or the amount of assets to withdraw.

Implementing Profit Sharing.

Now that we have seen the usage of a fully compliant vault, we can implement some custom functionality to support our business logic.

In this case, lets say, we want to implement a profit sharing mechanism so that shareholders in the vault can receive a dividend of the profits generated by the vaults capital. We'll say that these profits are occasionally distributed from the vault owner to the shareholders.

Thus, we simply need a function which the owner can call to transfer profits, in the form of the asset token into the vault.

import { ERC4626 } from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import { ERC20, IERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

contract SharesVault is ERC4626 {
    constructor(address _depositToken) ERC4626(IERC20(_depositToken)) ERC20("Shares Vault", "SV") {

    }

    function shareProfits(uint256 amount) public {
        SafeERC20.safeTransferFrom(IERC20(asset()), msg.sender, address(this), amount);
    }
}

All we've done here is import the SafeERC20 utility from openzeppelin and implemented a shareProfits function which executes a safeTransferFrom of the asset token from the caller to the vault. Note, however, that this is not strictly necessary. As any asset tokens set directly to the contract without going through the share creation mechanism will result in the vaults total assets being increase while the totalSupply of shares remains the same, thus the existing shareholders receive their portion of these profits regardless if this function is executed or not. We implement it here for convenience and to demonstrate the concept.

Now we can test it out. In order to do so, we will need to setup a test state where the vault has some shareholders and assets. We can achieve this by simply adding a utility function to our test file.

    function setup_shareholders() public {
        vm.startPrank(user1);
        depositToken.approve(address(shares), 10 ether);
        shares.deposit(10 ether, user1);
        vm.stopPrank();
        vm.startPrank(user2);
        depositToken.approve(address(shares), 10 ether);
        shares.deposit(10 ether, user2);
        vm.stopPrank();
        assertEq(shares.balanceOf(user1), 10 ether);
        assertEq(shares.balanceOf(user2), 10 ether);
    }

This will give us two shareholders with equal shares in the vault and 20 ether of assets in the vault. Then we can implement a test were the vault receives some profits from the owner.

    function test_profitSharing() public {
        setup_shareholders();
        vm.startPrank(OwnerWallet);
        depositToken.approve(address(shares), 2 ether);
        shares.shareProfits(2 ether);
        vm.stopPrank();
        assertEq(depositToken.balanceOf(address(shares)), 22 ether);
        uint256 user1_value = shares.previewRedeem(10 ether);
        assertEq(user1_value, 11 ether);
    }

After the owner executes shareProfits and transfers in 2 asset tokens, we can use one of the vaults view functions to get an accounting of our shares new value. In this case, we'll use the previewRedeem function which allows us to see the value of our shares if we were to redeem them for the underlying assets and we'll pass it our user1 balance of 10 shares.

Our hypothetical user1 deposited 10 asset tokens in exchange for 10 shares. The pools total assets was 20 with 20 shares outstanding, since we had a second user do the same. Now, the pool has earned a profit of 2 asset tokens so we would expect the value of our shares to have grown to 11 asset tokens. 1 token being half the profits since we hold half of the shares.

However if we run this test with forge test -vv we see that it fails! and produces the following output.

[FAIL. Reason: assertion failed] test_profitSharing() (gas: 195837)
Logs:
  Error: a == b not satisfied [uint]
        Left: 10999999999999999999
       Right: 11000000000000000000

So, what happened? In fact we are only short 1 wei of what we expected.

If we go to the openzeppelin implementation we can have a look at how this happened. It is fundamentally an issue of precision. we called previewRedeem in our test which itself calls _convertToAssets

    /** @dev See {IERC4626-previewRedeem}. */
    function previewRedeem(uint256 shares) public view virtual returns (uint256) {
        return _convertToAssets(shares, Math.Rounding.Floor);
    }

    /**
     * @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) {
        return shares.mulDiv(totalAssets() + 1, totalSupply() + 10 ** _decimalsOffset(), rounding);
    }

We can see now that conversion function takes the shares amount * totalAssets+1 (plus one avoids having 0 in the denominator during share creation so that the first depositor can receive a 1:1 exchange of assets for shares, +1 here preserves that relationship) the product is then divided by the totalSupply of shares + virtual liquidity ( totalSupply() + 10 ** _decimalOffset()).

The _decimalOffset default value is 0, this number is used to prevent certain kinds of inflations attacks which we will go over later. Here, its default is 10**0 or 1. So we are effectively adding 1 to the totalSupply in addition to adding one to the totalAssets.

This all results in the amount of assets proportional to the shares amount, this final number is then rounded down. running these numbers in python with integer division we can see that the result is 10999999999999999999.

10000000000000000000 * 22000000000000000001 // 20000000000000000001
220000000000000000010000000000000000000 // 20000000000000000001

Since the vault always rounds in favor of the vault, in this case rounding down, the precision of redemption calculations will sometimes result in the loss of value, from the user to the pool. This loss becomes more pronounced the lower the amount of shares being redeemed since it accounts for a larger percentage of the expected return. From the user's perspective this sort of loss should be considered transaction slippage.

Inflation Attack Vector

Interestingly, this kind of precision error also occurs in the deposit and mint functions. In fact, under certain circumstances a depositor can receive 0 shares for their assets because of this precision behavior and ends up making a donation to the vault, thus giving free money to the current shareholder.

This is actually an attack vector on ERC4626 vaults, known as an inflation attack, where by an attacking shareholder can front run a large share creation transaction with a large donation to the vault at a specific amount such that share creation transaction will round down to 0 shares.

The inflation attack is the reason for the _decimalsOffset variable to be included as virtual liquidity in the calculation of assets and shares. Effectively, virtual liquidity increases the precision of the shares token making it more expensive for the attacker to donate a sufficient amount to cause a victim to receive 0 shares.

Openzeppelin has a good explanation of this attack and the solution in their documentation.

Conclusion

Now that we have setup a basic functional vault, we can implement this kind ERC4626 compliant vault with some custom functionality into many different kinds of Defi application such as a lending protocol, AMM, pooled yield vault, DAOs etc.

Final Code

src/SharesVault.sol

import { ERC4626 } from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import { ERC20, IERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

contract SharesVault is ERC4626 {
    constructor(address _depositToken) ERC4626(IERC20(_depositToken)) ERC20("Shares Vault", "SV") {

    }

    function shareProfits(uint256 amount) public {
        SafeERC20.safeTransferFrom(IERC20(asset()), msg.sender, address(this), amount);
    }

}

test/SharesVault.t.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;

import {Test, console2} from "forge-std/Test.sol";
import { SharesVault } from "../src/SharesVault.sol";
import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract TestSharesVault is Test {
    address OwnerWallet;
    address user1;
    address user2;
    address user3;
    SharesVault shares;
    MockERC20 depositToken; // assets token

    function setUp() public {
        OwnerWallet = address(69);

        user1 = address(420);
        user2 = address(666);
        user3 = address(777);

        vm.prank(OwnerWallet);
        depositToken = new MockERC20("Deposit Token", "DT");
        depositToken.mint(user1, 10 ether);
        depositToken.mint(user2, 10 ether);
        depositToken.mint(OwnerWallet, 10 ether);
        shares = new SharesVault(address(depositToken));
        vm.stopPrank();
    }
    function setup_shareholders() public {
        vm.startPrank(user1);
        depositToken.approve(address(shares), 10 ether);
        shares.deposit(10 ether, user1);
        vm.stopPrank();
        vm.startPrank(user2);
        depositToken.approve(address(shares), 10 ether);
        shares.deposit(10 ether, user2);
        vm.stopPrank();
        assertEq(shares.balanceOf(user1), 10 ether);
        assertEq(shares.balanceOf(user2), 10 ether);
    }
    // Two functions handle the use case of a user depositing tokens and receiving shares in return.
    // deposit(uint256 assets, address receiver)
    // mint(uint256 shares, address receiver)
    function test_deposit() public {
        vm.startPrank(user1);
        depositToken.approve(address(shares), 1 ether);
        shares.deposit(1 ether, user1);
        assertEq(shares.balanceOf(user1), 1 ether);
        assertEq(depositToken.balanceOf(address(shares)), 1 ether);
        vm.stopPrank();
    }
    function test_mint() public {
        vm.startPrank(user1);
        depositToken.approve(address(shares), 1 ether);
        shares.mint(1 ether, user1);
        assertEq(shares.balanceOf(user1), 1 ether);
        assertEq(depositToken.balanceOf(address(shares)), 1 ether);
        vm.stopPrank();
    }
    // Two functions handle the redemption of shares for the underlying asset
    // withdraw(uint256 assets, address receiver)
    // redeem(uint256 shares, address receiver)
    function test_withdraw() public {
        test_mint();
        vm.startPrank(user1);
        shares.withdraw(1 ether, user1, user1);
        assertEq(shares.balanceOf(user1), 0);
        assertEq(depositToken.balanceOf(address(shares)), 0);
        assertEq(depositToken.balanceOf(user1), 10 ether);
        vm.stopPrank();
    }
    function test_redeem() public {
        test_mint();
        vm.startPrank(user1);
        shares.redeem(1 ether, user1, user1);
        assertEq(shares.balanceOf(user1), 0);
        assertEq(depositToken.balanceOf(address(shares)), 0);
        assertEq(depositToken.balanceOf(user1), 10 ether);
        vm.stopPrank();
    }
    function test_profitSharing() public {
        setup_shareholders();
        vm.startPrank(OwnerWallet);
        depositToken.approve(address(shares), 2 ether);
        shares.shareProfits(2 ether);
        vm.stopPrank();
        assertEq(depositToken.balanceOf(address(shares)), 22 ether);
        uint256 user1_value = shares.previewRedeem(10 ether);
        assertEq(user1_value, 11 ether);
    }
}

contract MockERC20 is ERC20 {
    constructor (string memory name_, string memory symbol_) ERC20(name_, symbol_) {
    }

    function mint(address to, uint256 amount) external {
        _mint(to, amount);
    }
}