Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions contracts/tokenbridge/libraries/vault/IMasterVault.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.0;

interface IMasterVault {
function setSubVault(address subVault) external;
}
13 changes: 13 additions & 0 deletions contracts/tokenbridge/libraries/vault/IMasterVaultFactory.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.0;

interface IMasterVaultFactory {
event VaultDeployed(address indexed token, address indexed vault);
event SubVaultSet(address indexed masterVault, address indexed subVault);

function initialize(address _owner) external;
function deployVault(address token) external returns (address vault);
function calculateVaultAddress(address token) external view returns (address);
function getVault(address token) external returns (address);
function setSubVault(address masterVault, address subVault) external;
}
255 changes: 255 additions & 0 deletions contracts/tokenbridge/libraries/vault/MasterVault.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.0;

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

contract MasterVault is ERC4626, Ownable {
using SafeERC20 for IERC20;
using Math for uint256;

error TooFewSharesReceived();
error TooManySharesBurned();
error TooManyAssetsDeposited();
error TooFewAssetsReceived();
error SubVaultAlreadySet();
error SubVaultCannotBeZeroAddress();
error MustHaveSupplyBeforeSettingSubVault();
error SubVaultAssetMismatch();
error SubVaultExchangeRateTooLow();
error NoExistingSubVault();
error MustHaveSupplyBeforeSwitchingSubVault();
error NewSubVaultExchangeRateTooLow();

// todo: avoid inflation, rounding, other common 4626 vulns
// we may need a minimum asset or master share amount when setting subvaults (bc of exchange rate calc)
ERC4626 public subVault;

// how many subVault shares one MV2 share can be redeemed for
// initially 1 to 1
// constant per subvault
// changes when subvault is set
uint256 public subVaultExchRateWad = 1e18;

// note: the performance fee can be avoided if the underlying strategy can be sandwiched (eg ETH to wstETH dex swap)
// maybe a simpler and more robust implementation would be for the owner to adjust the subVaultExchRateWad directly
// this would also avoid the need for totalPrincipal tracking
// however, this would require more trust in the owner
uint256 public performanceFeeBps; // in basis points, e.g. 200 = 2% | todo a way to set this
uint256 totalPrincipal; // total assets deposited, used to calculate profit

event SubvaultChanged(address indexed oldSubvault, address indexed newSubvault);

constructor(IERC20 _asset, string memory _name, string memory _symbol) ERC20(_name, _symbol) ERC4626(_asset) Ownable() {}

function deposit(uint256 assets, address receiver, uint256 minSharesMinted) public returns (uint256) {
uint256 shares = super.deposit(assets, receiver);
if (shares < minSharesMinted) revert TooFewSharesReceived();
return shares;
}

function withdraw(uint256 assets, address receiver, address _owner, uint256 maxSharesBurned) public returns (uint256) {
uint256 shares = super.withdraw(assets, receiver, _owner);
if (shares > maxSharesBurned) revert TooManySharesBurned();
return shares;
}

function mint(uint256 shares, address receiver, uint256 maxAssetsDeposited) public returns (uint256) {
uint256 assets = super.mint(shares, receiver);
if (assets > maxAssetsDeposited) revert TooManyAssetsDeposited();
return assets;
}

function redeem(uint256 shares, address receiver, address _owner, uint256 minAssetsReceived) public returns (uint256) {
uint256 assets = super.redeem(shares, receiver, _owner);
if (assets < minAssetsReceived) revert TooFewAssetsReceived();
return assets;
}

/// @notice Set a subvault. Can only be called if there is not already a subvault set.
/// @param _subVault The subvault to set. Must be an ERC4626 vault with the same asset as this MasterVault.
/// @param minSubVaultExchRateWad Minimum acceptable ratio (times 1e18) of new subvault shares to outstanding MasterVault shares after deposit.
function setSubVault(ERC4626 _subVault, uint256 minSubVaultExchRateWad) external onlyOwner {
if (address(subVault) != address(0)) revert SubVaultAlreadySet();
_setSubVault(_subVault, minSubVaultExchRateWad);
}

/// @notice Revokes the current subvault, moving all assets back to MasterVault
/// @param minAssetExchRateWad Minimum acceptable ratio (times 1e18) of assets received from subvault to outstanding MasterVault shares
function revokeSubVault(uint256 minAssetExchRateWad) external onlyOwner {
_revokeSubVault(minAssetExchRateWad);
}

function _setSubVault(ERC4626 _subVault, uint256 minSubVaultExchRateWad) internal {
if (address(_subVault) == address(0)) revert SubVaultCannotBeZeroAddress();
if (totalSupply() == 0) revert MustHaveSupplyBeforeSettingSubVault();
if (address(_subVault.asset()) != address(asset())) revert SubVaultAssetMismatch();

IERC20(asset()).safeApprove(address(_subVault), type(uint256).max);
uint256 subShares = _subVault.deposit(totalAssets(), address(this));

uint256 _subVaultExchRateWad = subShares.mulDiv(1e18, totalSupply(), Math.Rounding.Down);
if (_subVaultExchRateWad < minSubVaultExchRateWad) revert SubVaultExchangeRateTooLow();
subVaultExchRateWad = _subVaultExchRateWad;

subVault = _subVault;

emit SubvaultChanged(address(0), address(_subVault));
}
Comment on lines +92 to +107

Check warning

Code scanning / Slither

Dangerous strict equalities Medium


function _revokeSubVault(uint256 minAssetExchRateWad) internal {
ERC4626 oldSubVault = subVault;
if (address(oldSubVault) == address(0)) revert NoExistingSubVault();

uint256 _totalSupply = totalSupply();
uint256 assetReceived = oldSubVault.withdraw(oldSubVault.maxWithdraw(address(this)), address(this), address(this));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is an edge case here - the subvault may not have enough liquidity to serve this big withdrawal all at once.

we probably need to make switching vaults more robust to those liquidity constaints.

the same could be said about depositing to the new vault, it could be such a large deposit that slippage starts to become a serious issue

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could do check whether maxWithdraw will return same amount of what master vault actually own

uint256 effectiveAssetExchRateWad = assetReceived.mulDiv(1e18, _totalSupply, Math.Rounding.Down);
if (effectiveAssetExchRateWad < minAssetExchRateWad) revert TooFewAssetsReceived();

IERC20(asset()).safeApprove(address(oldSubVault), 0);
subVault = ERC4626(address(0));
subVaultExchRateWad = 1e18;

emit SubvaultChanged(address(oldSubVault), address(0));
}

/// @notice Switches to a new subvault or revokes current subvault if newSubVault is zero address
/// @param newSubVault The new subvault to switch to, or zero address to revoke current subvault
/// @param minAssetExchRateWad Minimum acceptable ratio (times 1e18) of assets received from old subvault to outstanding MasterVault shares
/// @param minNewSubVaultExchRateWad Minimum acceptable ratio (times 1e18) of new subvault shares to outstanding MasterVault shares after deposit
function switchSubVault(ERC4626 newSubVault, uint256 minAssetExchRateWad, uint256 minNewSubVaultExchRateWad) external onlyOwner {
_revokeSubVault(minAssetExchRateWad);

if (address(newSubVault) != address(0)) {
_setSubVault(newSubVault, minNewSubVaultExchRateWad);
}
}

function masterSharesToSubShares(uint256 masterShares, Math.Rounding rounding) public view returns (uint256) {
return masterShares.mulDiv(subVaultExchRateWad, 1e18, rounding);
}

function subSharesToMasterShares(uint256 subShares, Math.Rounding rounding) public view returns (uint256) {
return subShares.mulDiv(1e18, subVaultExchRateWad, rounding);
}

/** @dev See {IERC4626-totalAssets}. */
function totalAssets() public view virtual override returns (uint256) {
ERC4626 _subVault = subVault;
if (address(_subVault) == address(0)) {
return super.totalAssets();
}
return _subVault.convertToAssets(_subVault.balanceOf(address(this)));
}

/** @dev See {IERC4626-maxDeposit}. */
function maxDeposit(address) public view virtual override returns (uint256) {
if (address(subVault) == address(0)) {
return type(uint256).max;
}
return subVault.maxDeposit(address(this));
}

/** @dev See {IERC4626-maxMint}. */
function maxMint(address) public view virtual override returns (uint256) {
uint256 subShares = subVault.maxMint(address(this));
if (subShares == type(uint256).max) {
return type(uint256).max;
}
return subSharesToMasterShares(subShares, Math.Rounding.Down);
}

/**
* @dev Internal conversion 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 amount of shares.
*/
function _convertToShares(uint256 assets, Math.Rounding rounding) internal view virtual override returns (uint256 shares) {
ERC4626 _subVault = subVault;
if (address(_subVault) == address(0)) {
return super._convertToShares(assets, rounding);
}
uint256 subShares = rounding == Math.Rounding.Up ? _subVault.previewWithdraw(assets) : _subVault.previewDeposit(assets);
return subSharesToMasterShares(subShares, rounding);
}

/**
* @dev Internal conversion function (from shares to assets) with support for rounding direction.
*/
function _convertToAssets(uint256 shares, Math.Rounding rounding) internal view virtual override returns (uint256 assets) {
ERC4626 _subVault = subVault;
if (address(_subVault) == address(0)) {
return super._convertToAssets(shares, rounding);
}
uint256 subShares = masterSharesToSubShares(shares, rounding);
return rounding == Math.Rounding.Up ? _subVault.previewMint(subShares) : _subVault.previewRedeem(subShares);
}

function totalProfit() public view returns (uint256) {
uint256 _totalAssets = totalAssets();
return _totalAssets > totalPrincipal ? _totalAssets - totalPrincipal : 0;
}

/**
* @dev Deposit/mint common workflow.
*/
function _deposit(
address caller,
address receiver,
uint256 assets,
uint256 shares
) internal virtual override {
super._deposit(caller, receiver, assets, shares);
totalPrincipal += assets;
ERC4626 _subVault = subVault;
if (address(_subVault) != address(0)) {
_subVault.deposit(assets, address(this));
}
}
Comment on lines +237 to +249

Check warning

Code scanning / Slither

Unused return Medium


/**
* @dev Withdraw/redeem common workflow.
*/
function _withdraw(
address caller,
address receiver,
address _owner,
uint256 assets,
uint256 shares
) internal virtual override {
ERC4626 _subVault = subVault;
if (address(_subVault) != address(0)) {
_subVault.withdraw(assets, address(this), address(this));
}

////// PERF FEE STUFF //////
// determine profit portion and principal portion of assets
uint256 _totalProfit = totalProfit();
// use shares because they are rounded up vs assets which are rounded down
uint256 profitPortion = shares.mulDiv(_totalProfit, totalSupply(), Math.Rounding.Up);
uint256 principalPortion = assets - profitPortion;

// subtract principal portion from totalPrincipal
totalPrincipal -= principalPortion;

// send fee to owner (todo should be a separate beneficiary addr set by owner)
if (performanceFeeBps > 0 && profitPortion > 0) {
uint256 fee = profitPortion.mulDiv(performanceFeeBps, 10000, Math.Rounding.Up);
// send fee to owner
IERC20(asset()).safeTransfer(owner(), fee);

// note subtraction
assets -= fee;
}

////// END PERF FEE STUFF //////

// call super._withdraw with remaining assets
super._withdraw(caller, receiver, _owner, assets, shares);
}
Comment on lines 254 to 269

Check warning

Code scanning / Slither

Unused return Medium

}
69 changes: 69 additions & 0 deletions contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// SPDX-License-Identifier: Apache-2.0

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/utils/Create2.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
import "./IMasterVault.sol";
import "./IMasterVaultFactory.sol";
import "./MasterVault.sol";

