Skip to content

Commit 951c33c

Browse files
committed
feat: add master vault
1 parent 15858f0 commit 951c33c

File tree

7 files changed

+620
-0
lines changed

7 files changed

+620
-0
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
pragma solidity ^0.8.0;
3+
4+
interface IMasterVault {
5+
function setSubVault(address subVault) external;
6+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
pragma solidity ^0.8.0;
3+
4+
interface IMasterVaultFactory {
5+
event VaultDeployed(address indexed token, address indexed vault);
6+
event SubVaultSet(address indexed masterVault, address indexed subVault);
7+
8+
function initialize(address _owner) external;
9+
function deployVault(address token) external returns (address vault);
10+
function calculateVaultAddress(address token) external view returns (address);
11+
function getVault(address token) external returns (address);
12+
function setSubVault(address masterVault, address subVault) external;
13+
}
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
pragma solidity ^0.8.0;
3+
4+
import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
5+
import { IERC20, ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
6+
import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";
7+
import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
8+
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
9+
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
10+
11+
contract MasterVault is ERC4626, Ownable {
12+
using SafeERC20 for IERC20;
13+
using Math for uint256;
14+
15+
error TooFewSharesReceived();
16+
error TooManySharesBurned();
17+
error TooManyAssetsDeposited();
18+
error TooFewAssetsReceived();
19+
error SubVaultAlreadySet();
20+
error SubVaultCannotBeZeroAddress();
21+
error MustHaveSupplyBeforeSettingSubVault();
22+
error SubVaultAssetMismatch();
23+
error SubVaultExchangeRateTooLow();
24+
error NoExistingSubVault();
25+
error MustHaveSupplyBeforeSwitchingSubVault();
26+
error NewSubVaultExchangeRateTooLow();
27+
28+
// todo: avoid inflation, rounding, other common 4626 vulns
29+
// we may need a minimum asset or master share amount when setting subvaults (bc of exchange rate calc)
30+
ERC4626 public subVault;
31+
32+
// how many subVault shares one MV2 share can be redeemed for
33+
// initially 1 to 1
34+
// constant per subvault
35+
// changes when subvault is set
36+
uint256 public subVaultExchRateWad = 1e18;
37+
38+
// note: the performance fee can be avoided if the underlying strategy can be sandwiched (eg ETH to wstETH dex swap)
39+
// maybe a simpler and more robust implementation would be for the owner to adjust the subVaultExchRateWad directly
40+
// this would also avoid the need for totalPrincipal tracking
41+
// however, this would require more trust in the owner
42+
uint256 public performanceFeeBps; // in basis points, e.g. 200 = 2% | todo a way to set this
43+
uint256 totalPrincipal; // total assets deposited, used to calculate profit
44+
45+
event SubvaultChanged(address indexed oldSubvault, address indexed newSubvault);
46+
47+
constructor(IERC20 _asset, string memory _name, string memory _symbol) ERC20(_name, _symbol) ERC4626(_asset) Ownable() {}
48+
49+
function deposit(uint256 assets, address receiver, uint256 minSharesMinted) public returns (uint256) {
50+
uint256 shares = super.deposit(assets, receiver);
51+
if (shares < minSharesMinted) revert TooFewSharesReceived();
52+
return shares;
53+
}
54+
55+
function withdraw(uint256 assets, address receiver, address _owner, uint256 maxSharesBurned) public returns (uint256) {
56+
uint256 shares = super.withdraw(assets, receiver, _owner);
57+
if (shares > maxSharesBurned) revert TooManySharesBurned();
58+
return shares;
59+
}
60+
61+
function mint(uint256 shares, address receiver, uint256 maxAssetsDeposited) public returns (uint256) {
62+
uint256 assets = super.mint(shares, receiver);
63+
if (assets > maxAssetsDeposited) revert TooManyAssetsDeposited();
64+
return assets;
65+
}
66+
67+
function redeem(uint256 shares, address receiver, address _owner, uint256 minAssetsReceived) public returns (uint256) {
68+
uint256 assets = super.redeem(shares, receiver, _owner);
69+
if (assets < minAssetsReceived) revert TooFewAssetsReceived();
70+
return assets;
71+
}
72+
73+
/// @notice Set a subvault. Can only be called if there is not already a subvault set.
74+
/// @param _subVault The subvault to set. Must be an ERC4626 vault with the same asset as this MasterVault.
75+
/// @param minSubVaultExchRateWad Minimum acceptable ratio (times 1e18) of new subvault shares to outstanding MasterVault shares after deposit.
76+
function setSubVault(ERC4626 _subVault, uint256 minSubVaultExchRateWad) external onlyOwner {
77+
if (address(subVault) != address(0)) revert SubVaultAlreadySet();
78+
_setSubVault(_subVault, minSubVaultExchRateWad);
79+
}
80+
81+
/// @notice Revokes the current subvault, moving all assets back to MasterVault
82+
/// @param minAssetExchRateWad Minimum acceptable ratio (times 1e18) of assets received from subvault to outstanding MasterVault shares
83+
function revokeSubVault(uint256 minAssetExchRateWad) external onlyOwner {
84+
_revokeSubVault(minAssetExchRateWad);
85+
}
86+
87+
function _setSubVault(ERC4626 _subVault, uint256 minSubVaultExchRateWad) internal {
88+
if (address(_subVault) == address(0)) revert SubVaultCannotBeZeroAddress();
89+
if (totalSupply() == 0) revert MustHaveSupplyBeforeSettingSubVault();
90+
if (address(_subVault.asset()) != address(asset())) revert SubVaultAssetMismatch();
91+
92+
IERC20(asset()).safeApprove(address(_subVault), type(uint256).max);
93+
uint256 subShares = _subVault.deposit(totalAssets(), address(this));
94+
95+
uint256 _subVaultExchRateWad = subShares.mulDiv(1e18, totalSupply(), Math.Rounding.Down);
96+
if (_subVaultExchRateWad < minSubVaultExchRateWad) revert SubVaultExchangeRateTooLow();
97+
subVaultExchRateWad = _subVaultExchRateWad;
98+
99+
subVault = _subVault;
100+
101+
emit SubvaultChanged(address(0), address(_subVault));
102+
}
103+
104+
function _revokeSubVault(uint256 minAssetExchRateWad) internal {
105+
ERC4626 oldSubVault = subVault;
106+
if (address(oldSubVault) == address(0)) revert NoExistingSubVault();
107+
108+
uint256 _totalSupply = totalSupply();
109+
uint256 assetReceived = oldSubVault.withdraw(oldSubVault.maxWithdraw(address(this)), address(this), address(this));
110+
uint256 effectiveAssetExchRateWad = assetReceived.mulDiv(1e18, _totalSupply, Math.Rounding.Down);
111+
if (effectiveAssetExchRateWad < minAssetExchRateWad) revert TooFewAssetsReceived();
112+
113+
IERC20(asset()).safeApprove(address(oldSubVault), 0);
114+
subVault = ERC4626(address(0));
115+
subVaultExchRateWad = 1e18;
116+
117+
emit SubvaultChanged(address(oldSubVault), address(0));
118+
}
119+
120+
/// @notice Switches to a new subvault or revokes current subvault if newSubVault is zero address
121+
/// @param newSubVault The new subvault to switch to, or zero address to revoke current subvault
122+
/// @param minAssetExchRateWad Minimum acceptable ratio (times 1e18) of assets received from old subvault to outstanding MasterVault shares
123+
/// @param minNewSubVaultExchRateWad Minimum acceptable ratio (times 1e18) of new subvault shares to outstanding MasterVault shares after deposit
124+
function switchSubVault(ERC4626 newSubVault, uint256 minAssetExchRateWad, uint256 minNewSubVaultExchRateWad) external onlyOwner {
125+
_revokeSubVault(minAssetExchRateWad);
126+
127+
if (address(newSubVault) != address(0)) {
128+
_setSubVault(newSubVault, minNewSubVaultExchRateWad);
129+
}
130+
}
131+
132+
function masterSharesToSubShares(uint256 masterShares, Math.Rounding rounding) public view returns (uint256) {
133+
return masterShares.mulDiv(subVaultExchRateWad, 1e18, rounding);
134+
}
135+
136+
function subSharesToMasterShares(uint256 subShares, Math.Rounding rounding) public view returns (uint256) {
137+
return subShares.mulDiv(1e18, subVaultExchRateWad, rounding);
138+
}
139+
140+
/** @dev See {IERC4626-totalAssets}. */
141+
function totalAssets() public view virtual override returns (uint256) {
142+
ERC4626 _subVault = subVault;
143+
if (address(_subVault) == address(0)) {
144+
return super.totalAssets();
145+
}
146+
return _subVault.convertToAssets(_subVault.balanceOf(address(this)));
147+
}
148+
149+
/** @dev See {IERC4626-maxDeposit}. */
150+
function maxDeposit(address) public view virtual override returns (uint256) {
151+
if (address(subVault) == address(0)) {
152+
return type(uint256).max;
153+
}
154+
return subVault.maxDeposit(address(this));
155+
}
156+
157+
/** @dev See {IERC4626-maxMint}. */
158+
function maxMint(address) public view virtual override returns (uint256) {
159+
uint256 subShares = subVault.maxMint(address(this));
160+
if (subShares == type(uint256).max) {
161+
return type(uint256).max;
162+
}
163+
return subSharesToMasterShares(subShares, Math.Rounding.Down);
164+
}
165+
166+
/**
167+
* @dev Internal conversion function (from assets to shares) with support for rounding direction.
168+
*
169+
* Will revert if assets > 0, totalSupply > 0 and totalAssets = 0. That corresponds to a case where any asset
170+
* would represent an infinite amount of shares.
171+
*/
172+
function _convertToShares(uint256 assets, Math.Rounding rounding) internal view virtual override returns (uint256 shares) {
173+
ERC4626 _subVault = subVault;
174+
if (address(_subVault) == address(0)) {
175+
return super._convertToShares(assets, rounding);
176+
}
177+
uint256 subShares = rounding == Math.Rounding.Up ? _subVault.previewWithdraw(assets) : _subVault.previewDeposit(assets);
178+
return subSharesToMasterShares(subShares, rounding);
179+
}
180+
181+
/**
182+
* @dev Internal conversion function (from shares to assets) with support for rounding direction.
183+
*/
184+
function _convertToAssets(uint256 shares, Math.Rounding rounding) internal view virtual override returns (uint256 assets) {
185+
ERC4626 _subVault = subVault;
186+
if (address(_subVault) == address(0)) {
187+
return super._convertToAssets(shares, rounding);
188+
}
189+
uint256 subShares = masterSharesToSubShares(shares, rounding);
190+
return rounding == Math.Rounding.Up ? _subVault.previewMint(subShares) : _subVault.previewRedeem(subShares);
191+
}
192+
193+
function totalProfit() public view returns (uint256) {
194+
uint256 _totalAssets = totalAssets();
195+
return _totalAssets > totalPrincipal ? _totalAssets - totalPrincipal : 0;
196+
}
197+
198+
/**
199+
* @dev Deposit/mint common workflow.
200+
*/
201+
function _deposit(
202+
address caller,
203+
address receiver,
204+
uint256 assets,
205+
uint256 shares
206+
) internal virtual override {
207+
super._deposit(caller, receiver, assets, shares);
208+
totalPrincipal += assets;
209+
ERC4626 _subVault = subVault;
210+
if (address(_subVault) != address(0)) {
211+
_subVault.deposit(assets, address(this));
212+
}
213+
}
214+
215+
/**
216+
* @dev Withdraw/redeem common workflow.
217+
*/
218+
function _withdraw(
219+
address caller,
220+
address receiver,
221+
address _owner,
222+
uint256 assets,
223+
uint256 shares
224+
) internal virtual override {
225+
ERC4626 _subVault = subVault;
226+
if (address(_subVault) != address(0)) {
227+
_subVault.withdraw(assets, address(this), address(this));
228+
}
229+
230+
////// PERF FEE STUFF //////
231+
// determine profit portion and principal portion of assets
232+
uint256 _totalProfit = totalProfit();
233+
// use shares because they are rounded up vs assets which are rounded down
234+
uint256 profitPortion = shares.mulDiv(_totalProfit, totalSupply(), Math.Rounding.Up);
235+
uint256 principalPortion = assets - profitPortion;
236+
237+
// subtract principal portion from totalPrincipal
238+
totalPrincipal -= principalPortion;
239+
240+
// send fee to owner (todo should be a separate beneficiary addr set by owner)
241+
if (performanceFeeBps > 0 && profitPortion > 0) {
242+
uint256 fee = profitPortion.mulDiv(performanceFeeBps, 10000, Math.Rounding.Up);
243+
// send fee to owner
244+
IERC20(asset()).safeTransfer(owner(), fee);
245+
246+
// note subtraction
247+
assets -= fee;
248+
}
249+
250+
////// END PERF FEE STUFF //////
251+
252+
// call super._withdraw with remaining assets
253+
super._withdraw(caller, receiver, _owner, assets, shares);
254+
}
255+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
pragma solidity ^0.8.0;
4+
5+
import "@openzeppelin/contracts/utils/Create2.sol";
6+
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
7+
import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
8+
import "./IMasterVault.sol";
9+
import "./IMasterVaultFactory.sol";
10+
import "./MasterVault.sol";
11+
12+
contract MasterVaultFactory is IMasterVaultFactory, OwnableUpgradeable {
13+
14+
error ZeroAddress();
15+
16+
function initialize(address _owner) public initializer {
17+
_transferOwnership(_owner);
18+
}
19+
20+
function deployVault(address token) public returns (address vault) {
21+
if (token == address(0)) {
22+
revert ZeroAddress();
23+
}
24+
25+
IERC20Metadata tokenMetadata = IERC20Metadata(token);
26+
string memory name = string(abi.encodePacked("Master ", tokenMetadata.name()));
27+
string memory symbol = string(abi.encodePacked("m", tokenMetadata.symbol()));
28+
29+
bytes memory bytecode = abi.encodePacked(
30+
type(MasterVault).creationCode,
31+
abi.encode(token, name, symbol)
32+
);
33+
34+
vault = Create2.deploy(0, bytes32(0), bytecode);
35+
36+
emit VaultDeployed(token, vault);
37+
}
38+
39+
function calculateVaultAddress(address token) public view returns (address) {
40+
IERC20Metadata tokenMetadata = IERC20Metadata(token);
41+
string memory name = string(abi.encodePacked("Master ", tokenMetadata.name()));
42+
string memory symbol = string(abi.encodePacked("m", tokenMetadata.symbol()));
43+
44+
bytes32 bytecodeHash = keccak256(
45+
abi.encodePacked(
46+
type(MasterVault).creationCode,
47+
abi.encode(token, name, symbol)
48+
)
49+
);
50+
return Create2.computeAddress(bytes32(0), bytecodeHash);
51+
}
52+
53+
function getVault(address token) external returns (address) {
54+
address vault = calculateVaultAddress(token);
55+
if (vault.code.length == 0) {
56+
return deployVault(token);
57+
}
58+
return vault;
59+
}
60+
61+
// todo: consider a method to enable bridge owner to transfer specific master vault ownership to new address
62+
function setSubVault(
63+
address masterVault,
64+
address subVault
65+
) external onlyOwner {
66+
IMasterVault(masterVault).setSubVault(subVault);
67+
emit SubVaultSet(masterVault, subVault);
68+
}
69+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
pragma solidity ^0.8.0;
3+
4+
import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
5+
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
6+
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
7+
8+
contract MockSubVault is ERC4626 {
9+
constructor(
10+
IERC20 _asset,
11+
string memory _name,
12+
string memory _symbol
13+
) ERC20(_name, _symbol) ERC4626(_asset) {}
14+
15+
function totalAssets() public view override returns (uint256) {
16+
return IERC20(asset()).balanceOf(address(this));
17+
}
18+
}

0 commit comments

Comments
 (0)