From 951c33c22ae82797b519f5e4d2b7ddff75e8d402 Mon Sep 17 00:00:00 2001 From: Wael Almattar Date: Mon, 29 Sep 2025 14:43:35 +0200 Subject: [PATCH 1/2] feat: add master vault --- .../libraries/vault/IMasterVault.sol | 6 + .../libraries/vault/IMasterVaultFactory.sol | 13 + .../libraries/vault/MasterVault.sol | 255 ++++++++++++++++++ .../libraries/vault/MasterVaultFactory.sol | 69 +++++ contracts/tokenbridge/test/MockSubVault.sol | 18 ++ .../libraries/vault/MasterVault.t.sol | 187 +++++++++++++ .../libraries/vault/MasterVaultFactory.t.sol | 72 +++++ 7 files changed, 620 insertions(+) create mode 100644 contracts/tokenbridge/libraries/vault/IMasterVault.sol create mode 100644 contracts/tokenbridge/libraries/vault/IMasterVaultFactory.sol create mode 100644 contracts/tokenbridge/libraries/vault/MasterVault.sol create mode 100644 contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol create mode 100644 contracts/tokenbridge/test/MockSubVault.sol create mode 100644 test-foundry/libraries/vault/MasterVault.t.sol create mode 100644 test-foundry/libraries/vault/MasterVaultFactory.t.sol diff --git a/contracts/tokenbridge/libraries/vault/IMasterVault.sol b/contracts/tokenbridge/libraries/vault/IMasterVault.sol new file mode 100644 index 000000000..6ea8c6255 --- /dev/null +++ b/contracts/tokenbridge/libraries/vault/IMasterVault.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +interface IMasterVault { + function setSubVault(address subVault) external; +} \ No newline at end of file diff --git a/contracts/tokenbridge/libraries/vault/IMasterVaultFactory.sol b/contracts/tokenbridge/libraries/vault/IMasterVaultFactory.sol new file mode 100644 index 000000000..513a80007 --- /dev/null +++ b/contracts/tokenbridge/libraries/vault/IMasterVaultFactory.sol @@ -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; +} \ No newline at end of file diff --git a/contracts/tokenbridge/libraries/vault/MasterVault.sol b/contracts/tokenbridge/libraries/vault/MasterVault.sol new file mode 100644 index 000000000..ea3614677 --- /dev/null +++ b/contracts/tokenbridge/libraries/vault/MasterVault.sol @@ -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)); + } + + 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)); + 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)); + } + } + + /** + * @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); + } +} \ No newline at end of file diff --git a/contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol b/contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol new file mode 100644 index 000000000..4ee008b05 --- /dev/null +++ b/contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol @@ -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); + } +} \ No newline at end of file diff --git a/contracts/tokenbridge/test/MockSubVault.sol b/contracts/tokenbridge/test/MockSubVault.sol new file mode 100644 index 000000000..411edb61b --- /dev/null +++ b/contracts/tokenbridge/test/MockSubVault.sol @@ -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)); + } +} \ No newline at end of file diff --git a/test-foundry/libraries/vault/MasterVault.t.sol b/test-foundry/libraries/vault/MasterVault.t.sol new file mode 100644 index 000000000..037cdbf3b --- /dev/null +++ b/test-foundry/libraries/vault/MasterVault.t.sol @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import { Test } from "forge-std/Test.sol"; +import { MasterVault } from "../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; +import { TestERC20 } from "../../../contracts/tokenbridge/test/TestERC20.sol"; +import { MockSubVault } from "../../../contracts/tokenbridge/test/MockSubVault.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +contract MasterVaultTest is Test { + MasterVault public vault; + TestERC20 public token; + + event SubvaultChanged(address indexed oldSubvault, address indexed newSubvault); + + address public user = address(0x1); + string public name = "Master Test Token"; + string public symbol = "mTST"; + + function setUp() public { + token = new TestERC20(); + vault = new MasterVault(IERC20(address(token)), name, symbol); + } + + function test_initialize() public { + assertEq(address(vault.asset()), address(token), "Invalid asset"); + assertEq(vault.name(), name, "Invalid name"); + assertEq(vault.symbol(), symbol, "Invalid symbol"); + assertEq(vault.decimals(), token.decimals(), "Invalid decimals"); + assertEq(vault.totalSupply(), 0, "Invalid initial supply"); + assertEq(vault.totalAssets(), 0, "Invalid initial assets"); + assertEq(address(vault.subVault()), address(0), "SubVault should be zero initially"); + } + + function test_WithoutSubvault_deposit() public { + assertEq(address(vault.subVault()), address(0), "SubVault should be zero initially"); + + // user deposit 500 tokens to vault + // by this test expec: + //- user to receive 500 shares + //- total shares supply to increase by 500 + //- total assets to increase by 500 + + uint256 minShares = 0; + + vm.startPrank(user); + token.mint(); + uint256 depositAmount = token.balanceOf(user); + + token.approve(address(vault), depositAmount); + + uint256 sharesBefore = vault.balanceOf(user); + uint256 totalSupplyBefore = vault.totalSupply(); + uint256 totalAssetsBefore = vault.totalAssets(); + + uint256 shares = vault.deposit(depositAmount, user, minShares); + + assertEq(vault.balanceOf(user), sharesBefore + shares, "Invalid user balance"); + assertEq(vault.totalSupply(), totalSupplyBefore + shares, "Invalid total supply"); + assertEq(vault.totalAssets(), totalAssetsBefore + depositAmount, "Invalid total assets"); + assertEq(token.balanceOf(user), 0, "User tokens should be transferred"); + + vm.stopPrank(); + } + + function test_deposit_RevertTooFewSharesReceived() public { + vm.startPrank(user); + token.mint(); + uint256 depositAmount = token.balanceOf(user); + uint256 minShares = depositAmount * 2; // Unrealistic requirement + + token.approve(address(vault), depositAmount); + + vm.expectRevert(MasterVault.TooFewSharesReceived.selector); + vault.deposit(depositAmount, user, minShares); + + vm.stopPrank(); + } + + function test_setSubvault() public { + MockSubVault subVault = new MockSubVault( + IERC20(address(token)), + "Sub Vault Token", + "svTST" + ); + + vm.startPrank(user); + token.mint(); + uint256 depositAmount = token.balanceOf(user); + token.approve(address(vault), depositAmount); + vault.deposit(depositAmount, user, 0); + vm.stopPrank(); + + assertEq(address(vault.subVault()), address(0), "SubVault should be zero initially"); + assertEq(vault.totalAssets(), depositAmount, "Total assets should equal deposit"); + + uint256 minSubVaultExchRateWad = 1e18; + + vm.expectEmit(true, true, false, false); + emit SubvaultChanged(address(0), address(subVault)); + + vault.setSubVault(subVault, minSubVaultExchRateWad); + + assertEq(address(vault.subVault()), address(subVault), "SubVault should be set"); + assertEq(vault.subVaultExchRateWad(), 1e18, "Exchange rate should be 1:1 initially"); + assertEq(vault.totalAssets(), depositAmount, "Total assets should remain the same"); + assertEq(subVault.balanceOf(address(vault)), depositAmount, "SubVault should have received assets"); + } + + function test_switchSubvault() public { + MockSubVault oldSubVault = new MockSubVault( + IERC20(address(token)), + "Old Sub Vault", + "osvTST" + ); + + MockSubVault newSubVault = new MockSubVault( + IERC20(address(token)), + "New Sub Vault", + "nsvTST" + ); + + vm.startPrank(user); + token.mint(); + uint256 depositAmount = token.balanceOf(user); + token.approve(address(vault), depositAmount); + vault.deposit(depositAmount, user, 0); + vm.stopPrank(); + + vault.setSubVault(oldSubVault, 1e18); + + assertEq(address(vault.subVault()), address(oldSubVault), "Old subvault should be set"); + assertEq(oldSubVault.balanceOf(address(vault)), depositAmount, "Old subvault should have assets"); + assertEq(newSubVault.balanceOf(address(vault)), 0, "New subvault should have no assets initially"); + + uint256 minAssetExchRateWad = 1e18; + uint256 minNewSubVaultExchRateWad = 1e18; + + vm.expectEmit(true, true, false, false); + emit SubvaultChanged(address(oldSubVault), address(0)); + vm.expectEmit(true, true, false, false); + emit SubvaultChanged(address(0), address(newSubVault)); + + vault.switchSubVault(newSubVault, minAssetExchRateWad, minNewSubVaultExchRateWad); + + assertEq(address(vault.subVault()), address(newSubVault), "New subvault should be set"); + assertEq(vault.subVaultExchRateWad(), 1e18, "Exchange rate should remain 1:1"); + assertEq(vault.totalAssets(), depositAmount, "Total assets should remain the same"); + assertEq(oldSubVault.balanceOf(address(vault)), 0, "Old subvault should have no assets"); + assertEq(newSubVault.balanceOf(address(vault)), depositAmount, "New subvault should have received assets"); + } + + function test_revokeSubvault() public { + MockSubVault subVault = new MockSubVault( + IERC20(address(token)), + "Sub Vault Token", + "svTST" + ); + + vm.startPrank(user); + token.mint(); + uint256 depositAmount = token.balanceOf(user); + token.approve(address(vault), depositAmount); + vault.deposit(depositAmount, user, 0); + vm.stopPrank(); + + vault.setSubVault(subVault, 1e18); + + assertEq(address(vault.subVault()), address(subVault), "SubVault should be set"); + assertEq(subVault.balanceOf(address(vault)), depositAmount, "SubVault should have assets"); + assertEq(vault.subVaultExchRateWad(), 1e18, "Exchange rate should be 1:1"); + + uint256 minAssetExchRateWad = 1e18; + + vm.expectEmit(true, true, false, false); + emit SubvaultChanged(address(subVault), address(0)); + + vault.revokeSubVault(minAssetExchRateWad); + + assertEq(address(vault.subVault()), address(0), "SubVault should be revoked"); + assertEq(vault.subVaultExchRateWad(), 1e18, "Exchange rate should reset to 1:1"); + assertEq(vault.totalAssets(), depositAmount, "Total assets should remain the same"); + assertEq(subVault.balanceOf(address(vault)), 0, "SubVault should have no assets"); + assertEq(token.balanceOf(address(vault)), depositAmount, "MasterVault should have assets directly"); + } + +} diff --git a/test-foundry/libraries/vault/MasterVaultFactory.t.sol b/test-foundry/libraries/vault/MasterVaultFactory.t.sol new file mode 100644 index 000000000..69d31106a --- /dev/null +++ b/test-foundry/libraries/vault/MasterVaultFactory.t.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {Test} from "forge-std/Test.sol"; +import {MasterVaultFactory} from "../../../contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol"; +import {MasterVault} from "../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; +import {TestERC20} from "../../../contracts/tokenbridge/test/TestERC20.sol"; + +contract MasterVaultFactoryTest is Test { + MasterVaultFactory public factory; + TestERC20 public token; + + address public owner = address(0x1); + address public user = address(0x2); + + event VaultDeployed(address indexed token, address indexed vault); + + function setUp() public { + token = new TestERC20(); + factory = new MasterVaultFactory(); + + vm.prank(owner); + factory.initialize(owner); + } + + function test_initialize() public { + assertEq(factory.owner(), owner, "Invalid owner"); + } + + function test_deployVault() public { + address expectedVault = factory.calculateVaultAddress(address(token)); + + vm.expectEmit(true, true, false, false); + emit VaultDeployed(address(token), expectedVault); + + address deployedVault = factory.deployVault(address(token)); + + assertEq(deployedVault, expectedVault, "Vault address mismatch"); + assertTrue(deployedVault.code.length > 0, "Vault not deployed"); + + MasterVault vault = MasterVault(deployedVault); + assertEq(address(vault.asset()), address(token), "Invalid vault asset"); + assertEq(vault.owner(), address(factory), "Invalid vault owner"); + } + + function test_deployVault_RevertZeroAddress() public { + vm.expectRevert(MasterVaultFactory.ZeroAddress.selector); + factory.deployVault(address(0)); + } + + function test_getVault_DeploysIfNotExists() public { + address expectedVault = factory.calculateVaultAddress(address(token)); + address vault = factory.getVault(address(token)); + + assertEq(vault, expectedVault, "Vault address mismatch"); + assertTrue(vault.code.length > 0, "Vault not deployed"); + } + + function test_getVault_ReturnsExistingVault() public { + address vault1 = factory.getVault(address(token)); + address vault2 = factory.getVault(address(token)); + + assertEq(vault1, vault2, "Should return same vault"); + } + + function test_calculateVaultAddress() public { + address calculatedAddress = factory.calculateVaultAddress(address(token)); + address deployedVault = factory.deployVault(address(token)); + + assertEq(calculatedAddress, deployedVault, "Address calculation incorrect"); + } +} \ No newline at end of file From fc89964a509b2f5e48be15dd8687360010d97d29 Mon Sep 17 00:00:00 2001 From: Wael Almattar Date: Mon, 29 Sep 2025 19:47:30 +0200 Subject: [PATCH 2/2] permission withdraw performence fees --- .../libraries/vault/MasterVault.sol | 63 +++++++++------ .../libraries/vault/MasterVault.t.sol | 78 +++++++++++++++++++ 2 files changed, 117 insertions(+), 24 deletions(-) diff --git a/contracts/tokenbridge/libraries/vault/MasterVault.sol b/contracts/tokenbridge/libraries/vault/MasterVault.sol index ea3614677..1dbeaa063 100644 --- a/contracts/tokenbridge/libraries/vault/MasterVault.sol +++ b/contracts/tokenbridge/libraries/vault/MasterVault.sol @@ -24,6 +24,8 @@ contract MasterVault is ERC4626, Ownable { error NoExistingSubVault(); error MustHaveSupplyBeforeSwitchingSubVault(); error NewSubVaultExchangeRateTooLow(); + error BeneficiaryNotSet(); + error PerformanceFeeDisabled(); // 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) @@ -39,10 +41,13 @@ contract MasterVault is ERC4626, Ownable { // 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 + bool public enablePerformanceFee; + address public beneficiary; uint256 totalPrincipal; // total assets deposited, used to calculate profit event SubvaultChanged(address indexed oldSubvault, address indexed newSubvault); + event PerformanceFeeToggled(bool enabled); + event BeneficiaryUpdated(address indexed oldBeneficiary, address indexed newBeneficiary); constructor(IERC20 _asset, string memory _name, string memory _symbol) ERC20(_name, _symbol) ERC4626(_asset) Ownable() {} @@ -137,6 +142,37 @@ contract MasterVault is ERC4626, Ownable { return subShares.mulDiv(1e18, subVaultExchRateWad, rounding); } + /// @notice Toggle performance fee collection on/off + /// @param enabled True to enable performance fees, false to disable + function setPerformanceFee(bool enabled) external onlyOwner { + enablePerformanceFee = enabled; + emit PerformanceFeeToggled(enabled); + } + + /// @notice Set the beneficiary address for performance fees + /// @param newBeneficiary Address to receive performance fees, zero address defaults to owner + function setBeneficiary(address newBeneficiary) external onlyOwner { + address oldBeneficiary = beneficiary; + beneficiary = newBeneficiary; + emit BeneficiaryUpdated(oldBeneficiary, newBeneficiary); + } + + /// @notice Withdraw all accumulated performance fees to beneficiary + /// @dev Only callable by owner when performance fees are enabled + function withdrawPerformanceFees() external onlyOwner { + if (!enablePerformanceFee) revert PerformanceFeeDisabled(); + if (beneficiary == address(0)) revert BeneficiaryNotSet(); + + uint256 totalProfits = totalProfit(); + if (totalProfits > 0) { + ERC4626 _subVault = subVault; + if (address(_subVault) != address(0)) { + _subVault.withdraw(totalProfits, address(this), address(this)); + } + IERC20(asset()).safeTransfer(beneficiary, totalProfits); + } + } + /** @dev See {IERC4626-totalAssets}. */ function totalAssets() public view virtual override returns (uint256) { ERC4626 _subVault = subVault; @@ -222,34 +258,13 @@ contract MasterVault is ERC4626, Ownable { uint256 assets, uint256 shares ) internal virtual override { + totalPrincipal -= assets; + 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); } } \ No newline at end of file diff --git a/test-foundry/libraries/vault/MasterVault.t.sol b/test-foundry/libraries/vault/MasterVault.t.sol index 037cdbf3b..b5070e92b 100644 --- a/test-foundry/libraries/vault/MasterVault.t.sol +++ b/test-foundry/libraries/vault/MasterVault.t.sol @@ -184,4 +184,82 @@ contract MasterVaultTest is Test { assertEq(token.balanceOf(address(vault)), depositAmount, "MasterVault should have assets directly"); } + function test_WithoutSubvault_withdraw() public { + uint256 maxSharesBurned = type(uint256).max; + + vm.startPrank(user); + token.mint(); + uint256 depositAmount = token.balanceOf(user); + token.approve(address(vault), depositAmount); + vault.deposit(depositAmount, user, 0); + + uint256 withdrawAmount = depositAmount / 2; + uint256 userSharesBefore = vault.balanceOf(user); + uint256 totalSupplyBefore = vault.totalSupply(); + uint256 totalAssetsBefore = vault.totalAssets(); + + uint256 shares = vault.withdraw(withdrawAmount, user, user, maxSharesBurned); + + assertEq(vault.balanceOf(user), userSharesBefore - shares, "User shares should decrease"); + assertEq(vault.totalSupply(), totalSupplyBefore - shares, "Total supply should decrease"); + assertEq(vault.totalAssets(), totalAssetsBefore - withdrawAmount, "Total assets should decrease"); + assertEq(token.balanceOf(user), withdrawAmount, "User should receive withdrawn assets"); + assertEq(token.balanceOf(address(vault)), depositAmount - withdrawAmount, "Vault should have remaining assets"); + + vm.stopPrank(); + } + + function test_WithSubvault_withdraw() public { + MockSubVault subVault = new MockSubVault( + IERC20(address(token)), + "Sub Vault Token", + "svTST" + ); + + vm.startPrank(user); + token.mint(); + uint256 firstDepositAmount = token.balanceOf(user); + token.approve(address(vault), firstDepositAmount); + vault.deposit(firstDepositAmount, user, 0); + vm.stopPrank(); + + vault.setSubVault(subVault, 1e18); + + uint256 withdrawAmount = firstDepositAmount / 2; + uint256 maxSharesBurned = type(uint256).max; + + vm.startPrank(user); + uint256 userSharesBefore = vault.balanceOf(user); + uint256 totalSupplyBefore = vault.totalSupply(); + uint256 totalAssetsBefore = vault.totalAssets(); + uint256 subVaultSharesBefore = subVault.balanceOf(address(vault)); + + uint256 shares = vault.withdraw(withdrawAmount, user, user, maxSharesBurned); + + assertEq(vault.balanceOf(user), userSharesBefore - shares, "User shares should decrease"); + assertEq(vault.totalSupply(), totalSupplyBefore - shares, "Total supply should decrease"); + assertEq(vault.totalAssets(), totalAssetsBefore - withdrawAmount, "Total assets should decrease"); + assertEq(token.balanceOf(user), withdrawAmount, "User should receive withdrawn assets"); + assertLt(subVault.balanceOf(address(vault)), subVaultSharesBefore, "SubVault shares should decrease"); + + token.mint(); + uint256 secondDepositAmount = token.balanceOf(user) - withdrawAmount; + token.approve(address(vault), secondDepositAmount); + vault.deposit(secondDepositAmount, user, 0); + + vault.balanceOf(user); + uint256 finalTotalAssets = vault.totalAssets(); + subVault.balanceOf(address(vault)); + + vault.withdraw(finalTotalAssets, user, user, type(uint256).max); + + assertEq(vault.balanceOf(user), 0, "User should have no shares left"); + assertEq(vault.totalSupply(), 0, "Total supply should be zero"); + assertEq(vault.totalAssets(), 0, "Total assets should be zero"); + assertEq(token.balanceOf(user), firstDepositAmount + secondDepositAmount, "User should have all original tokens"); + assertEq(subVault.balanceOf(address(vault)), 0, "SubVault should have no shares left"); + + vm.stopPrank(); + } + }