contract MasterVaultFactory is IMasterVaultFactory, OwnableUpgradeable {

error ZeroAddress();

function initialize(address _owner) public initializer {
_transferOwnership(_owner);
}

function deployVault(address token) public returns (address vault) {
if (token == address(0)) {
revert ZeroAddress();
}

IERC20Metadata tokenMetadata = IERC20Metadata(token);
string memory name = string(abi.encodePacked("Master ", tokenMetadata.name()));
string memory symbol = string(abi.encodePacked("m", tokenMetadata.symbol()));

bytes memory bytecode = abi.encodePacked(
type(MasterVault).creationCode,
abi.encode(token, name, symbol)
);

vault = Create2.deploy(0, bytes32(0), bytecode);

emit VaultDeployed(token, vault);
}

function calculateVaultAddress(address token) public view returns (address) {
IERC20Metadata tokenMetadata = IERC20Metadata(token);
string memory name = string(abi.encodePacked("Master ", tokenMetadata.name()));
string memory symbol = string(abi.encodePacked("m", tokenMetadata.symbol()));

bytes32 bytecodeHash = keccak256(
abi.encodePacked(
type(MasterVault).creationCode,
abi.encode(token, name, symbol)
)
);
return Create2.computeAddress(bytes32(0), bytecodeHash);
}

function getVault(address token) external returns (address) {
address vault = calculateVaultAddress(token);
if (vault.code.length == 0) {
return deployVault(token);
}
return vault;
}

// todo: consider a method to enable bridge owner to transfer specific master vault ownership to new address
function setSubVault(
address masterVault,
address subVault
) external onlyOwner {
IMasterVault(masterVault).setSubVault(subVault);
emit SubVaultSet(masterVault, subVault);
}
}
18 changes: 18 additions & 0 deletions contracts/tokenbridge/test/MockSubVault.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.0;

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

contract MockSubVault is ERC4626 {
constructor(
IERC20 _asset,
string memory _name,
string memory _symbol
) ERC20(_name, _symbol) ERC4626(_asset) {}

function totalAssets() public view override returns (uint256) {
return IERC20(asset()).balanceOf(address(this));
}
}
Loading
Loading