From 951c33c22ae82797b519f5e4d2b7ddff75e8d402 Mon Sep 17 00:00:00 2001 From: Wael Almattar Date: Mon, 29 Sep 2025 14:43:35 +0200 Subject: [PATCH 01/30] 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 02/30] 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(); + } + } From 7922536aa59c93deb736601e8ad9a49c2999d6a9 Mon Sep 17 00:00:00 2001 From: Wael Almattar Date: Wed, 1 Oct 2025 15:42:06 +0200 Subject: [PATCH 03/30] feat: make master vault beacon upgradable --- .../libraries/vault/IMasterVault.sol | 2 +- .../libraries/vault/IMasterVaultFactory.sol | 2 +- .../libraries/vault/MasterVault.sol | 78 +++++++++++-------- .../libraries/vault/MasterVaultFactory.sol | 50 +++++++----- .../libraries/vault/MasterVault.t.sol | 17 +++- 5 files changed, 94 insertions(+), 55 deletions(-) diff --git a/contracts/tokenbridge/libraries/vault/IMasterVault.sol b/contracts/tokenbridge/libraries/vault/IMasterVault.sol index 6ea8c6255..91cfb6c50 100644 --- a/contracts/tokenbridge/libraries/vault/IMasterVault.sol +++ b/contracts/tokenbridge/libraries/vault/IMasterVault.sol @@ -2,5 +2,5 @@ pragma solidity ^0.8.0; interface IMasterVault { - function setSubVault(address subVault) external; + function setSubVault(address subVault, uint256 minSubVaultExchRateWad) external; } \ No newline at end of file diff --git a/contracts/tokenbridge/libraries/vault/IMasterVaultFactory.sol b/contracts/tokenbridge/libraries/vault/IMasterVaultFactory.sol index 513a80007..7ac654fca 100644 --- a/contracts/tokenbridge/libraries/vault/IMasterVaultFactory.sol +++ b/contracts/tokenbridge/libraries/vault/IMasterVaultFactory.sol @@ -9,5 +9,5 @@ interface IMasterVaultFactory { 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; + function setSubVault(address masterVault, address subVault, uint256 minSubVaultExchRateWad) external; } \ No newline at end of file diff --git a/contracts/tokenbridge/libraries/vault/MasterVault.sol b/contracts/tokenbridge/libraries/vault/MasterVault.sol index 1dbeaa063..b55978d21 100644 --- a/contracts/tokenbridge/libraries/vault/MasterVault.sol +++ b/contracts/tokenbridge/libraries/vault/MasterVault.sol @@ -1,16 +1,20 @@ // 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 {ERC4626Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.sol"; +import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; -import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {MathUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/math/MathUpgradeable.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -contract MasterVault is ERC4626, Ownable { +contract MasterVault is Initializable, ERC4626Upgradeable, OwnableUpgradeable { using SafeERC20 for IERC20; - using Math for uint256; + using MathUpgradeable for uint256; error TooFewSharesReceived(); error TooManySharesBurned(); @@ -29,13 +33,13 @@ contract MasterVault is ERC4626, Ownable { // 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; + IERC4626 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; + uint256 public subVaultExchRateWad; // 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 @@ -49,16 +53,27 @@ contract MasterVault is ERC4626, Ownable { 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() {} + function vaultInit(IERC20 _asset, string memory _name, string memory _symbol, address _owner) external initializer { + require(address(_asset) != address(0), "INVALID_ASSET"); + require(_owner != address(0), "INVALID_OWNER"); + + __ERC20_init(_name, _symbol); + __ERC4626_init(IERC20Upgradeable(address(_asset))); + __Ownable_init(); + _transferOwnership(_owner); + + subVaultExchRateWad = 1e18; + } + function deposit(uint256 assets, address receiver, uint256 minSharesMinted) public returns (uint256) { - uint256 shares = super.deposit(assets, receiver); + uint256 shares = 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); + uint256 shares = withdraw(assets, receiver, _owner); if (shares > maxSharesBurned) revert TooManySharesBurned(); return shares; } @@ -78,7 +93,7 @@ contract MasterVault is ERC4626, Ownable { /// @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 { + function setSubVault(IERC4626 _subVault, uint256 minSubVaultExchRateWad) external onlyOwner { if (address(subVault) != address(0)) revert SubVaultAlreadySet(); _setSubVault(_subVault, minSubVaultExchRateWad); } @@ -89,7 +104,7 @@ contract MasterVault is ERC4626, Ownable { _revokeSubVault(minAssetExchRateWad); } - function _setSubVault(ERC4626 _subVault, uint256 minSubVaultExchRateWad) internal { + function _setSubVault(IERC4626 _subVault, uint256 minSubVaultExchRateWad) internal { if (address(_subVault) == address(0)) revert SubVaultCannotBeZeroAddress(); if (totalSupply() == 0) revert MustHaveSupplyBeforeSettingSubVault(); if (address(_subVault.asset()) != address(asset())) revert SubVaultAssetMismatch(); @@ -97,7 +112,7 @@ contract MasterVault is ERC4626, Ownable { IERC20(asset()).safeApprove(address(_subVault), type(uint256).max); uint256 subShares = _subVault.deposit(totalAssets(), address(this)); - uint256 _subVaultExchRateWad = subShares.mulDiv(1e18, totalSupply(), Math.Rounding.Down); + uint256 _subVaultExchRateWad = subShares.mulDiv(1e18, totalSupply(), MathUpgradeable.Rounding.Down); if (_subVaultExchRateWad < minSubVaultExchRateWad) revert SubVaultExchangeRateTooLow(); subVaultExchRateWad = _subVaultExchRateWad; @@ -107,16 +122,16 @@ contract MasterVault is ERC4626, Ownable { } function _revokeSubVault(uint256 minAssetExchRateWad) internal { - ERC4626 oldSubVault = subVault; + IERC4626 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); + uint256 effectiveAssetExchRateWad = assetReceived.mulDiv(1e18, _totalSupply, MathUpgradeable.Rounding.Down); if (effectiveAssetExchRateWad < minAssetExchRateWad) revert TooFewAssetsReceived(); IERC20(asset()).safeApprove(address(oldSubVault), 0); - subVault = ERC4626(address(0)); + subVault = IERC4626(address(0)); subVaultExchRateWad = 1e18; emit SubvaultChanged(address(oldSubVault), address(0)); @@ -126,7 +141,7 @@ contract MasterVault is ERC4626, Ownable { /// @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 { + function switchSubVault(IERC4626 newSubVault, uint256 minAssetExchRateWad, uint256 minNewSubVaultExchRateWad) external onlyOwner { _revokeSubVault(minAssetExchRateWad); if (address(newSubVault) != address(0)) { @@ -134,11 +149,11 @@ contract MasterVault is ERC4626, Ownable { } } - function masterSharesToSubShares(uint256 masterShares, Math.Rounding rounding) public view returns (uint256) { + function masterSharesToSubShares(uint256 masterShares, MathUpgradeable.Rounding rounding) public view returns (uint256) { return masterShares.mulDiv(subVaultExchRateWad, 1e18, rounding); } - function subSharesToMasterShares(uint256 subShares, Math.Rounding rounding) public view returns (uint256) { + function subSharesToMasterShares(uint256 subShares, MathUpgradeable.Rounding rounding) public view returns (uint256) { return subShares.mulDiv(1e18, subVaultExchRateWad, rounding); } @@ -165,7 +180,7 @@ contract MasterVault is ERC4626, Ownable { uint256 totalProfits = totalProfit(); if (totalProfits > 0) { - ERC4626 _subVault = subVault; + IERC4626 _subVault = subVault; if (address(_subVault) != address(0)) { _subVault.withdraw(totalProfits, address(this), address(this)); } @@ -175,7 +190,7 @@ contract MasterVault is ERC4626, Ownable { /** @dev See {IERC4626-totalAssets}. */ function totalAssets() public view virtual override returns (uint256) { - ERC4626 _subVault = subVault; + IERC4626 _subVault = subVault; if (address(_subVault) == address(0)) { return super.totalAssets(); } @@ -196,7 +211,7 @@ contract MasterVault is ERC4626, Ownable { if (subShares == type(uint256).max) { return type(uint256).max; } - return subSharesToMasterShares(subShares, Math.Rounding.Down); + return subSharesToMasterShares(subShares, MathUpgradeable.Rounding.Down); } /** @@ -205,25 +220,25 @@ contract MasterVault is ERC4626, Ownable { * 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; + function _convertToShares(uint256 assets, MathUpgradeable.Rounding rounding) internal view virtual override returns (uint256 shares) { + IERC4626 _subVault = subVault; if (address(_subVault) == address(0)) { return super._convertToShares(assets, rounding); } - uint256 subShares = rounding == Math.Rounding.Up ? _subVault.previewWithdraw(assets) : _subVault.previewDeposit(assets); + uint256 subShares = rounding == MathUpgradeable.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; + function _convertToAssets(uint256 shares, MathUpgradeable.Rounding rounding) internal view virtual override returns (uint256 assets) { + IERC4626 _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); + return rounding == MathUpgradeable.Rounding.Up ? _subVault.previewMint(subShares) : _subVault.previewRedeem(subShares); } function totalProfit() public view returns (uint256) { @@ -241,8 +256,9 @@ contract MasterVault is ERC4626, Ownable { uint256 shares ) internal virtual override { super._deposit(caller, receiver, assets, shares); + totalPrincipal += assets; - ERC4626 _subVault = subVault; + IERC4626 _subVault = subVault; if (address(_subVault) != address(0)) { _subVault.deposit(assets, address(this)); } @@ -260,7 +276,7 @@ contract MasterVault is ERC4626, Ownable { ) internal virtual override { totalPrincipal -= assets; - ERC4626 _subVault = subVault; + IERC4626 _subVault = subVault; if (address(_subVault) != address(0)) { _subVault.withdraw(assets, address(this), address(this)); } diff --git a/contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol b/contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol index 4ee008b05..ee39b93e7 100644 --- a/contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol +++ b/contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol @@ -5,49 +5,56 @@ 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 "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; +import "../ClonableBeaconProxy.sol"; import "./IMasterVault.sol"; import "./IMasterVaultFactory.sol"; import "./MasterVault.sol"; contract MasterVaultFactory is IMasterVaultFactory, OwnableUpgradeable { - error ZeroAddress(); + error BeaconNotDeployed(); + + UpgradeableBeacon public beacon; + BeaconProxyFactory public beaconProxyFactory; function initialize(address _owner) public initializer { _transferOwnership(_owner); + + MasterVault masterVaultImplementation = new MasterVault(); + beacon = new UpgradeableBeacon(address(masterVaultImplementation)); + beaconProxyFactory = new BeaconProxyFactory(); + beaconProxyFactory.initialize(address(beacon)); + beacon.transferOwnership(address(this)); } function deployVault(address token) public returns (address vault) { if (token == address(0)) { revert ZeroAddress(); } + if (address(beaconProxyFactory) == address(0)) { + revert BeaconNotDeployed(); + } + + bytes32 userSalt = _getUserSalt(token); + vault = beaconProxyFactory.createProxy(userSalt); 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); + MasterVault(vault).vaultInit(IERC20(token), name, symbol, address(this)); 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())); + function _getUserSalt(address token) internal pure returns (bytes32) { + return keccak256(abi.encode(token)); + } - bytes32 bytecodeHash = keccak256( - abi.encodePacked( - type(MasterVault).creationCode, - abi.encode(token, name, symbol) - ) - ); - return Create2.computeAddress(bytes32(0), bytecodeHash); + function calculateVaultAddress(address token) public view returns (address) { + bytes32 userSalt = _getUserSalt(token); + return beaconProxyFactory.calculateExpectedAddress(address(this), userSalt); } function getVault(address token) external returns (address) { @@ -61,9 +68,10 @@ contract MasterVaultFactory is IMasterVaultFactory, OwnableUpgradeable { // todo: consider a method to enable bridge owner to transfer specific master vault ownership to new address function setSubVault( address masterVault, - address subVault + address subVault, + uint256 minSubVaultExchRateWad ) external onlyOwner { - IMasterVault(masterVault).setSubVault(subVault); + IMasterVault(masterVault).setSubVault(subVault, minSubVaultExchRateWad); emit SubVaultSet(masterVault, subVault); } -} \ 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 b5070e92b..9032e744d 100644 --- a/test-foundry/libraries/vault/MasterVault.t.sol +++ b/test-foundry/libraries/vault/MasterVault.t.sol @@ -6,10 +6,14 @@ import { MasterVault } from "../../../contracts/tokenbridge/libraries/vault/Mast 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"; +import { UpgradeableBeacon } from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; +import { BeaconProxyFactory, ClonableBeaconProxy } from "../../../contracts/tokenbridge/libraries/ClonableBeaconProxy.sol"; contract MasterVaultTest is Test { MasterVault public vault; TestERC20 public token; + UpgradeableBeacon public beacon; + BeaconProxyFactory public beaconProxyFactory; event SubvaultChanged(address indexed oldSubvault, address indexed newSubvault); @@ -19,7 +23,18 @@ contract MasterVaultTest is Test { function setUp() public { token = new TestERC20(); - vault = new MasterVault(IERC20(address(token)), name, symbol); + + MasterVault implementation = new MasterVault(); + beacon = new UpgradeableBeacon(address(implementation)); + + beaconProxyFactory = new BeaconProxyFactory(); + beaconProxyFactory.initialize(address(beacon)); + + bytes32 salt = keccak256("test"); + address proxyAddress = beaconProxyFactory.createProxy(salt); + vault = MasterVault(proxyAddress); + + vault.vaultInit(IERC20(address(token)), name, symbol, address(this)); } function test_initialize() public { From 6331e7a255a677cd1e2239fe0f60dfd2a4dadbeb Mon Sep 17 00:00:00 2001 From: Wael Almattar Date: Fri, 3 Oct 2025 11:43:33 +0200 Subject: [PATCH 04/30] fixup! feat: make master vault beacon upgradable --- .../libraries/vault/MasterVaultFactory.sol | 6 ++- .../libraries/vault/MasterVault.t.sol | 20 +++++++++ .../libraries/vault/MasterVaultFactory.t.sol | 42 +++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol b/contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol index ee39b93e7..bde4d51d6 100644 --- a/contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol +++ b/contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol @@ -25,14 +25,16 @@ contract MasterVaultFactory is IMasterVaultFactory, OwnableUpgradeable { beacon = new UpgradeableBeacon(address(masterVaultImplementation)); beaconProxyFactory = new BeaconProxyFactory(); beaconProxyFactory.initialize(address(beacon)); - beacon.transferOwnership(address(this)); + beacon.transferOwnership(_owner); } function deployVault(address token) public returns (address vault) { if (token == address(0)) { revert ZeroAddress(); } - if (address(beaconProxyFactory) == address(0)) { + if ( + address(beaconProxyFactory) == address(0) && beaconProxyFactory.beacon() == address(0) + ) { revert BeaconNotDeployed(); } diff --git a/test-foundry/libraries/vault/MasterVault.t.sol b/test-foundry/libraries/vault/MasterVault.t.sol index 9032e744d..71a7299cc 100644 --- a/test-foundry/libraries/vault/MasterVault.t.sol +++ b/test-foundry/libraries/vault/MasterVault.t.sol @@ -277,4 +277,24 @@ contract MasterVaultTest is Test { vm.stopPrank(); } + function test_beaconUpgrade() public { + vm.startPrank(user); + token.mint(); + uint256 depositAmount = token.balanceOf(user); + token.approve(address(vault), depositAmount); + vault.deposit(depositAmount, user, 0); + vm.stopPrank(); + + address oldImplementation = beacon.implementation(); + assertEq(oldImplementation, address(beacon.implementation()), "Should have initial implementation"); + + MasterVault newImplementation = new MasterVault(); + beacon.upgradeTo(address(newImplementation)); + + assertEq(beacon.implementation(), address(newImplementation), "Beacon should point to new implementation"); + assertTrue(beacon.implementation() != oldImplementation, "Implementation should have changed"); + + assertEq(vault.name(), name, "Name should remain after upgrade"); + } + } diff --git a/test-foundry/libraries/vault/MasterVaultFactory.t.sol b/test-foundry/libraries/vault/MasterVaultFactory.t.sol index 69d31106a..224e6de2f 100644 --- a/test-foundry/libraries/vault/MasterVaultFactory.t.sol +++ b/test-foundry/libraries/vault/MasterVaultFactory.t.sol @@ -5,6 +5,7 @@ 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"; +import {UpgradeableBeacon} from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; contract MasterVaultFactoryTest is Test { MasterVaultFactory public factory; @@ -69,4 +70,45 @@ contract MasterVaultFactoryTest is Test { assertEq(calculatedAddress, deployedVault, "Address calculation incorrect"); } + + function test_beaconOwnership() public { + assertEq(factory.beacon().owner(), owner, "Beacon owner should be the factory owner"); + } + + function test_ownerCanUpgradeBeacon() public { + MasterVault newImplementation = new MasterVault(); + + UpgradeableBeacon beacon = factory.beacon(); + vm.prank(owner); + beacon.upgradeTo(address(newImplementation)); + + assertEq(factory.beacon().implementation(), address(newImplementation), "Beacon implementation should be updated"); + } + + function test_nonOwnerCannotUpgradeBeacon() public { + MasterVault newImplementation = new MasterVault(); + + UpgradeableBeacon beacon = factory.beacon(); + vm.prank(user); + vm.expectRevert("Ownable: caller is not the owner"); + beacon.upgradeTo(address(newImplementation)); + } + + function test_beaconUpgradeAffectsAllVaults() public { + address vault1 = factory.deployVault(address(token)); + + TestERC20 token2 = new TestERC20(); + address vault2 = factory.deployVault(address(token2)); + + MasterVault newImplementation = new MasterVault(); + + UpgradeableBeacon beacon = factory.beacon(); + vm.prank(owner); + beacon.upgradeTo(address(newImplementation)); + + assertEq(factory.beacon().implementation(), address(newImplementation), "Beacon should point to new implementation"); + + MasterVault(vault1).owner(); + MasterVault(vault2).owner(); + } } \ No newline at end of file From 5632d3d67be2146fa95af97b15f4377b9f18d1d4 Mon Sep 17 00:00:00 2001 From: Wael Almattar Date: Fri, 3 Oct 2025 12:35:21 +0200 Subject: [PATCH 05/30] satisify slither --- .../libraries/vault/MasterVault.sol | 40 +++++++++++++------ 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/contracts/tokenbridge/libraries/vault/MasterVault.sol b/contracts/tokenbridge/libraries/vault/MasterVault.sol index b55978d21..8f3fd2840 100644 --- a/contracts/tokenbridge/libraries/vault/MasterVault.sol +++ b/contracts/tokenbridge/libraries/vault/MasterVault.sol @@ -30,6 +30,11 @@ contract MasterVault is Initializable, ERC4626Upgradeable, OwnableUpgradeable { error NewSubVaultExchangeRateTooLow(); error BeneficiaryNotSet(); error PerformanceFeeDisabled(); + error NoSharesRedeemed(); + error NoSubvaultShares(); + error NoSharesBurned(); + error InvalidAsset(); + error InvalidOwner(); // 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) @@ -54,8 +59,8 @@ contract MasterVault is Initializable, ERC4626Upgradeable, OwnableUpgradeable { event BeneficiaryUpdated(address indexed oldBeneficiary, address indexed newBeneficiary); function vaultInit(IERC20 _asset, string memory _name, string memory _symbol, address _owner) external initializer { - require(address(_asset) != address(0), "INVALID_ASSET"); - require(_owner != address(0), "INVALID_OWNER"); + if (address(_asset) == address(0)) revert InvalidAsset(); + if (_owner == address(0)) revert InvalidOwner(); __ERC20_init(_name, _symbol); __ERC4626_init(IERC20Upgradeable(address(_asset))); @@ -109,15 +114,18 @@ contract MasterVault is Initializable, ERC4626Upgradeable, OwnableUpgradeable { if (totalSupply() == 0) revert MustHaveSupplyBeforeSettingSubVault(); if (address(_subVault.asset()) != address(asset())) revert SubVaultAssetMismatch(); + uint256 _totalAssets = totalAssets(); + uint256 _totalSupply = totalSupply(); + + subVault = _subVault; + IERC20(asset()).safeApprove(address(_subVault), type(uint256).max); - uint256 subShares = _subVault.deposit(totalAssets(), address(this)); + uint256 subShares = _subVault.deposit(_totalAssets, address(this)); - uint256 _subVaultExchRateWad = subShares.mulDiv(1e18, totalSupply(), MathUpgradeable.Rounding.Down); + uint256 _subVaultExchRateWad = subShares.mulDiv(1e18, _totalSupply, MathUpgradeable.Rounding.Down); if (_subVaultExchRateWad < minSubVaultExchRateWad) revert SubVaultExchangeRateTooLow(); subVaultExchRateWad = _subVaultExchRateWad; - subVault = _subVault; - emit SubvaultChanged(address(0), address(_subVault)); } @@ -126,14 +134,17 @@ contract MasterVault is Initializable, ERC4626Upgradeable, OwnableUpgradeable { 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, MathUpgradeable.Rounding.Down); - if (effectiveAssetExchRateWad < minAssetExchRateWad) revert TooFewAssetsReceived(); + uint256 maxWithdrawAmount = oldSubVault.maxWithdraw(address(this)); - IERC20(asset()).safeApprove(address(oldSubVault), 0); subVault = IERC4626(address(0)); subVaultExchRateWad = 1e18; + uint256 assetReceived = oldSubVault.withdraw(maxWithdrawAmount, address(this), address(this)); + IERC20(asset()).safeApprove(address(oldSubVault), 0); + + uint256 effectiveAssetExchRateWad = assetReceived.mulDiv(1e18, _totalSupply, MathUpgradeable.Rounding.Down); + if (effectiveAssetExchRateWad < minAssetExchRateWad) revert TooFewAssetsReceived(); + emit SubvaultChanged(address(oldSubVault), address(0)); } @@ -182,7 +193,8 @@ contract MasterVault is Initializable, ERC4626Upgradeable, OwnableUpgradeable { if (totalProfits > 0) { IERC4626 _subVault = subVault; if (address(_subVault) != address(0)) { - _subVault.withdraw(totalProfits, address(this), address(this)); + uint256 sharesRedeemed = _subVault.withdraw(totalProfits, address(this), address(this)); + if (sharesRedeemed == 0) revert NoSharesRedeemed(); } IERC20(asset()).safeTransfer(beneficiary, totalProfits); } @@ -260,7 +272,8 @@ contract MasterVault is Initializable, ERC4626Upgradeable, OwnableUpgradeable { totalPrincipal += assets; IERC4626 _subVault = subVault; if (address(_subVault) != address(0)) { - _subVault.deposit(assets, address(this)); + uint256 subShares = _subVault.deposit(assets, address(this)); + if (subShares == 0) revert NoSubvaultShares(); } } @@ -278,7 +291,8 @@ contract MasterVault is Initializable, ERC4626Upgradeable, OwnableUpgradeable { IERC4626 _subVault = subVault; if (address(_subVault) != address(0)) { - _subVault.withdraw(assets, address(this), address(this)); + uint256 sharesBurned = _subVault.withdraw(assets, address(this), address(this)); + if (sharesBurned == 0) revert NoSharesBurned(); } super._withdraw(caller, receiver, _owner, assets, shares); From 2b8f151a43a14fe4a75d41ebe425951b99e0319f Mon Sep 17 00:00:00 2001 From: Wael Almattar Date: Thu, 16 Oct 2025 09:58:46 +0200 Subject: [PATCH 06/30] remove unnecessary checks --- contracts/tokenbridge/libraries/vault/MasterVault.sol | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/contracts/tokenbridge/libraries/vault/MasterVault.sol b/contracts/tokenbridge/libraries/vault/MasterVault.sol index 8f3fd2840..b641d40bf 100644 --- a/contracts/tokenbridge/libraries/vault/MasterVault.sol +++ b/contracts/tokenbridge/libraries/vault/MasterVault.sol @@ -193,8 +193,7 @@ contract MasterVault is Initializable, ERC4626Upgradeable, OwnableUpgradeable { if (totalProfits > 0) { IERC4626 _subVault = subVault; if (address(_subVault) != address(0)) { - uint256 sharesRedeemed = _subVault.withdraw(totalProfits, address(this), address(this)); - if (sharesRedeemed == 0) revert NoSharesRedeemed(); + _subVault.withdraw(totalProfits, address(this), address(this)); } IERC20(asset()).safeTransfer(beneficiary, totalProfits); } @@ -272,8 +271,7 @@ contract MasterVault is Initializable, ERC4626Upgradeable, OwnableUpgradeable { totalPrincipal += assets; IERC4626 _subVault = subVault; if (address(_subVault) != address(0)) { - uint256 subShares = _subVault.deposit(assets, address(this)); - if (subShares == 0) revert NoSubvaultShares(); + _subVault.deposit(assets, address(this)); } } @@ -291,8 +289,7 @@ contract MasterVault is Initializable, ERC4626Upgradeable, OwnableUpgradeable { IERC4626 _subVault = subVault; if (address(_subVault) != address(0)) { - uint256 sharesBurned = _subVault.withdraw(assets, address(this), address(this)); - if (sharesBurned == 0) revert NoSharesBurned(); + _subVault.withdraw(assets, address(this), address(this)); } super._withdraw(caller, receiver, _owner, assets, shares); From 53486041587812cde0f4cbadf78d4f472ed4ee51 Mon Sep 17 00:00:00 2001 From: Wael Almattar Date: Thu, 16 Oct 2025 09:59:56 +0200 Subject: [PATCH 07/30] fixup! feat: make master vault beacon upgradable --- contracts/tokenbridge/libraries/vault/MasterVault.sol | 1 - 1 file changed, 1 deletion(-) diff --git a/contracts/tokenbridge/libraries/vault/MasterVault.sol b/contracts/tokenbridge/libraries/vault/MasterVault.sol index b641d40bf..a37e0957b 100644 --- a/contracts/tokenbridge/libraries/vault/MasterVault.sol +++ b/contracts/tokenbridge/libraries/vault/MasterVault.sol @@ -64,7 +64,6 @@ contract MasterVault is Initializable, ERC4626Upgradeable, OwnableUpgradeable { __ERC20_init(_name, _symbol); __ERC4626_init(IERC20Upgradeable(address(_asset))); - __Ownable_init(); _transferOwnership(_owner); subVaultExchRateWad = 1e18; From 3a24b45ddf0bc96322ca2b4993fc819b4d6519a5 Mon Sep 17 00:00:00 2001 From: Wael Almattar Date: Thu, 16 Oct 2025 11:11:18 +0200 Subject: [PATCH 08/30] fix: set subvault in between depsoit and _subVaultExchRateWad calculation --- contracts/tokenbridge/libraries/vault/MasterVault.sol | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/contracts/tokenbridge/libraries/vault/MasterVault.sol b/contracts/tokenbridge/libraries/vault/MasterVault.sol index a37e0957b..6ad4ca00f 100644 --- a/contracts/tokenbridge/libraries/vault/MasterVault.sol +++ b/contracts/tokenbridge/libraries/vault/MasterVault.sol @@ -113,15 +113,12 @@ contract MasterVault is Initializable, ERC4626Upgradeable, OwnableUpgradeable { if (totalSupply() == 0) revert MustHaveSupplyBeforeSettingSubVault(); if (address(_subVault.asset()) != address(asset())) revert SubVaultAssetMismatch(); - uint256 _totalAssets = totalAssets(); - uint256 _totalSupply = totalSupply(); + IERC20(asset()).safeApprove(address(_subVault), type(uint256).max); + uint256 subShares = _subVault.deposit(totalAssets(), address(this)); subVault = _subVault; - IERC20(asset()).safeApprove(address(_subVault), type(uint256).max); - uint256 subShares = _subVault.deposit(_totalAssets, address(this)); - - uint256 _subVaultExchRateWad = subShares.mulDiv(1e18, _totalSupply, MathUpgradeable.Rounding.Down); + uint256 _subVaultExchRateWad = subShares.mulDiv(1e18, totalAssets(), MathUpgradeable.Rounding.Down); if (_subVaultExchRateWad < minSubVaultExchRateWad) revert SubVaultExchangeRateTooLow(); subVaultExchRateWad = _subVaultExchRateWad; From db0feb15c464aa349bfe89f5ed88530b6f8c648f Mon Sep 17 00:00:00 2001 From: Wael Almattar Date: Sun, 19 Oct 2025 19:43:21 +0200 Subject: [PATCH 09/30] fixup! feat: make master vault beacon upgradable --- contracts/tokenbridge/libraries/vault/MasterVault.sol | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/contracts/tokenbridge/libraries/vault/MasterVault.sol b/contracts/tokenbridge/libraries/vault/MasterVault.sol index 6ad4ca00f..f3d7d7359 100644 --- a/contracts/tokenbridge/libraries/vault/MasterVault.sol +++ b/contracts/tokenbridge/libraries/vault/MasterVault.sol @@ -130,17 +130,14 @@ contract MasterVault is Initializable, ERC4626Upgradeable, OwnableUpgradeable { if (address(oldSubVault) == address(0)) revert NoExistingSubVault(); uint256 _totalSupply = totalSupply(); - uint256 maxWithdrawAmount = oldSubVault.maxWithdraw(address(this)); + uint256 assetReceived = oldSubVault.withdraw(oldSubVault.maxWithdraw(address(this)), address(this), address(this)); + uint256 effectiveAssetExchRateWad = assetReceived.mulDiv(1e18, _totalSupply, MathUpgradeable.Rounding.Down); + if (effectiveAssetExchRateWad < minAssetExchRateWad) revert TooFewAssetsReceived(); + IERC20(asset()).safeApprove(address(oldSubVault), 0); subVault = IERC4626(address(0)); subVaultExchRateWad = 1e18; - uint256 assetReceived = oldSubVault.withdraw(maxWithdrawAmount, address(this), address(this)); - IERC20(asset()).safeApprove(address(oldSubVault), 0); - - uint256 effectiveAssetExchRateWad = assetReceived.mulDiv(1e18, _totalSupply, MathUpgradeable.Rounding.Down); - if (effectiveAssetExchRateWad < minAssetExchRateWad) revert TooFewAssetsReceived(); - emit SubvaultChanged(address(oldSubVault), address(0)); } From 1e0c2671a6cb28a27d4448958e6a00a45404c141 Mon Sep 17 00:00:00 2001 From: Wael Almattar Date: Sun, 19 Oct 2025 19:45:30 +0200 Subject: [PATCH 10/30] fixup! feat: make master vault beacon upgradable --- contracts/tokenbridge/libraries/vault/MasterVault.sol | 3 --- 1 file changed, 3 deletions(-) diff --git a/contracts/tokenbridge/libraries/vault/MasterVault.sol b/contracts/tokenbridge/libraries/vault/MasterVault.sol index f3d7d7359..a763a5182 100644 --- a/contracts/tokenbridge/libraries/vault/MasterVault.sol +++ b/contracts/tokenbridge/libraries/vault/MasterVault.sol @@ -30,9 +30,6 @@ contract MasterVault is Initializable, ERC4626Upgradeable, OwnableUpgradeable { error NewSubVaultExchangeRateTooLow(); error BeneficiaryNotSet(); error PerformanceFeeDisabled(); - error NoSharesRedeemed(); - error NoSubvaultShares(); - error NoSharesBurned(); error InvalidAsset(); error InvalidOwner(); From 4aad0ddffc2d7098633469bccf9f229372b48082 Mon Sep 17 00:00:00 2001 From: Wael Almattar Date: Sun, 19 Oct 2025 19:51:53 +0200 Subject: [PATCH 11/30] fix: remove not used beacon address from storage --- .../libraries/vault/MasterVaultFactory.sol | 3 +-- .../libraries/vault/MasterVaultFactory.t.sol | 12 ++++++------ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol b/contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol index bde4d51d6..180f3c1c2 100644 --- a/contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol +++ b/contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol @@ -15,14 +15,13 @@ contract MasterVaultFactory is IMasterVaultFactory, OwnableUpgradeable { error ZeroAddress(); error BeaconNotDeployed(); - UpgradeableBeacon public beacon; BeaconProxyFactory public beaconProxyFactory; function initialize(address _owner) public initializer { _transferOwnership(_owner); MasterVault masterVaultImplementation = new MasterVault(); - beacon = new UpgradeableBeacon(address(masterVaultImplementation)); + UpgradeableBeacon beacon = new UpgradeableBeacon(address(masterVaultImplementation)); beaconProxyFactory = new BeaconProxyFactory(); beaconProxyFactory.initialize(address(beacon)); beacon.transferOwnership(_owner); diff --git a/test-foundry/libraries/vault/MasterVaultFactory.t.sol b/test-foundry/libraries/vault/MasterVaultFactory.t.sol index 224e6de2f..268b593a8 100644 --- a/test-foundry/libraries/vault/MasterVaultFactory.t.sol +++ b/test-foundry/libraries/vault/MasterVaultFactory.t.sol @@ -72,23 +72,23 @@ contract MasterVaultFactoryTest is Test { } function test_beaconOwnership() public { - assertEq(factory.beacon().owner(), owner, "Beacon owner should be the factory owner"); + assertEq(UpgradeableBeacon(factory.beaconProxyFactory().beacon()).owner(), owner, "Beacon owner should be the factory owner"); } function test_ownerCanUpgradeBeacon() public { MasterVault newImplementation = new MasterVault(); - UpgradeableBeacon beacon = factory.beacon(); + UpgradeableBeacon beacon = UpgradeableBeacon(factory.beaconProxyFactory().beacon()); vm.prank(owner); beacon.upgradeTo(address(newImplementation)); - assertEq(factory.beacon().implementation(), address(newImplementation), "Beacon implementation should be updated"); + assertEq(UpgradeableBeacon(factory.beaconProxyFactory().beacon()).implementation(), address(newImplementation), "Beacon implementation should be updated"); } function test_nonOwnerCannotUpgradeBeacon() public { MasterVault newImplementation = new MasterVault(); - UpgradeableBeacon beacon = factory.beacon(); + UpgradeableBeacon beacon = UpgradeableBeacon(factory.beaconProxyFactory().beacon()); vm.prank(user); vm.expectRevert("Ownable: caller is not the owner"); beacon.upgradeTo(address(newImplementation)); @@ -102,11 +102,11 @@ contract MasterVaultFactoryTest is Test { MasterVault newImplementation = new MasterVault(); - UpgradeableBeacon beacon = factory.beacon(); + UpgradeableBeacon beacon = UpgradeableBeacon(factory.beaconProxyFactory().beacon()); vm.prank(owner); beacon.upgradeTo(address(newImplementation)); - assertEq(factory.beacon().implementation(), address(newImplementation), "Beacon should point to new implementation"); + assertEq(UpgradeableBeacon(factory.beaconProxyFactory().beacon()).implementation(), address(newImplementation), "Beacon should point to new implementation"); MasterVault(vault1).owner(); MasterVault(vault2).owner(); From 9ff2c39ad4335b44c37504438c9c594d3c808f11 Mon Sep 17 00:00:00 2001 From: Wael Almattar Date: Sun, 19 Oct 2025 19:53:16 +0200 Subject: [PATCH 12/30] fixup! feat: make master vault beacon upgradable --- contracts/tokenbridge/libraries/vault/MasterVault.sol | 2 +- contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol | 2 +- test-foundry/libraries/vault/MasterVault.t.sol | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/tokenbridge/libraries/vault/MasterVault.sol b/contracts/tokenbridge/libraries/vault/MasterVault.sol index a763a5182..0c1ab34c4 100644 --- a/contracts/tokenbridge/libraries/vault/MasterVault.sol +++ b/contracts/tokenbridge/libraries/vault/MasterVault.sol @@ -55,7 +55,7 @@ contract MasterVault is Initializable, ERC4626Upgradeable, OwnableUpgradeable { event PerformanceFeeToggled(bool enabled); event BeneficiaryUpdated(address indexed oldBeneficiary, address indexed newBeneficiary); - function vaultInit(IERC20 _asset, string memory _name, string memory _symbol, address _owner) external initializer { + function initialize(IERC20 _asset, string memory _name, string memory _symbol, address _owner) external initializer { if (address(_asset) == address(0)) revert InvalidAsset(); if (_owner == address(0)) revert InvalidOwner(); diff --git a/contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol b/contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol index 180f3c1c2..24a19fb81 100644 --- a/contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol +++ b/contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol @@ -44,7 +44,7 @@ contract MasterVaultFactory is IMasterVaultFactory, OwnableUpgradeable { string memory name = string(abi.encodePacked("Master ", tokenMetadata.name())); string memory symbol = string(abi.encodePacked("m", tokenMetadata.symbol())); - MasterVault(vault).vaultInit(IERC20(token), name, symbol, address(this)); + MasterVault(vault).initialize(IERC20(token), name, symbol, address(this)); emit VaultDeployed(token, vault); } diff --git a/test-foundry/libraries/vault/MasterVault.t.sol b/test-foundry/libraries/vault/MasterVault.t.sol index 71a7299cc..84a628e32 100644 --- a/test-foundry/libraries/vault/MasterVault.t.sol +++ b/test-foundry/libraries/vault/MasterVault.t.sol @@ -34,7 +34,7 @@ contract MasterVaultTest is Test { address proxyAddress = beaconProxyFactory.createProxy(salt); vault = MasterVault(proxyAddress); - vault.vaultInit(IERC20(address(token)), name, symbol, address(this)); + vault.initialize(IERC20(address(token)), name, symbol, address(this)); } function test_initialize() public { From cd1a25d9727da66491799b873ec6b3d7cfa385ae Mon Sep 17 00:00:00 2001 From: Wael Date: Fri, 7 Nov 2025 17:35:42 +0100 Subject: [PATCH 13/30] feat: access control roles (#132) * feat: access control roles * remove OwnableUpgradeable * add note about permissionless fee manager --------- Co-authored-by: Henry <11198460+godzillaba@users.noreply.github.com> --- .../libraries/vault/IMasterVaultFactory.sol | 2 - .../libraries/vault/MasterVault.sol | 50 +++- .../libraries/vault/MasterVaultFactory.sol | 19 +- .../libraries/vault/MasterVault.t.sol | 216 ++++++++++++++++++ .../libraries/vault/MasterVaultFactory.t.sol | 6 +- 5 files changed, 261 insertions(+), 32 deletions(-) diff --git a/contracts/tokenbridge/libraries/vault/IMasterVaultFactory.sol b/contracts/tokenbridge/libraries/vault/IMasterVaultFactory.sol index 7ac654fca..2a7617abc 100644 --- a/contracts/tokenbridge/libraries/vault/IMasterVaultFactory.sol +++ b/contracts/tokenbridge/libraries/vault/IMasterVaultFactory.sol @@ -3,11 +3,9 @@ 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, uint256 minSubVaultExchRateWad) external; } \ No newline at end of file diff --git a/contracts/tokenbridge/libraries/vault/MasterVault.sol b/contracts/tokenbridge/libraries/vault/MasterVault.sol index 0c1ab34c4..4f6c39b64 100644 --- a/contracts/tokenbridge/libraries/vault/MasterVault.sol +++ b/contracts/tokenbridge/libraries/vault/MasterVault.sol @@ -6,16 +6,21 @@ import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; -import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/security/PausableUpgradeable.sol"; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import {MathUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/math/MathUpgradeable.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -contract MasterVault is Initializable, ERC4626Upgradeable, OwnableUpgradeable { +contract MasterVault is Initializable, ERC4626Upgradeable, AccessControlUpgradeable, PausableUpgradeable { using SafeERC20 for IERC20; using MathUpgradeable for uint256; + bytes32 public constant VAULT_MANAGER_ROLE = keccak256("VAULT_MANAGER_ROLE"); + bytes32 public constant FEE_MANAGER_ROLE = keccak256("FEE_MANAGER_ROLE"); + bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); + error TooFewSharesReceived(); error TooManySharesBurned(); error TooManyAssetsDeposited(); @@ -61,7 +66,17 @@ contract MasterVault is Initializable, ERC4626Upgradeable, OwnableUpgradeable { __ERC20_init(_name, _symbol); __ERC4626_init(IERC20Upgradeable(address(_asset))); - _transferOwnership(_owner); + __AccessControl_init(); + __Pausable_init(); + + _setRoleAdmin(VAULT_MANAGER_ROLE, DEFAULT_ADMIN_ROLE); + _setRoleAdmin(FEE_MANAGER_ROLE, DEFAULT_ADMIN_ROLE); + _setRoleAdmin(PAUSER_ROLE, DEFAULT_ADMIN_ROLE); + + _grantRole(DEFAULT_ADMIN_ROLE, _owner); + _grantRole(VAULT_MANAGER_ROLE, _owner); + _grantRole(FEE_MANAGER_ROLE, _owner); // todo: consider permissionless by default + _grantRole(PAUSER_ROLE, _owner); subVaultExchRateWad = 1e18; } @@ -94,14 +109,14 @@ contract MasterVault is Initializable, ERC4626Upgradeable, OwnableUpgradeable { /// @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(IERC4626 _subVault, uint256 minSubVaultExchRateWad) external onlyOwner { + function setSubVault(IERC4626 _subVault, uint256 minSubVaultExchRateWad) external onlyRole(VAULT_MANAGER_ROLE) { 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 { + function revokeSubVault(uint256 minAssetExchRateWad) external onlyRole(VAULT_MANAGER_ROLE) { _revokeSubVault(minAssetExchRateWad); } @@ -142,7 +157,7 @@ contract MasterVault is Initializable, ERC4626Upgradeable, OwnableUpgradeable { /// @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(IERC4626 newSubVault, uint256 minAssetExchRateWad, uint256 minNewSubVaultExchRateWad) external onlyOwner { + function switchSubVault(IERC4626 newSubVault, uint256 minAssetExchRateWad, uint256 minNewSubVaultExchRateWad) external onlyRole(VAULT_MANAGER_ROLE) { _revokeSubVault(minAssetExchRateWad); if (address(newSubVault) != address(0)) { @@ -160,22 +175,22 @@ contract MasterVault is Initializable, ERC4626Upgradeable, OwnableUpgradeable { /// @notice Toggle performance fee collection on/off /// @param enabled True to enable performance fees, false to disable - function setPerformanceFee(bool enabled) external onlyOwner { + function setPerformanceFee(bool enabled) external onlyRole(VAULT_MANAGER_ROLE) { 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 { + function setBeneficiary(address newBeneficiary) external onlyRole(FEE_MANAGER_ROLE) { 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 { + /// @dev Only callable by fee manager when performance fees are enabled + function withdrawPerformanceFees() external onlyRole(FEE_MANAGER_ROLE) { if (!enablePerformanceFee) revert PerformanceFeeDisabled(); if (beneficiary == address(0)) revert BeneficiaryNotSet(); @@ -189,6 +204,14 @@ contract MasterVault is Initializable, ERC4626Upgradeable, OwnableUpgradeable { } } + function pause() external onlyRole(PAUSER_ROLE) { + _pause(); + } + + function unpause() external onlyRole(PAUSER_ROLE) { + _unpause(); + } + /** @dev See {IERC4626-totalAssets}. */ function totalAssets() public view virtual override returns (uint256) { IERC4626 _subVault = subVault; @@ -208,6 +231,9 @@ contract MasterVault is Initializable, ERC4626Upgradeable, OwnableUpgradeable { /** @dev See {IERC4626-maxMint}. */ function maxMint(address) public view virtual override returns (uint256) { + if (address(subVault) == address(0)) { + return type(uint256).max; + } uint256 subShares = subVault.maxMint(address(this)); if (subShares == type(uint256).max) { return type(uint256).max; @@ -255,7 +281,7 @@ contract MasterVault is Initializable, ERC4626Upgradeable, OwnableUpgradeable { address receiver, uint256 assets, uint256 shares - ) internal virtual override { + ) internal virtual override whenNotPaused { super._deposit(caller, receiver, assets, shares); totalPrincipal += assets; @@ -274,7 +300,7 @@ contract MasterVault is Initializable, ERC4626Upgradeable, OwnableUpgradeable { address _owner, uint256 assets, uint256 shares - ) internal virtual override { + ) internal virtual override whenNotPaused { totalPrincipal -= assets; IERC4626 _subVault = subVault; diff --git a/contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol b/contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol index 24a19fb81..0259c84d4 100644 --- a/contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol +++ b/contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol @@ -3,7 +3,6 @@ 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 "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; import "../ClonableBeaconProxy.sol"; @@ -11,15 +10,15 @@ import "./IMasterVault.sol"; import "./IMasterVaultFactory.sol"; import "./MasterVault.sol"; -contract MasterVaultFactory is IMasterVaultFactory, OwnableUpgradeable { +contract MasterVaultFactory is IMasterVaultFactory, Initializable { error ZeroAddress(); error BeaconNotDeployed(); BeaconProxyFactory public beaconProxyFactory; + address public owner; function initialize(address _owner) public initializer { - _transferOwnership(_owner); - + owner = _owner; MasterVault masterVaultImplementation = new MasterVault(); UpgradeableBeacon beacon = new UpgradeableBeacon(address(masterVaultImplementation)); beaconProxyFactory = new BeaconProxyFactory(); @@ -44,7 +43,7 @@ contract MasterVaultFactory is IMasterVaultFactory, OwnableUpgradeable { string memory name = string(abi.encodePacked("Master ", tokenMetadata.name())); string memory symbol = string(abi.encodePacked("m", tokenMetadata.symbol())); - MasterVault(vault).initialize(IERC20(token), name, symbol, address(this)); + MasterVault(vault).initialize(IERC20(token), name, symbol, owner); emit VaultDeployed(token, vault); } @@ -65,14 +64,4 @@ contract MasterVaultFactory is IMasterVaultFactory, OwnableUpgradeable { } 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, - uint256 minSubVaultExchRateWad - ) external onlyOwner { - IMasterVault(masterVault).setSubVault(subVault, minSubVaultExchRateWad); - emit SubVaultSet(masterVault, subVault); - } } diff --git a/test-foundry/libraries/vault/MasterVault.t.sol b/test-foundry/libraries/vault/MasterVault.t.sol index 84a628e32..f78c4f2c2 100644 --- a/test-foundry/libraries/vault/MasterVault.t.sol +++ b/test-foundry/libraries/vault/MasterVault.t.sol @@ -8,6 +8,7 @@ import { MockSubVault } from "../../../contracts/tokenbridge/test/MockSubVault.s import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { UpgradeableBeacon } from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; import { BeaconProxyFactory, ClonableBeaconProxy } from "../../../contracts/tokenbridge/libraries/ClonableBeaconProxy.sol"; +import { IAccessControl } from "@openzeppelin/contracts/access/IAccessControl.sol"; contract MasterVaultTest is Test { MasterVault public vault; @@ -45,6 +46,10 @@ contract MasterVaultTest is Test { 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"); + + assertTrue(vault.hasRole(vault.DEFAULT_ADMIN_ROLE(), address(this)), "Should have DEFAULT_ADMIN_ROLE"); + assertTrue(vault.hasRole(vault.VAULT_MANAGER_ROLE(), address(this)), "Should have VAULT_MANAGER_ROLE"); + assertTrue(vault.hasRole(vault.FEE_MANAGER_ROLE(), address(this)), "Should have FEE_MANAGER_ROLE"); } function test_WithoutSubvault_deposit() public { @@ -297,4 +302,215 @@ contract MasterVaultTest is Test { assertEq(vault.name(), name, "Name should remain after upgrade"); } + function test_setSubVault_revert_NotVaultManager() 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.expectRevert(); + vault.setSubVault(subVault, 1e18); + + vm.stopPrank(); + } + + function test_setBeneficiary_revert_NotFeeManager() public { + address newBeneficiary = address(0x999); + + vm.prank(user); + vm.expectRevert(); + vault.setBeneficiary(newBeneficiary); + } + + function test_withdrawPerformanceFees_revert_NotFeeManager() public { + vm.prank(user); + vm.expectRevert(); + vault.withdrawPerformanceFees(); + } + + function test_roleAdmin() public { + address vaultManager = address(0x1111); + address feeManager = address(0x2222); + + vault.grantRole(vault.VAULT_MANAGER_ROLE(), vaultManager); + vault.grantRole(vault.FEE_MANAGER_ROLE(), feeManager); + + assertTrue(vault.hasRole(vault.VAULT_MANAGER_ROLE(), vaultManager), "Should have VAULT_MANAGER_ROLE"); + assertTrue(vault.hasRole(vault.FEE_MANAGER_ROLE(), feeManager), "Should have FEE_MANAGER_ROLE"); + + vault.revokeRole(vault.VAULT_MANAGER_ROLE(), vaultManager); + assertFalse(vault.hasRole(vault.VAULT_MANAGER_ROLE(), vaultManager), "Should not have VAULT_MANAGER_ROLE"); + } + + function test_multipleRoleHolders() public { + address vaultManager1 = address(0x1111); + address vaultManager2 = address(0x2222); + + vault.grantRole(vault.VAULT_MANAGER_ROLE(), vaultManager1); + vault.grantRole(vault.VAULT_MANAGER_ROLE(), vaultManager2); + + assertTrue(vault.hasRole(vault.VAULT_MANAGER_ROLE(), vaultManager1), "Manager1 should have VAULT_MANAGER_ROLE"); + assertTrue(vault.hasRole(vault.VAULT_MANAGER_ROLE(), vaultManager2), "Manager2 should have VAULT_MANAGER_ROLE"); + + 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(); + + vm.prank(vaultManager1); + vault.setSubVault(subVault, 1e18); + + assertEq(address(vault.subVault()), address(subVault), "SubVault should be set by manager1"); + } + + function test_initialize_pauserRole() public { + assertTrue(vault.hasRole(vault.PAUSER_ROLE(), address(this)), "Should have PAUSER_ROLE"); + assertFalse(vault.paused(), "Should not be paused initially"); + } + + function test_pause() public { + assertFalse(vault.paused(), "Should not be paused initially"); + + vault.pause(); + + assertTrue(vault.paused(), "Should be paused"); + } + + function test_unpause() public { + vault.pause(); + assertTrue(vault.paused(), "Should be paused"); + + vault.unpause(); + + assertFalse(vault.paused(), "Should not be paused"); + } + + function test_pause_revert_NotPauser() public { + vm.prank(user); + vm.expectRevert(); + vault.pause(); + } + + function test_unpause_revert_NotPauser() public { + vault.pause(); + + vm.prank(user); + vm.expectRevert(); + vault.unpause(); + } + + function test_deposit_revert_WhenPaused() public { + vm.startPrank(user); + token.mint(); + uint256 depositAmount = token.balanceOf(user); + token.approve(address(vault), depositAmount); + vm.stopPrank(); + + vault.pause(); + + vm.prank(user); + vm.expectRevert("Pausable: paused"); + vault.deposit(depositAmount, user, 0); + } + + function test_withdraw_revert_WhenPaused() public { + vm.startPrank(user); + token.mint(); + uint256 depositAmount = token.balanceOf(user); + token.approve(address(vault), depositAmount); + vault.deposit(depositAmount, user, 0); + vm.stopPrank(); + + vault.pause(); + + vm.prank(user); + vm.expectRevert("Pausable: paused"); + vault.withdraw(depositAmount / 2, user, user, type(uint256).max); + } + + function test_mint_revert_WhenPaused() public { + vm.startPrank(user); + token.mint(); + uint256 depositAmount = token.balanceOf(user); + token.approve(address(vault), depositAmount); + vm.stopPrank(); + + vault.pause(); + + vm.prank(user); + vm.expectRevert("Pausable: paused"); + vault.mint(100, user, type(uint256).max); + } + + function test_redeem_revert_WhenPaused() public { + vm.startPrank(user); + token.mint(); + uint256 depositAmount = token.balanceOf(user); + token.approve(address(vault), depositAmount); + uint256 shares = vault.deposit(depositAmount, user, 0); + vm.stopPrank(); + + vault.pause(); + + vm.prank(user); + vm.expectRevert("Pausable: paused"); + vault.redeem(shares / 2, user, user, 0); + } + + function test_pauseUnpauseFlow() public { + vm.startPrank(user); + token.mint(); + uint256 depositAmount = token.balanceOf(user); + token.approve(address(vault), depositAmount); + vault.deposit(depositAmount / 2, user, 0); + vm.stopPrank(); + + vault.pause(); + + vm.prank(user); + vm.expectRevert("Pausable: paused"); + vault.deposit(depositAmount / 2, user, 0); + + vault.unpause(); + + vm.prank(user); + vault.deposit(depositAmount / 2, user, 0); + + assertEq(token.balanceOf(user), 0, "All tokens should be deposited"); + } + + function test_multiplePausers() public { + address pauser1 = address(0x3333); + address pauser2 = address(0x4444); + + vault.grantRole(vault.PAUSER_ROLE(), pauser1); + vault.grantRole(vault.PAUSER_ROLE(), pauser2); + + assertTrue(vault.hasRole(vault.PAUSER_ROLE(), pauser1), "Pauser1 should have PAUSER_ROLE"); + assertTrue(vault.hasRole(vault.PAUSER_ROLE(), pauser2), "Pauser2 should have PAUSER_ROLE"); + + vm.prank(pauser1); + vault.pause(); + assertTrue(vault.paused(), "Should be paused by pauser1"); + + vm.prank(pauser2); + vault.unpause(); + assertFalse(vault.paused(), "Should be unpaused by pauser2"); + } + } diff --git a/test-foundry/libraries/vault/MasterVaultFactory.t.sol b/test-foundry/libraries/vault/MasterVaultFactory.t.sol index 268b593a8..48359c390 100644 --- a/test-foundry/libraries/vault/MasterVaultFactory.t.sol +++ b/test-foundry/libraries/vault/MasterVaultFactory.t.sol @@ -41,7 +41,7 @@ contract MasterVaultFactoryTest is Test { MasterVault vault = MasterVault(deployedVault); assertEq(address(vault.asset()), address(token), "Invalid vault asset"); - assertEq(vault.owner(), address(factory), "Invalid vault owner"); + assertTrue(vault.hasRole(vault.DEFAULT_ADMIN_ROLE(), owner), "Factory owner should have DEFAULT_ADMIN_ROLE"); } function test_deployVault_RevertZeroAddress() public { @@ -108,7 +108,7 @@ contract MasterVaultFactoryTest is Test { assertEq(UpgradeableBeacon(factory.beaconProxyFactory().beacon()).implementation(), address(newImplementation), "Beacon should point to new implementation"); - MasterVault(vault1).owner(); - MasterVault(vault2).owner(); + assertTrue(MasterVault(vault1).hasRole(MasterVault(vault1).DEFAULT_ADMIN_ROLE(), owner), "Vault1 should have owner as admin"); + assertTrue(MasterVault(vault2).hasRole(MasterVault(vault2).DEFAULT_ADMIN_ROLE(), owner), "Vault2 should have owner as admin"); } } \ No newline at end of file From 35c2a3cca6b370fb54f55d4aa7525d3062123faf Mon Sep 17 00:00:00 2001 From: Henry <11198460+godzillaba@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:08:57 -0700 Subject: [PATCH 14/30] remove subVaultExchRateWad --- .../libraries/vault/MasterVault.sol | 43 ++++++++----------- .../libraries/vault/MasterVault.t.sol | 4 -- 2 files changed, 17 insertions(+), 30 deletions(-) diff --git a/contracts/tokenbridge/libraries/vault/MasterVault.sol b/contracts/tokenbridge/libraries/vault/MasterVault.sol index 4f6c39b64..187da940f 100644 --- a/contracts/tokenbridge/libraries/vault/MasterVault.sol +++ b/contracts/tokenbridge/libraries/vault/MasterVault.sol @@ -42,12 +42,6 @@ contract MasterVault is Initializable, ERC4626Upgradeable, AccessControlUpgradea // we may need a minimum asset or master share amount when setting subvaults (bc of exchange rate calc) IERC4626 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; - // 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 @@ -77,8 +71,6 @@ contract MasterVault is Initializable, ERC4626Upgradeable, AccessControlUpgradea _grantRole(VAULT_MANAGER_ROLE, _owner); _grantRole(FEE_MANAGER_ROLE, _owner); // todo: consider permissionless by default _grantRole(PAUSER_ROLE, _owner); - - subVaultExchRateWad = 1e18; } @@ -110,7 +102,6 @@ contract MasterVault is Initializable, ERC4626Upgradeable, AccessControlUpgradea /// @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(IERC4626 _subVault, uint256 minSubVaultExchRateWad) external onlyRole(VAULT_MANAGER_ROLE) { - if (address(subVault) != address(0)) revert SubVaultAlreadySet(); _setSubVault(_subVault, minSubVaultExchRateWad); } @@ -121,18 +112,17 @@ contract MasterVault is Initializable, ERC4626Upgradeable, AccessControlUpgradea } function _setSubVault(IERC4626 _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)); + IERC20 underlyingAsset = IERC20(asset()); + if (address(subVault) != address(0)) revert SubVaultAlreadySet(); + if (address(_subVault.asset()) != address(underlyingAsset)) revert SubVaultAssetMismatch(); subVault = _subVault; - uint256 _subVaultExchRateWad = subShares.mulDiv(1e18, totalAssets(), MathUpgradeable.Rounding.Down); - if (_subVaultExchRateWad < minSubVaultExchRateWad) revert SubVaultExchangeRateTooLow(); - subVaultExchRateWad = _subVaultExchRateWad; + IERC20(asset()).safeApprove(address(_subVault), type(uint256).max); + _subVault.deposit(underlyingAsset.balanceOf(address(this)), address(this)); + + uint256 subVaultExchRateWad = _subVault.balanceOf(address(this)).mulDiv(1e18, totalSupply(), MathUpgradeable.Rounding.Down); + if (subVaultExchRateWad < minSubVaultExchRateWad) revert NewSubVaultExchangeRateTooLow(); emit SubvaultChanged(address(0), address(_subVault)); } @@ -141,14 +131,13 @@ contract MasterVault is Initializable, ERC4626Upgradeable, AccessControlUpgradea IERC4626 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, MathUpgradeable.Rounding.Down); - if (effectiveAssetExchRateWad < minAssetExchRateWad) revert TooFewAssetsReceived(); + subVault = IERC4626(address(0)); + oldSubVault.redeem(oldSubVault.balanceOf(address(this)), address(this), address(this)); IERC20(asset()).safeApprove(address(oldSubVault), 0); - subVault = IERC4626(address(0)); - subVaultExchRateWad = 1e18; + + uint256 assetExchRateWad = IERC20(asset()).balanceOf(address(this)).mulDiv(1e18, totalSupply(), MathUpgradeable.Rounding.Down); + if (assetExchRateWad < minAssetExchRateWad) revert SubVaultExchangeRateTooLow(); emit SubvaultChanged(address(oldSubVault), address(0)); } @@ -166,11 +155,13 @@ contract MasterVault is Initializable, ERC4626Upgradeable, AccessControlUpgradea } function masterSharesToSubShares(uint256 masterShares, MathUpgradeable.Rounding rounding) public view returns (uint256) { - return masterShares.mulDiv(subVaultExchRateWad, 1e18, rounding); + // masterShares * totalSubVaultShares / totalMasterShares + return masterShares.mulDiv(subVault.balanceOf(address(this)), totalSupply(), rounding); } function subSharesToMasterShares(uint256 subShares, MathUpgradeable.Rounding rounding) public view returns (uint256) { - return subShares.mulDiv(1e18, subVaultExchRateWad, rounding); + // subShares * totalMasterShares / totalSubVaultShares + return subShares.mulDiv(totalSupply(), subVault.balanceOf(address(this)), rounding); } /// @notice Toggle performance fee collection on/off diff --git a/test-foundry/libraries/vault/MasterVault.t.sol b/test-foundry/libraries/vault/MasterVault.t.sol index f78c4f2c2..57b7f93b9 100644 --- a/test-foundry/libraries/vault/MasterVault.t.sol +++ b/test-foundry/libraries/vault/MasterVault.t.sol @@ -122,7 +122,6 @@ contract MasterVaultTest is Test { 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"); } @@ -164,7 +163,6 @@ contract MasterVaultTest is Test { 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"); @@ -188,7 +186,6 @@ contract MasterVaultTest is Test { 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; @@ -198,7 +195,6 @@ contract MasterVaultTest is Test { 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"); From 9b4809c2785f56ade6aebe64fb7c6b2847a6c487 Mon Sep 17 00:00:00 2001 From: Henry <11198460+godzillaba@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:13:26 -0700 Subject: [PATCH 15/30] remove unused errors --- contracts/tokenbridge/libraries/vault/MasterVault.sol | 3 --- 1 file changed, 3 deletions(-) diff --git a/contracts/tokenbridge/libraries/vault/MasterVault.sol b/contracts/tokenbridge/libraries/vault/MasterVault.sol index 187da940f..8c43eac5d 100644 --- a/contracts/tokenbridge/libraries/vault/MasterVault.sol +++ b/contracts/tokenbridge/libraries/vault/MasterVault.sol @@ -26,12 +26,9 @@ contract MasterVault is Initializable, ERC4626Upgradeable, AccessControlUpgradea error TooManyAssetsDeposited(); error TooFewAssetsReceived(); error SubVaultAlreadySet(); - error SubVaultCannotBeZeroAddress(); - error MustHaveSupplyBeforeSettingSubVault(); error SubVaultAssetMismatch(); error SubVaultExchangeRateTooLow(); error NoExistingSubVault(); - error MustHaveSupplyBeforeSwitchingSubVault(); error NewSubVaultExchangeRateTooLow(); error BeneficiaryNotSet(); error PerformanceFeeDisabled(); From a05eddcc659d62c71259984b4e8bc42c6b765106 Mon Sep 17 00:00:00 2001 From: Henry <11198460+godzillaba@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:34:42 -0700 Subject: [PATCH 16/30] remove switchSubVault --- .../libraries/vault/MasterVault.sol | 26 ++---------- .../libraries/vault/MasterVault.t.sol | 42 ------------------- 2 files changed, 3 insertions(+), 65 deletions(-) diff --git a/contracts/tokenbridge/libraries/vault/MasterVault.sol b/contracts/tokenbridge/libraries/vault/MasterVault.sol index 8c43eac5d..5897d4286 100644 --- a/contracts/tokenbridge/libraries/vault/MasterVault.sol +++ b/contracts/tokenbridge/libraries/vault/MasterVault.sol @@ -99,16 +99,6 @@ contract MasterVault is Initializable, ERC4626Upgradeable, AccessControlUpgradea /// @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(IERC4626 _subVault, uint256 minSubVaultExchRateWad) external onlyRole(VAULT_MANAGER_ROLE) { - _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 onlyRole(VAULT_MANAGER_ROLE) { - _revokeSubVault(minAssetExchRateWad); - } - - function _setSubVault(IERC4626 _subVault, uint256 minSubVaultExchRateWad) internal { IERC20 underlyingAsset = IERC20(asset()); if (address(subVault) != address(0)) revert SubVaultAlreadySet(); if (address(_subVault.asset()) != address(underlyingAsset)) revert SubVaultAssetMismatch(); @@ -124,7 +114,9 @@ contract MasterVault is Initializable, ERC4626Upgradeable, AccessControlUpgradea emit SubvaultChanged(address(0), address(_subVault)); } - function _revokeSubVault(uint256 minAssetExchRateWad) internal { + /// @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 onlyRole(VAULT_MANAGER_ROLE) { IERC4626 oldSubVault = subVault; if (address(oldSubVault) == address(0)) revert NoExistingSubVault(); @@ -139,18 +131,6 @@ contract MasterVault is Initializable, ERC4626Upgradeable, AccessControlUpgradea 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(IERC4626 newSubVault, uint256 minAssetExchRateWad, uint256 minNewSubVaultExchRateWad) external onlyRole(VAULT_MANAGER_ROLE) { - _revokeSubVault(minAssetExchRateWad); - - if (address(newSubVault) != address(0)) { - _setSubVault(newSubVault, minNewSubVaultExchRateWad); - } - } - function masterSharesToSubShares(uint256 masterShares, MathUpgradeable.Rounding rounding) public view returns (uint256) { // masterShares * totalSubVaultShares / totalMasterShares return masterShares.mulDiv(subVault.balanceOf(address(this)), totalSupply(), rounding); diff --git a/test-foundry/libraries/vault/MasterVault.t.sol b/test-foundry/libraries/vault/MasterVault.t.sol index 57b7f93b9..384ef9a39 100644 --- a/test-foundry/libraries/vault/MasterVault.t.sol +++ b/test-foundry/libraries/vault/MasterVault.t.sol @@ -126,48 +126,6 @@ contract MasterVaultTest is Test { 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.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)), From 294c0a0113c57b59bfdc5054e55e73c49d1803e6 Mon Sep 17 00:00:00 2001 From: Wael Almattar Date: Sat, 29 Nov 2025 13:05:17 +0100 Subject: [PATCH 17/30] feat: rewrite master vault with basic performence fee functionality, without subvault --- .../libraries/vault/IMasterVault.sol | 6 - .../libraries/vault/MasterVault.sol | 217 ++------ .../libraries/vault/MasterVaultFactory.sol | 1 - .../libraries/vault/MasterVault.t.sol | 463 ++---------------- .../libraries/vault/MasterVaultCore.t.sol | 131 +++++ .../libraries/vault/MasterVaultFee.t.sol | 205 ++++++++ 6 files changed, 408 insertions(+), 615 deletions(-) delete mode 100644 contracts/tokenbridge/libraries/vault/IMasterVault.sol create mode 100644 test-foundry/libraries/vault/MasterVaultCore.t.sol create mode 100644 test-foundry/libraries/vault/MasterVaultFee.t.sol diff --git a/contracts/tokenbridge/libraries/vault/IMasterVault.sol b/contracts/tokenbridge/libraries/vault/IMasterVault.sol deleted file mode 100644 index 91cfb6c50..000000000 --- a/contracts/tokenbridge/libraries/vault/IMasterVault.sol +++ /dev/null @@ -1,6 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -pragma solidity ^0.8.0; - -interface IMasterVault { - function setSubVault(address subVault, uint256 minSubVaultExchRateWad) external; -} \ No newline at end of file diff --git a/contracts/tokenbridge/libraries/vault/MasterVault.sol b/contracts/tokenbridge/libraries/vault/MasterVault.sol index 5897d4286..32cc3b46c 100644 --- a/contracts/tokenbridge/libraries/vault/MasterVault.sol +++ b/contracts/tokenbridge/libraries/vault/MasterVault.sol @@ -14,9 +14,6 @@ import {MathUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/math/Ma import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; contract MasterVault is Initializable, ERC4626Upgradeable, AccessControlUpgradeable, PausableUpgradeable { - using SafeERC20 for IERC20; - using MathUpgradeable for uint256; - bytes32 public constant VAULT_MANAGER_ROLE = keccak256("VAULT_MANAGER_ROLE"); bytes32 public constant FEE_MANAGER_ROLE = keccak256("FEE_MANAGER_ROLE"); bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); @@ -25,19 +22,15 @@ contract MasterVault is Initializable, ERC4626Upgradeable, AccessControlUpgradea error TooManySharesBurned(); error TooManyAssetsDeposited(); error TooFewAssetsReceived(); - error SubVaultAlreadySet(); - error SubVaultAssetMismatch(); - error SubVaultExchangeRateTooLow(); - error NoExistingSubVault(); - error NewSubVaultExchangeRateTooLow(); - error BeneficiaryNotSet(); - error PerformanceFeeDisabled(); error InvalidAsset(); error InvalidOwner(); + error ZeroAddress(); + error PerformanceFeeDisabled(); + error BeneficiaryNotSet(); - // 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) - IERC4626 public subVault; + event PerformanceFeeToggled(bool enabled); + event BeneficiaryUpdated(address indexed oldBeneficiary, address indexed newBeneficiary); + event PerformanceFeesWithdrawn(address indexed beneficiary, uint256 amount); // 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 @@ -45,11 +38,7 @@ contract MasterVault is Initializable, ERC4626Upgradeable, AccessControlUpgradea // however, this would require more trust in the owner 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); + uint256 public totalPrincipal; // total assets deposited, used to calculate profit function initialize(IERC20 _asset, string memory _name, string memory _symbol, address _owner) external initializer { if (address(_asset) == address(0)) revert InvalidAsset(); @@ -70,76 +59,15 @@ contract MasterVault is Initializable, ERC4626Upgradeable, AccessControlUpgradea _grantRole(PAUSER_ROLE, _owner); } - - function deposit(uint256 assets, address receiver, uint256 minSharesMinted) public returns (uint256) { - uint256 shares = 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 = 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(IERC4626 _subVault, uint256 minSubVaultExchRateWad) external onlyRole(VAULT_MANAGER_ROLE) { - IERC20 underlyingAsset = IERC20(asset()); - if (address(subVault) != address(0)) revert SubVaultAlreadySet(); - if (address(_subVault.asset()) != address(underlyingAsset)) revert SubVaultAssetMismatch(); - - subVault = _subVault; - - IERC20(asset()).safeApprove(address(_subVault), type(uint256).max); - _subVault.deposit(underlyingAsset.balanceOf(address(this)), address(this)); - - uint256 subVaultExchRateWad = _subVault.balanceOf(address(this)).mulDiv(1e18, totalSupply(), MathUpgradeable.Rounding.Down); - if (subVaultExchRateWad < minSubVaultExchRateWad) revert NewSubVaultExchangeRateTooLow(); - - emit SubvaultChanged(address(0), address(_subVault)); - } - - /// @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 onlyRole(VAULT_MANAGER_ROLE) { - IERC4626 oldSubVault = subVault; - if (address(oldSubVault) == address(0)) revert NoExistingSubVault(); - - subVault = IERC4626(address(0)); - - oldSubVault.redeem(oldSubVault.balanceOf(address(this)), address(this), address(this)); - IERC20(asset()).safeApprove(address(oldSubVault), 0); - - uint256 assetExchRateWad = IERC20(asset()).balanceOf(address(this)).mulDiv(1e18, totalSupply(), MathUpgradeable.Rounding.Down); - if (assetExchRateWad < minAssetExchRateWad) revert SubVaultExchangeRateTooLow(); - - emit SubvaultChanged(address(oldSubVault), address(0)); + function pause() external onlyRole(PAUSER_ROLE) { + _pause(); } - function masterSharesToSubShares(uint256 masterShares, MathUpgradeable.Rounding rounding) public view returns (uint256) { - // masterShares * totalSubVaultShares / totalMasterShares - return masterShares.mulDiv(subVault.balanceOf(address(this)), totalSupply(), rounding); + function unpause() external onlyRole(PAUSER_ROLE) { + _unpause(); } - function subSharesToMasterShares(uint256 subShares, MathUpgradeable.Rounding rounding) public view returns (uint256) { - // subShares * totalMasterShares / totalSubVaultShares - return subShares.mulDiv(totalSupply(), subVault.balanceOf(address(this)), rounding); - } + /// fee-related methods /// /// @notice Toggle performance fee collection on/off /// @param enabled True to enable performance fees, false to disable @@ -149,13 +77,20 @@ contract MasterVault is Initializable, ERC4626Upgradeable, AccessControlUpgradea } /// @notice Set the beneficiary address for performance fees - /// @param newBeneficiary Address to receive performance fees, zero address defaults to owner + /// @param newBeneficiary Address to receive performance fees function setBeneficiary(address newBeneficiary) external onlyRole(FEE_MANAGER_ROLE) { + if (newBeneficiary == address(0)) revert ZeroAddress(); address oldBeneficiary = beneficiary; beneficiary = newBeneficiary; emit BeneficiaryUpdated(oldBeneficiary, newBeneficiary); } + /// @notice calculating total profit + function totalProfit() public view returns (uint256) { + uint256 _totalAssets = totalAssets(); + return _totalAssets > totalPrincipal ? _totalAssets - totalPrincipal : 0; + } + /// @notice Withdraw all accumulated performance fees to beneficiary /// @dev Only callable by fee manager when performance fees are enabled function withdrawPerformanceFees() external onlyRole(FEE_MANAGER_ROLE) { @@ -164,118 +99,22 @@ contract MasterVault is Initializable, ERC4626Upgradeable, AccessControlUpgradea uint256 totalProfits = totalProfit(); if (totalProfits > 0) { - IERC4626 _subVault = subVault; - if (address(_subVault) != address(0)) { - _subVault.withdraw(totalProfits, address(this), address(this)); - } - IERC20(asset()).safeTransfer(beneficiary, totalProfits); - } - } - - function pause() external onlyRole(PAUSER_ROLE) { - _pause(); - } - - function unpause() external onlyRole(PAUSER_ROLE) { - _unpause(); - } - - /** @dev See {IERC4626-totalAssets}. */ - function totalAssets() public view virtual override returns (uint256) { - IERC4626 _subVault = subVault; - if (address(_subVault) == address(0)) { - return super.totalAssets(); + SafeERC20.safeTransfer(IERC20(asset()), beneficiary, totalProfits); + emit PerformanceFeesWithdrawn(beneficiary, totalProfits); } - 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)); - } + /// ERC4626 internal methods /// - /** @dev See {IERC4626-maxMint}. */ - function maxMint(address) public view virtual override returns (uint256) { - if (address(subVault) == address(0)) { - return type(uint256).max; - } - uint256 subShares = subVault.maxMint(address(this)); - if (subShares == type(uint256).max) { - return type(uint256).max; - } - return subSharesToMasterShares(subShares, MathUpgradeable.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, MathUpgradeable.Rounding rounding) internal view virtual override returns (uint256 shares) { - IERC4626 _subVault = subVault; - if (address(_subVault) == address(0)) { - return super._convertToShares(assets, rounding); - } - uint256 subShares = rounding == MathUpgradeable.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, MathUpgradeable.Rounding rounding) internal view virtual override returns (uint256 assets) { - IERC4626 _subVault = subVault; - if (address(_subVault) == address(0)) { - return super._convertToAssets(shares, rounding); - } - uint256 subShares = masterSharesToSubShares(shares, rounding); - return rounding == MathUpgradeable.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 whenNotPaused { + /// @dev Override internal deposit to track total principal + function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal virtual override whenNotPaused { super._deposit(caller, receiver, assets, shares); - totalPrincipal += assets; - IERC4626 _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 whenNotPaused { + /// @dev Override internal withdraw to track total principal + function _withdraw(address caller, address receiver, address owner, uint256 assets, uint256 shares) internal virtual override whenNotPaused { + super._withdraw(caller, receiver, owner, assets, shares); totalPrincipal -= assets; - - IERC4626 _subVault = subVault; - if (address(_subVault) != address(0)) { - _subVault.withdraw(assets, address(this), address(this)); - } - - 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 index 0259c84d4..f6e746b95 100644 --- a/contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol +++ b/contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol @@ -6,7 +6,6 @@ import "@openzeppelin/contracts/utils/Create2.sol"; import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; import "../ClonableBeaconProxy.sol"; -import "./IMasterVault.sol"; import "./IMasterVaultFactory.sol"; import "./MasterVault.sol"; diff --git a/test-foundry/libraries/vault/MasterVault.t.sol b/test-foundry/libraries/vault/MasterVault.t.sol index 384ef9a39..ed68e7887 100644 --- a/test-foundry/libraries/vault/MasterVault.t.sol +++ b/test-foundry/libraries/vault/MasterVault.t.sol @@ -1,470 +1,95 @@ // 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"; -import { UpgradeableBeacon } from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; -import { BeaconProxyFactory, ClonableBeaconProxy } from "../../../contracts/tokenbridge/libraries/ClonableBeaconProxy.sol"; -import { IAccessControl } from "@openzeppelin/contracts/access/IAccessControl.sol"; - -contract MasterVaultTest is Test { - MasterVault public vault; - TestERC20 public token; - UpgradeableBeacon public beacon; - BeaconProxyFactory public beaconProxyFactory; - - 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(); - - MasterVault implementation = new MasterVault(); - beacon = new UpgradeableBeacon(address(implementation)); - - beaconProxyFactory = new BeaconProxyFactory(); - beaconProxyFactory.initialize(address(beacon)); - - bytes32 salt = keccak256("test"); - address proxyAddress = beaconProxyFactory.createProxy(salt); - vault = MasterVault(proxyAddress); - - vault.initialize(IERC20(address(token)), name, symbol, address(this)); - } - - 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"); - - assertTrue(vault.hasRole(vault.DEFAULT_ADMIN_ROLE(), address(this)), "Should have DEFAULT_ADMIN_ROLE"); - assertTrue(vault.hasRole(vault.VAULT_MANAGER_ROLE(), address(this)), "Should have VAULT_MANAGER_ROLE"); - assertTrue(vault.hasRole(vault.FEE_MANAGER_ROLE(), address(this)), "Should have FEE_MANAGER_ROLE"); - } - - 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; +import { MasterVaultCoreTest } from "./MasterVaultCore.t.sol"; +contract MasterVaultTest is MasterVaultCoreTest { + // first deposit + function test_deposit() public { vm.startPrank(user); token.mint(); - uint256 depositAmount = token.balanceOf(user); + uint256 depositAmount = 100; token.approve(address(vault), depositAmount); - uint256 sharesBefore = vault.balanceOf(user); - uint256 totalSupplyBefore = vault.totalSupply(); - uint256 totalAssetsBefore = vault.totalAssets(); + uint256 shares = vault.deposit(depositAmount, user); - uint256 shares = vault.deposit(depositAmount, user, minShares); + assertEq(vault.balanceOf(user), shares, "User should receive shares"); + assertEq(vault.totalAssets(), depositAmount, "Vault should hold deposited assets"); + assertEq(vault.totalSupply(), shares, "Total supply should equal shares minted"); + assertEq(token.balanceOf(address(vault)), depositAmount, "Vault should hold the tokens"); - 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"); + assertGt(token.balanceOf(address(vault)), 0, "Vault should hold the tokens"); + assertEq( + vault.totalSupply(), + token.balanceOf(address(vault)), + "First deposit should be at a rate of 1" + ); vm.stopPrank(); } - function test_deposit_RevertTooFewSharesReceived() public { + // first mint + function test_mint() public { vm.startPrank(user); token.mint(); - uint256 depositAmount = token.balanceOf(user); - uint256 minShares = depositAmount * 2; // Unrealistic requirement + uint256 depositAmount = 100; + uint256 expectedShares = depositAmount; // rate 1:1 token.approve(address(vault), depositAmount); - vm.expectRevert(MasterVault.TooFewSharesReceived.selector); - vault.deposit(depositAmount, user, minShares); + uint256 shares = vault.mint(depositAmount, user); - vm.stopPrank(); - } + assertEq(vault.balanceOf(user), shares, "User should receive shares"); + assertEq(expectedShares, shares, "User received shares should be equal to returned shares"); - function test_setSubvault() public { - MockSubVault subVault = new MockSubVault( - IERC20(address(token)), - "Sub Vault Token", - "svTST" - ); + assertEq(vault.totalAssets(), depositAmount, "Vault should hold deposited assets"); + assertEq(vault.totalSupply(), shares, "Total supply should equal shares minted"); + assertEq(token.balanceOf(address(vault)), depositAmount, "Vault should hold the tokens"); - 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.totalAssets(), depositAmount, "Total assets should remain the same"); - assertEq(subVault.balanceOf(address(vault)), depositAmount, "SubVault should have received assets"); } - function test_revokeSubvault() public { - MockSubVault subVault = new MockSubVault( - IERC20(address(token)), - "Sub Vault Token", - "svTST" - ); - + function test_withdraw() public { vm.startPrank(user); token.mint(); uint256 depositAmount = token.balanceOf(user); token.approve(address(vault), depositAmount); - vault.deposit(depositAmount, user, 0); - vm.stopPrank(); + vault.deposit(depositAmount, user); - vault.setSubVault(subVault, 1e18); - - assertEq(address(vault.subVault()), address(subVault), "SubVault should be set"); - assertEq(subVault.balanceOf(address(vault)), depositAmount, "SubVault should have assets"); - - 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.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"); - } - - 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); + uint256 withdrawAmount = depositAmount; // withdraw all assets - vault.balanceOf(user); - uint256 finalTotalAssets = vault.totalAssets(); - subVault.balanceOf(address(vault)); - - vault.withdraw(finalTotalAssets, user, user, type(uint256).max); + uint256 sharesRedeemed = vault.withdraw(withdrawAmount, user, user); assertEq(vault.balanceOf(user), 0, "User should have no shares left"); + assertEq(token.balanceOf(user), depositAmount, "User should receive all withdrawn tokens"); + assertEq(vault.totalAssets(), 0, "Vault should have no assets 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(); - } - - function test_beaconUpgrade() public { - vm.startPrank(user); - token.mint(); - uint256 depositAmount = token.balanceOf(user); - token.approve(address(vault), depositAmount); - vault.deposit(depositAmount, user, 0); - vm.stopPrank(); - - address oldImplementation = beacon.implementation(); - assertEq(oldImplementation, address(beacon.implementation()), "Should have initial implementation"); - - MasterVault newImplementation = new MasterVault(); - beacon.upgradeTo(address(newImplementation)); - - assertEq(beacon.implementation(), address(newImplementation), "Beacon should point to new implementation"); - assertTrue(beacon.implementation() != oldImplementation, "Implementation should have changed"); - - assertEq(vault.name(), name, "Name should remain after upgrade"); - } - - function test_setSubVault_revert_NotVaultManager() 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.expectRevert(); - vault.setSubVault(subVault, 1e18); - - vm.stopPrank(); - } - - function test_setBeneficiary_revert_NotFeeManager() public { - address newBeneficiary = address(0x999); - - vm.prank(user); - vm.expectRevert(); - vault.setBeneficiary(newBeneficiary); - } - - function test_withdrawPerformanceFees_revert_NotFeeManager() public { - vm.prank(user); - vm.expectRevert(); - vault.withdrawPerformanceFees(); - } - - function test_roleAdmin() public { - address vaultManager = address(0x1111); - address feeManager = address(0x2222); + assertEq(token.balanceOf(address(vault)), 0, "Vault should have no tokens left"); + assertEq(sharesRedeemed, userSharesBefore, "All shares should be redeemed"); - vault.grantRole(vault.VAULT_MANAGER_ROLE(), vaultManager); - vault.grantRole(vault.FEE_MANAGER_ROLE(), feeManager); - - assertTrue(vault.hasRole(vault.VAULT_MANAGER_ROLE(), vaultManager), "Should have VAULT_MANAGER_ROLE"); - assertTrue(vault.hasRole(vault.FEE_MANAGER_ROLE(), feeManager), "Should have FEE_MANAGER_ROLE"); - - vault.revokeRole(vault.VAULT_MANAGER_ROLE(), vaultManager); - assertFalse(vault.hasRole(vault.VAULT_MANAGER_ROLE(), vaultManager), "Should not have VAULT_MANAGER_ROLE"); - } - - function test_multipleRoleHolders() public { - address vaultManager1 = address(0x1111); - address vaultManager2 = address(0x2222); - - vault.grantRole(vault.VAULT_MANAGER_ROLE(), vaultManager1); - vault.grantRole(vault.VAULT_MANAGER_ROLE(), vaultManager2); - - assertTrue(vault.hasRole(vault.VAULT_MANAGER_ROLE(), vaultManager1), "Manager1 should have VAULT_MANAGER_ROLE"); - assertTrue(vault.hasRole(vault.VAULT_MANAGER_ROLE(), vaultManager2), "Manager2 should have VAULT_MANAGER_ROLE"); - - 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(); - - vm.prank(vaultManager1); - vault.setSubVault(subVault, 1e18); - - assertEq(address(vault.subVault()), address(subVault), "SubVault should be set by manager1"); - } - - function test_initialize_pauserRole() public { - assertTrue(vault.hasRole(vault.PAUSER_ROLE(), address(this)), "Should have PAUSER_ROLE"); - assertFalse(vault.paused(), "Should not be paused initially"); - } - - function test_pause() public { - assertFalse(vault.paused(), "Should not be paused initially"); - - vault.pause(); - - assertTrue(vault.paused(), "Should be paused"); - } - - function test_unpause() public { - vault.pause(); - assertTrue(vault.paused(), "Should be paused"); - - vault.unpause(); - - assertFalse(vault.paused(), "Should not be paused"); } - function test_pause_revert_NotPauser() public { - vm.prank(user); - vm.expectRevert(); - vault.pause(); - } - - function test_unpause_revert_NotPauser() public { - vault.pause(); - - vm.prank(user); - vm.expectRevert(); - vault.unpause(); - } - - function test_deposit_revert_WhenPaused() public { + function test_redeem() public { vm.startPrank(user); token.mint(); uint256 depositAmount = token.balanceOf(user); token.approve(address(vault), depositAmount); - vm.stopPrank(); + uint256 shares = vault.deposit(depositAmount, user); - vault.pause(); + uint256 sharesToRedeem = shares; // redeem all shares - vm.prank(user); - vm.expectRevert("Pausable: paused"); - vault.deposit(depositAmount, user, 0); - } + uint256 assetsReceived = vault.redeem(sharesToRedeem, user, user); - function test_withdraw_revert_WhenPaused() public { - vm.startPrank(user); - token.mint(); - uint256 depositAmount = token.balanceOf(user); - token.approve(address(vault), depositAmount); - vault.deposit(depositAmount, user, 0); - vm.stopPrank(); - - vault.pause(); - - vm.prank(user); - vm.expectRevert("Pausable: paused"); - vault.withdraw(depositAmount / 2, user, user, type(uint256).max); - } - - function test_mint_revert_WhenPaused() public { - vm.startPrank(user); - token.mint(); - uint256 depositAmount = token.balanceOf(user); - token.approve(address(vault), depositAmount); - vm.stopPrank(); - - vault.pause(); - - vm.prank(user); - vm.expectRevert("Pausable: paused"); - vault.mint(100, user, type(uint256).max); - } - - function test_redeem_revert_WhenPaused() public { - vm.startPrank(user); - token.mint(); - uint256 depositAmount = token.balanceOf(user); - token.approve(address(vault), depositAmount); - uint256 shares = vault.deposit(depositAmount, user, 0); - vm.stopPrank(); - - vault.pause(); - - vm.prank(user); - vm.expectRevert("Pausable: paused"); - vault.redeem(shares / 2, user, user, 0); - } + assertEq(vault.balanceOf(user), 0, "User should have no shares left"); + assertEq(token.balanceOf(user), depositAmount, "User should receive all assets back"); + assertEq(vault.totalAssets(), 0, "Vault should have no assets left"); + assertEq(vault.totalSupply(), 0, "Total supply should be zero"); + assertEq(token.balanceOf(address(vault)), 0, "Vault should have no tokens left"); + assertEq(assetsReceived, depositAmount, "All assets should be received"); - function test_pauseUnpauseFlow() public { - vm.startPrank(user); - token.mint(); - uint256 depositAmount = token.balanceOf(user); - token.approve(address(vault), depositAmount); - vault.deposit(depositAmount / 2, user, 0); vm.stopPrank(); - - vault.pause(); - - vm.prank(user); - vm.expectRevert("Pausable: paused"); - vault.deposit(depositAmount / 2, user, 0); - - vault.unpause(); - - vm.prank(user); - vault.deposit(depositAmount / 2, user, 0); - - assertEq(token.balanceOf(user), 0, "All tokens should be deposited"); - } - - function test_multiplePausers() public { - address pauser1 = address(0x3333); - address pauser2 = address(0x4444); - - vault.grantRole(vault.PAUSER_ROLE(), pauser1); - vault.grantRole(vault.PAUSER_ROLE(), pauser2); - - assertTrue(vault.hasRole(vault.PAUSER_ROLE(), pauser1), "Pauser1 should have PAUSER_ROLE"); - assertTrue(vault.hasRole(vault.PAUSER_ROLE(), pauser2), "Pauser2 should have PAUSER_ROLE"); - - vm.prank(pauser1); - vault.pause(); - assertTrue(vault.paused(), "Should be paused by pauser1"); - - vm.prank(pauser2); - vault.unpause(); - assertFalse(vault.paused(), "Should be unpaused by pauser2"); } - } diff --git a/test-foundry/libraries/vault/MasterVaultCore.t.sol b/test-foundry/libraries/vault/MasterVaultCore.t.sol new file mode 100644 index 000000000..a0d9c20c4 --- /dev/null +++ b/test-foundry/libraries/vault/MasterVaultCore.t.sol @@ -0,0 +1,131 @@ +// 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 { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { UpgradeableBeacon } from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; +import { BeaconProxyFactory, ClonableBeaconProxy } from "../../../contracts/tokenbridge/libraries/ClonableBeaconProxy.sol"; +import { IAccessControl } from "@openzeppelin/contracts/access/IAccessControl.sol"; + +contract MasterVaultCoreTest is Test { + MasterVault public vault; + TestERC20 public token; + UpgradeableBeacon public beacon; + BeaconProxyFactory public beaconProxyFactory; + + address public user = address(0x1); + string public name = "Master Test Token"; + string public symbol = "mTST"; + + function setUp() public { + token = new TestERC20(); + + MasterVault implementation = new MasterVault(); + beacon = new UpgradeableBeacon(address(implementation)); + + beaconProxyFactory = new BeaconProxyFactory(); + beaconProxyFactory.initialize(address(beacon)); + + bytes32 salt = keccak256("test"); + address proxyAddress = beaconProxyFactory.createProxy(salt); + vault = MasterVault(proxyAddress); + + vault.initialize(IERC20(address(token)), name, symbol, address(this)); + } + + /// todo: + /// test pause func with deposits/withdrawals + /// test deposit, withdraw, mint, redeem + + 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"); + + assertTrue(vault.hasRole(vault.DEFAULT_ADMIN_ROLE(), address(this)), "Should have DEFAULT_ADMIN_ROLE"); + assertTrue(vault.hasRole(vault.VAULT_MANAGER_ROLE(), address(this)), "Should have VAULT_MANAGER_ROLE"); + assertTrue(vault.hasRole(vault.FEE_MANAGER_ROLE(), address(this)), "Should have FEE_MANAGER_ROLE"); + } + + function test_beaconUpgrade() public { + vm.startPrank(user); + token.mint(); + uint256 depositAmount = token.balanceOf(user); + token.approve(address(vault), depositAmount); + vault.deposit(depositAmount, user); + vm.stopPrank(); + + address oldImplementation = beacon.implementation(); + assertEq(oldImplementation, address(beacon.implementation()), "Should have initial implementation"); + + MasterVault newImplementation = new MasterVault(); + beacon.upgradeTo(address(newImplementation)); + + assertEq(beacon.implementation(), address(newImplementation), "Beacon should point to new implementation"); + assertTrue(beacon.implementation() != oldImplementation, "Implementation should have changed"); + + assertEq(vault.name(), name, "Name should remain after upgrade"); + } + + + function test_initialize_pauserRole() public { + assertTrue(vault.hasRole(vault.PAUSER_ROLE(), address(this)), "Should have PAUSER_ROLE"); + assertFalse(vault.paused(), "Should not be paused initially"); + } + + function test_pause() public { + assertFalse(vault.paused(), "Should not be paused initially"); + + vault.pause(); + + assertTrue(vault.paused(), "Should be paused"); + } + + function test_unpause() public { + vault.pause(); + assertTrue(vault.paused(), "Should be paused"); + + vault.unpause(); + + assertFalse(vault.paused(), "Should not be paused"); + } + + function test_pause_revert_NotPauser() public { + vm.prank(user); + vm.expectRevert(); + vault.pause(); + } + + function test_unpause_revert_NotPauser() public { + vault.pause(); + + vm.prank(user); + vm.expectRevert(); + vault.unpause(); + } + + function test_multiplePausers() public { + address pauser1 = address(0x3333); + address pauser2 = address(0x4444); + + vault.grantRole(vault.PAUSER_ROLE(), pauser1); + vault.grantRole(vault.PAUSER_ROLE(), pauser2); + + assertTrue(vault.hasRole(vault.PAUSER_ROLE(), pauser1), "Pauser1 should have PAUSER_ROLE"); + assertTrue(vault.hasRole(vault.PAUSER_ROLE(), pauser2), "Pauser2 should have PAUSER_ROLE"); + + vm.prank(pauser1); + vault.pause(); + assertTrue(vault.paused(), "Should be paused by pauser1"); + + vm.prank(pauser2); + vault.unpause(); + assertFalse(vault.paused(), "Should be unpaused by pauser2"); + } + +} diff --git a/test-foundry/libraries/vault/MasterVaultFee.t.sol b/test-foundry/libraries/vault/MasterVaultFee.t.sol new file mode 100644 index 000000000..7855b6d78 --- /dev/null +++ b/test-foundry/libraries/vault/MasterVaultFee.t.sol @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import { MasterVaultCoreTest } from "./MasterVaultCore.t.sol"; +import { MasterVault } from "../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; + +contract MasterVaultFeeTest is MasterVaultCoreTest { + address public beneficiaryAddress = address(0x9999); + + function test_setPerformanceFee_enable() public { + assertFalse(vault.enablePerformanceFee(), "Performance fee should be disabled by default"); + + vault.setPerformanceFee(true); + + assertTrue(vault.enablePerformanceFee(), "Performance fee should be enabled"); + } + + function test_setPerformanceFee_disable() public { + vault.setPerformanceFee(true); + assertTrue(vault.enablePerformanceFee(), "Performance fee should be enabled"); + + vault.setPerformanceFee(false); + + assertFalse(vault.enablePerformanceFee(), "Performance fee should be disabled"); + } + + function test_setPerformanceFee_revert_NotVaultManager() public { + vm.prank(user); + vm.expectRevert(); + vault.setPerformanceFee(true); + } + + function test_setPerformanceFee_emitsEvent() public { + vm.expectEmit(true, true, true, true); + emit PerformanceFeeToggled(true); + vault.setPerformanceFee(true); + + vm.expectEmit(true, true, true, true); + emit PerformanceFeeToggled(false); + vault.setPerformanceFee(false); + } + + function test_setBeneficiary() public { + assertEq(vault.beneficiary(), address(0), "Beneficiary should be zero address by default"); + + vault.setBeneficiary(beneficiaryAddress); + + assertEq(vault.beneficiary(), beneficiaryAddress, "Beneficiary should be updated"); + } + + function test_setBeneficiary_revert_ZeroAddress() public { + vm.expectRevert(MasterVault.ZeroAddress.selector); + vault.setBeneficiary(address(0)); + } + + function test_setBeneficiary_revert_NotFeeManager() public { + vm.prank(user); + vm.expectRevert(); + vault.setBeneficiary(beneficiaryAddress); + } + + function test_setBeneficiary_emitsEvent() public { + vm.expectEmit(true, true, true, true); + emit BeneficiaryUpdated(address(0), beneficiaryAddress); + vault.setBeneficiary(beneficiaryAddress); + + address newBeneficiary = address(0x8888); + vm.expectEmit(true, true, true, true); + emit BeneficiaryUpdated(beneficiaryAddress, newBeneficiary); + vault.setBeneficiary(newBeneficiary); + } + + function test_setPerformanceFee_withVaultManagerRole() public { + address vaultManager = address(0x7777); + vault.grantRole(vault.VAULT_MANAGER_ROLE(), vaultManager); + + vm.prank(vaultManager); + vault.setPerformanceFee(true); + + assertTrue(vault.enablePerformanceFee(), "Vault manager should be able to set performance fee"); + } + + function test_setBeneficiary_withFeeManagerRole() public { + address feeManager = address(0x6666); + vault.grantRole(vault.FEE_MANAGER_ROLE(), feeManager); + + vm.prank(feeManager); + vault.setBeneficiary(beneficiaryAddress); + + assertEq(vault.beneficiary(), beneficiaryAddress, "Fee manager should be able to set beneficiary"); + } + + function test_deposit_updatesTotalPrincipal() public { + assertEq(vault.totalPrincipal(), 0, "Total principal should be zero initially"); + + vm.startPrank(user); + token.mint(); + uint256 depositAmount = 100; + token.approve(address(vault), depositAmount); + + vault.deposit(depositAmount, user); + + assertEq(vault.totalPrincipal(), depositAmount, "Total principal should equal deposit amount"); + + vm.stopPrank(); + } + + function test_mint_updatesTotalPrincipal() public { + assertEq(vault.totalPrincipal(), 0, "Total principal should be zero initially"); + + vm.startPrank(user); + token.mint(); + uint256 shares = 100; + token.approve(address(vault), shares); + + uint256 assets = vault.mint(shares, user); + + assertEq(vault.totalPrincipal(), assets, "Total principal should equal assets deposited"); + + vm.stopPrank(); + } + + function test_withdraw_updatesTotalPrincipal() public { + vm.startPrank(user); + token.mint(); + uint256 depositAmount = 200; + token.approve(address(vault), depositAmount); + vault.deposit(depositAmount, user); + + assertEq(vault.totalPrincipal(), depositAmount, "Total principal should equal deposit amount"); + + uint256 withdrawAmount = 100; + vault.withdraw(withdrawAmount, user, user); + + assertEq(vault.totalPrincipal(), depositAmount - withdrawAmount, "Total principal should decrease by withdraw amount"); + + vm.stopPrank(); + } + + function test_redeem_updatesTotalPrincipal() public { + vm.startPrank(user); + token.mint(); + uint256 depositAmount = 200; + token.approve(address(vault), depositAmount); + uint256 shares = vault.deposit(depositAmount, user); + + assertEq(vault.totalPrincipal(), depositAmount, "Total principal should equal deposit amount"); + + uint256 sharesToRedeem = shares / 2; + uint256 assetsReceived = vault.redeem(sharesToRedeem, user, user); + + assertEq(vault.totalPrincipal(), depositAmount - assetsReceived, "Total principal should decrease by redeemed assets"); + + vm.stopPrank(); + } + + function test_withdrawPerformanceFees_revert_PerformanceFeeDisabled() public { + vault.setBeneficiary(beneficiaryAddress); + + vm.expectRevert(MasterVault.PerformanceFeeDisabled.selector); + vault.withdrawPerformanceFees(); + } + + function test_withdrawPerformanceFees_revert_BeneficiaryNotSet() public { + vault.setPerformanceFee(true); + + vm.expectRevert(MasterVault.BeneficiaryNotSet.selector); + vault.withdrawPerformanceFees(); + } + + function test_withdrawPerformanceFees_VaultDoubleInAssets() public { + vault.setPerformanceFee(true); + vault.setBeneficiary(beneficiaryAddress); + + vm.startPrank(user); + token.mint(); + uint256 depositAmount = token.balanceOf(user); + token.approve(address(vault), depositAmount); + vault.deposit(depositAmount, user); + vm.stopPrank(); + + assertEq(vault.totalPrincipal(), depositAmount, "Total principal should equal deposit"); + assertEq(vault.totalAssets(), depositAmount, "Total assets should equal deposit"); + assertEq(vault.totalProfit(), 0, "Should have no profit initially"); + + vm.prank(address(vault)); + token.mint(); + + assertEq(vault.totalAssets(), depositAmount * 2, "Total assets should be doubled"); + assertEq(vault.totalProfit(), depositAmount, "Profit should equal initial deposit amount"); + + uint256 beneficiaryBalanceBefore = token.balanceOf(beneficiaryAddress); + + vm.expectEmit(true, true, true, true); + emit PerformanceFeesWithdrawn(beneficiaryAddress, depositAmount); + vault.withdrawPerformanceFees(); + + assertEq(token.balanceOf(beneficiaryAddress), beneficiaryBalanceBefore + depositAmount, "Beneficiary should receive profit"); + assertEq(vault.totalAssets(), depositAmount, "Vault assets should decrease by profit amount"); + } + + event PerformanceFeeToggled(bool enabled); + event BeneficiaryUpdated(address indexed oldBeneficiary, address indexed newBeneficiary); + event PerformanceFeesWithdrawn(address indexed beneficiary, uint256 amount); +} From 35d0b971b5c01f682be630c183a5a8f01db0cf62 Mon Sep 17 00:00:00 2001 From: Wael Almattar Date: Sat, 29 Nov 2025 13:54:49 +0100 Subject: [PATCH 18/30] func: sharePrice --- .../libraries/vault/MasterVault.sol | 100 ++++++++++++++---- .../vault/MasterVaultSharePrice.t.sol | 83 +++++++++++++++ 2 files changed, 162 insertions(+), 21 deletions(-) create mode 100644 test-foundry/libraries/vault/MasterVaultSharePrice.t.sol diff --git a/contracts/tokenbridge/libraries/vault/MasterVault.sol b/contracts/tokenbridge/libraries/vault/MasterVault.sol index 32cc3b46c..3ebd9878b 100644 --- a/contracts/tokenbridge/libraries/vault/MasterVault.sol +++ b/contracts/tokenbridge/libraries/vault/MasterVault.sol @@ -1,19 +1,36 @@ // SPDX-License-Identifier: Apache-2.0 pragma solidity ^0.8.0; -import {ERC4626Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.sol"; -import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; -import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; -import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; -import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/security/PausableUpgradeable.sol"; -import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; -import {MathUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/math/MathUpgradeable.sol"; -import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; - -contract MasterVault is Initializable, ERC4626Upgradeable, AccessControlUpgradeable, PausableUpgradeable { +import { + ERC4626Upgradeable +} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.sol"; +import { + ERC20Upgradeable +} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { + IERC20Upgradeable +} from "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; +import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import { + AccessControlUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import { + PausableUpgradeable +} from "@openzeppelin/contracts-upgradeable/security/PausableUpgradeable.sol"; +import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import { + MathUpgradeable +} from "@openzeppelin/contracts-upgradeable/utils/math/MathUpgradeable.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +contract MasterVault is + Initializable, + ERC4626Upgradeable, + AccessControlUpgradeable, + PausableUpgradeable +{ bytes32 public constant VAULT_MANAGER_ROLE = keccak256("VAULT_MANAGER_ROLE"); bytes32 public constant FEE_MANAGER_ROLE = keccak256("FEE_MANAGER_ROLE"); bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); @@ -40,7 +57,12 @@ contract MasterVault is Initializable, ERC4626Upgradeable, AccessControlUpgradea address public beneficiary; uint256 public totalPrincipal; // total assets deposited, used to calculate profit - function initialize(IERC20 _asset, string memory _name, string memory _symbol, address _owner) external initializer { + function initialize( + IERC20 _asset, + string memory _name, + string memory _symbol, + address _owner + ) external initializer { if (address(_asset) == address(0)) revert InvalidAsset(); if (_owner == address(0)) revert InvalidOwner(); @@ -67,7 +89,7 @@ contract MasterVault is Initializable, ERC4626Upgradeable, AccessControlUpgradea _unpause(); } - /// fee-related methods /// + /// fee-related methods /// /// @notice Toggle performance fee collection on/off /// @param enabled True to enable performance fees, false to disable @@ -85,12 +107,12 @@ contract MasterVault is Initializable, ERC4626Upgradeable, AccessControlUpgradea emit BeneficiaryUpdated(oldBeneficiary, newBeneficiary); } - /// @notice calculating total profit + /// @notice calculating total profit function totalProfit() public view returns (uint256) { uint256 _totalAssets = totalAssets(); return _totalAssets > totalPrincipal ? _totalAssets - totalPrincipal : 0; } - + /// @notice Withdraw all accumulated performance fees to beneficiary /// @dev Only callable by fee manager when performance fees are enabled function withdrawPerformanceFees() external onlyRole(FEE_MANAGER_ROLE) { @@ -104,17 +126,53 @@ contract MasterVault is Initializable, ERC4626Upgradeable, AccessControlUpgradea } } - /// ERC4626 internal methods /// + /// @notice return share price by asset in 18 decimals + /// @dev max value is 1e18 if performance fee is enabled + /// @dev examples: + /// example 1. sharePrice = 1e18 means we need to pay 1 asset to get 1 share regardless of the decimals + /// example 2. sharePrice = 10 * 1e18 means we need to pay 10 asset to get 1 share regardless of the decimals + /// example 3. sharePrice = 0.1 * 1e18 means we need to pay 0.1 asset to get 1 share regardless of the decimals + /// example 4. vault holds 99 USDC and 100 shares => sharePrice = 99 * 1e18 / 100 + function sharePrice() public view returns (uint256) { + uint256 multiplier = 1e18; + uint256 _totalAssets = totalAssets(); + uint256 _totalSupply = totalSupply(); + + if (_totalSupply == 0) { + return 1 * multiplier; + } + + uint256 _sharePrice = MathUpgradeable.mulDiv(_totalAssets, multiplier, _totalSupply); + + if (enablePerformanceFee) { + _sharePrice = MathUpgradeable.min(_sharePrice, 1e18); + } + + return _sharePrice; + } + + /// ERC4626 internal methods /// /// @dev Override internal deposit to track total principal - function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal virtual override whenNotPaused { + function _deposit( + address caller, + address receiver, + uint256 assets, + uint256 shares + ) internal virtual override whenNotPaused { super._deposit(caller, receiver, assets, shares); totalPrincipal += assets; } /// @dev Override internal withdraw to track total principal - function _withdraw(address caller, address receiver, address owner, uint256 assets, uint256 shares) internal virtual override whenNotPaused { + function _withdraw( + address caller, + address receiver, + address owner, + uint256 assets, + uint256 shares + ) internal virtual override whenNotPaused { super._withdraw(caller, receiver, owner, assets, shares); totalPrincipal -= assets; } -} \ No newline at end of file +} diff --git a/test-foundry/libraries/vault/MasterVaultSharePrice.t.sol b/test-foundry/libraries/vault/MasterVaultSharePrice.t.sol new file mode 100644 index 000000000..a4685a1f2 --- /dev/null +++ b/test-foundry/libraries/vault/MasterVaultSharePrice.t.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import { MasterVaultCoreTest } from "./MasterVaultCore.t.sol"; +import { MasterVault } from "../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; + +contract MasterVaultSharePriceTest is MasterVaultCoreTest { + /// @dev example 1. sharePrice = 1e18 means we need to pay 1 asset to get 1 share regardless of the decimals + function test_sharePrice_example1_oneToOne() public { + vm.startPrank(user); + token.mint(); + uint256 depositAmount = 100; + token.approve(address(vault), depositAmount); + vault.deposit(depositAmount, user); + vm.stopPrank(); + + uint256 price = vault.sharePrice(); + assertEq(price, 1e18, "Share price should be 1e18 for 1:1 ratio"); + } + + /// @dev example 2. sharePrice = 2 * 1e18 means we need to pay 2 asset to get 1 share regardless of the decimals + function test_sharePrice_example2_twoToOne() public { + vm.startPrank(user); + token.mint(); + uint256 depositAmount = token.balanceOf(user); + token.approve(address(vault), depositAmount); + vault.deposit(depositAmount, user); + vm.stopPrank(); + + // Simulate vault growth: double the assets + vm.prank(address(vault)); + token.mint(); + + // Now vault has 2x assets compared to shares + uint256 price = vault.sharePrice(); + assertEq(price, 2e18, "Share price should be 2e18 when assets are 2x shares"); + } + + /// @dev example 3. sharePrice = 0.1 * 1e18 means we need to pay 0.1 asset to get 1 share regardless of the decimals + function test_sharePrice_example3_oneToTen() public { + // This scenario would require shares > assets, which happens in loss scenarios + // We'll simulate by having 1000 shares but only 100 assets + vm.startPrank(user); + token.mint(); + uint256 depositAmount = 1000; + token.approve(address(vault), depositAmount); + vault.deposit(depositAmount, user); + vm.stopPrank(); + + // simulate vault loss: transfer out 90% of assets + vm.prank(address(vault)); + token.transfer(user, 900); + + // vault has 100 assets but 1000 shares + uint256 price = vault.sharePrice(); + assertEq(price, 0.1e18, "Share price should be 0.1e18 when assets are 1/10 of shares"); + } + + /// @dev example 4. vault holds 99 USDC and 100 shares => sharePrice = 99 * 1e18 / 100 + function test_sharePrice_example4_ninetyNineToHundred() public { + vm.startPrank(user); + token.mint(); + uint256 depositAmount = 100; + token.approve(address(vault), depositAmount); + vault.deposit(depositAmount, user); + vm.stopPrank(); + + // simulate vault loss: transfer out 1 unit + vm.prank(address(vault)); + token.transfer(user, 1); + + // vault has 99 assets and 100 shares + uint256 price = vault.sharePrice(); + uint256 expectedPrice = (99 * 1e18) / 100; + assertEq(price, expectedPrice, "Share price should be 99/100 * 1e18"); + assertEq(price, 0.99e18, "Share price should be 0.99e18"); + } + + function test_sharePrice_zeroSupply() public { + uint256 price = vault.sharePrice(); + assertEq(price, 1e18, "Share price should default to 1e18 when supply is zero"); + } +} From b0f7ead8a1351f731be15a3ca6f5ab80e21c0581 Mon Sep 17 00:00:00 2001 From: Wael Almattar Date: Sat, 29 Nov 2025 21:20:53 +0100 Subject: [PATCH 19/30] override _convertToShares & _convertToAssets --- .../libraries/vault/MasterVault.sol | 69 +- contracts/tokenbridge/test/TestERC20.sol | 4 + .../libraries/vault/MasterVault.t.sol | 17 +- .../libraries/vault/MasterVaultCore.t.sol | 2 +- .../vault/MasterVaultSharePrice.t.sol | 83 --- .../vault/MasterVaultSharePriceNoFee.t.sol | 605 ++++++++++++++++++ .../vault/MasterVaultSharePriceWithFee.t.sol | 223 +++++++ 7 files changed, 902 insertions(+), 101 deletions(-) delete mode 100644 test-foundry/libraries/vault/MasterVaultSharePrice.t.sol create mode 100644 test-foundry/libraries/vault/MasterVaultSharePriceNoFee.t.sol create mode 100644 test-foundry/libraries/vault/MasterVaultSharePriceWithFee.t.sol diff --git a/contracts/tokenbridge/libraries/vault/MasterVault.sol b/contracts/tokenbridge/libraries/vault/MasterVault.sol index 3ebd9878b..ee5646446 100644 --- a/contracts/tokenbridge/libraries/vault/MasterVault.sol +++ b/contracts/tokenbridge/libraries/vault/MasterVault.sol @@ -35,6 +35,8 @@ contract MasterVault is bytes32 public constant FEE_MANAGER_ROLE = keccak256("FEE_MANAGER_ROLE"); bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); + uint256 public constant MULTIPLIER = 1e18; + error TooFewSharesReceived(); error TooManySharesBurned(); error TooManyAssetsDeposited(); @@ -129,20 +131,20 @@ contract MasterVault is /// @notice return share price by asset in 18 decimals /// @dev max value is 1e18 if performance fee is enabled /// @dev examples: - /// example 1. sharePrice = 1e18 means we need to pay 1 asset to get 1 share regardless of the decimals - /// example 2. sharePrice = 10 * 1e18 means we need to pay 10 asset to get 1 share regardless of the decimals - /// example 3. sharePrice = 0.1 * 1e18 means we need to pay 0.1 asset to get 1 share regardless of the decimals + /// example 1. sharePrice = 1e18 means we need to pay 1 asset to get 1 share + /// example 2. sharePrice = 10 * 1e18 means we need to pay 10 asset to get 1 share + /// example 3. sharePrice = 0.1 * 1e18 means we need to pay 0.1 asset to get 1 share /// example 4. vault holds 99 USDC and 100 shares => sharePrice = 99 * 1e18 / 100 function sharePrice() public view returns (uint256) { - uint256 multiplier = 1e18; uint256 _totalAssets = totalAssets(); uint256 _totalSupply = totalSupply(); - if (_totalSupply == 0) { - return 1 * multiplier; + // todo: should we also consider _totalAssets == 0 case? + if (_totalSupply == 0 || _totalAssets == 0) { + return 1 * MULTIPLIER; } - uint256 _sharePrice = MathUpgradeable.mulDiv(_totalAssets, multiplier, _totalSupply); + uint256 _sharePrice = MathUpgradeable.mulDiv(_totalAssets, MULTIPLIER, _totalSupply); if (enablePerformanceFee) { _sharePrice = MathUpgradeable.min(_sharePrice, 1e18); @@ -153,6 +155,59 @@ contract MasterVault is /// ERC4626 internal methods /// + /// @dev Override to implement performance fee logic when converting assets to shares + /// @dev this follow exactly same precision that ERC4626 impl. does with no deciamls. ie 1 share = 1 wei of share + /// @dev when user acquiring shares this should round Down [deposit, mint] + /// // and round Up when redeeming [withdraw, redeem] + /// examples: + /// 1. sharePrice = 1 * 1e18 & assets = 1; then output should be {Up: 1 , Down: 1 } + /// 2. sharePrice = 0.1 * 1e18 & assets = 1; then output should be {Up: 10, Down: 10} + /// 3. sharePrice = 10 * 1e18 & assets = 1; then output should be {Up: 1 , Down: 0 }; this require tests to cover: [deposit, mint, withdraw, redeem] + /// 4. sharePrice = 100 * 1e18 & assets = 99; then output should be {Up: 1 , Down: 0 }; this require tests to cover: [deposit, mint, withdraw, redeem] + /// 5. sharePrice = 100 * 1e18 & assets = 199; then output should be {Up: 2 , Down: 1 }; this require tests to cover: [deposit, mint, withdraw, redeem] + /// @notice sharePrice can be > 1 only if perf fee is disabled + function _convertToShares( + uint256 assets, + MathUpgradeable.Rounding rounding + ) internal view virtual override returns (uint256) { + uint256 _sharePrice = sharePrice(); + uint256 _shares = MathUpgradeable.mulDiv(assets, MULTIPLIER, _sharePrice, rounding); + return _shares; + } + + /// @dev Override to implement performance fee logic when converting assets to shares + /// @dev this follow exactly same precision that ERC4626 impl. does with no deciamls. ie 1 share = 1 wei of share + /// @dev _effectiveAssets is to: + /// // 1. let users socialize losses but not profit if perf fee is enable + /// // 2. let users socialize losses and profit if perf fee is disabled + /// @dev when user redeeming shares for assets this should round Down [withdraw, redeem] + /// // and round Up when redeeming [deposit, mint] + /// examples: + /// * group (A): perf fee is enable + /// 1. shares = 1 & _totalAssets = 1 & _totalSupply = 1 ; then output should be {Up: 1 , Down: 1 } + /// 2. shares = 1 & _totalAssets = 2 & _totalSupply = 1 ; then output should be {Up: 2 , Down: 2 } + /// 3. shares = 1 & _totalAssets = 1 & _totalSupply = 2 ; then output should be {Up: 1 , Down: 0 } + /// 4. shares = 99 & _totalAssets = 1 & _totalSupply = 100; then output should be {Up: 1 , Down: 0 } + /// 5. shares = 1 & _totalAssets = 1 & _totalSupply = 0 ; then output should be {Up: 1 , Down: 1 } + /// 6. shares = 1 & _totalAssets = 0 & _totalSupply = 1 ; then output should be {Up: 0 , Down: 0 } + function _convertToAssets( + uint256 shares, + MathUpgradeable.Rounding rounding + ) internal view virtual override returns (uint256) { + uint256 _totalAssets = totalAssets(); + uint256 _totalSupply = totalSupply(); + uint256 _effectiveAssets = enablePerformanceFee + ? MathUpgradeable.min(_totalAssets, totalPrincipal) + : _totalAssets; + + if (_totalSupply == 0) { + return 1; + } + + uint256 _assets = MathUpgradeable.mulDiv(shares, _effectiveAssets, _totalSupply, rounding); + return _assets; + } + /// @dev Override internal deposit to track total principal function _deposit( address caller, diff --git a/contracts/tokenbridge/test/TestERC20.sol b/contracts/tokenbridge/test/TestERC20.sol index 71dd2c005..fc2741144 100644 --- a/contracts/tokenbridge/test/TestERC20.sol +++ b/contracts/tokenbridge/test/TestERC20.sol @@ -28,6 +28,10 @@ contract TestERC20 is aeERC20 { function mint() external { _mint(msg.sender, 50000000); } + + function mint(uint256 amount) external { + _mint(msg.sender, amount); + } } // test token code inspired from maker diff --git a/test-foundry/libraries/vault/MasterVault.t.sol b/test-foundry/libraries/vault/MasterVault.t.sol index ed68e7887..89abb33dd 100644 --- a/test-foundry/libraries/vault/MasterVault.t.sol +++ b/test-foundry/libraries/vault/MasterVault.t.sol @@ -33,19 +33,16 @@ contract MasterVaultTest is MasterVaultCoreTest { function test_mint() public { vm.startPrank(user); token.mint(); - uint256 depositAmount = 100; - uint256 expectedShares = depositAmount; // rate 1:1 - - token.approve(address(vault), depositAmount); + uint256 sharesToMint = 100; - uint256 shares = vault.mint(depositAmount, user); + token.approve(address(vault), type(uint256).max); - assertEq(vault.balanceOf(user), shares, "User should receive shares"); - assertEq(expectedShares, shares, "User received shares should be equal to returned shares"); + uint256 assetsCost = vault.mint(sharesToMint, user); - assertEq(vault.totalAssets(), depositAmount, "Vault should hold deposited assets"); - assertEq(vault.totalSupply(), shares, "Total supply should equal shares minted"); - assertEq(token.balanceOf(address(vault)), depositAmount, "Vault should hold the tokens"); + assertEq(vault.balanceOf(user), sharesToMint, "User should receive requested shares"); + assertEq(vault.totalSupply(), sharesToMint, "Total supply should equal shares minted"); + assertEq(vault.totalAssets(), assetsCost, "Vault should hold the assets deposited"); + assertEq(token.balanceOf(address(vault)), assetsCost, "Vault should hold the tokens"); vm.stopPrank(); } diff --git a/test-foundry/libraries/vault/MasterVaultCore.t.sol b/test-foundry/libraries/vault/MasterVaultCore.t.sol index a0d9c20c4..c3f7f9639 100644 --- a/test-foundry/libraries/vault/MasterVaultCore.t.sol +++ b/test-foundry/libraries/vault/MasterVaultCore.t.sol @@ -19,7 +19,7 @@ contract MasterVaultCoreTest is Test { string public name = "Master Test Token"; string public symbol = "mTST"; - function setUp() public { + function setUp() public virtual { token = new TestERC20(); MasterVault implementation = new MasterVault(); diff --git a/test-foundry/libraries/vault/MasterVaultSharePrice.t.sol b/test-foundry/libraries/vault/MasterVaultSharePrice.t.sol deleted file mode 100644 index a4685a1f2..000000000 --- a/test-foundry/libraries/vault/MasterVaultSharePrice.t.sol +++ /dev/null @@ -1,83 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.0; - -import { MasterVaultCoreTest } from "./MasterVaultCore.t.sol"; -import { MasterVault } from "../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; - -contract MasterVaultSharePriceTest is MasterVaultCoreTest { - /// @dev example 1. sharePrice = 1e18 means we need to pay 1 asset to get 1 share regardless of the decimals - function test_sharePrice_example1_oneToOne() public { - vm.startPrank(user); - token.mint(); - uint256 depositAmount = 100; - token.approve(address(vault), depositAmount); - vault.deposit(depositAmount, user); - vm.stopPrank(); - - uint256 price = vault.sharePrice(); - assertEq(price, 1e18, "Share price should be 1e18 for 1:1 ratio"); - } - - /// @dev example 2. sharePrice = 2 * 1e18 means we need to pay 2 asset to get 1 share regardless of the decimals - function test_sharePrice_example2_twoToOne() public { - vm.startPrank(user); - token.mint(); - uint256 depositAmount = token.balanceOf(user); - token.approve(address(vault), depositAmount); - vault.deposit(depositAmount, user); - vm.stopPrank(); - - // Simulate vault growth: double the assets - vm.prank(address(vault)); - token.mint(); - - // Now vault has 2x assets compared to shares - uint256 price = vault.sharePrice(); - assertEq(price, 2e18, "Share price should be 2e18 when assets are 2x shares"); - } - - /// @dev example 3. sharePrice = 0.1 * 1e18 means we need to pay 0.1 asset to get 1 share regardless of the decimals - function test_sharePrice_example3_oneToTen() public { - // This scenario would require shares > assets, which happens in loss scenarios - // We'll simulate by having 1000 shares but only 100 assets - vm.startPrank(user); - token.mint(); - uint256 depositAmount = 1000; - token.approve(address(vault), depositAmount); - vault.deposit(depositAmount, user); - vm.stopPrank(); - - // simulate vault loss: transfer out 90% of assets - vm.prank(address(vault)); - token.transfer(user, 900); - - // vault has 100 assets but 1000 shares - uint256 price = vault.sharePrice(); - assertEq(price, 0.1e18, "Share price should be 0.1e18 when assets are 1/10 of shares"); - } - - /// @dev example 4. vault holds 99 USDC and 100 shares => sharePrice = 99 * 1e18 / 100 - function test_sharePrice_example4_ninetyNineToHundred() public { - vm.startPrank(user); - token.mint(); - uint256 depositAmount = 100; - token.approve(address(vault), depositAmount); - vault.deposit(depositAmount, user); - vm.stopPrank(); - - // simulate vault loss: transfer out 1 unit - vm.prank(address(vault)); - token.transfer(user, 1); - - // vault has 99 assets and 100 shares - uint256 price = vault.sharePrice(); - uint256 expectedPrice = (99 * 1e18) / 100; - assertEq(price, expectedPrice, "Share price should be 99/100 * 1e18"); - assertEq(price, 0.99e18, "Share price should be 0.99e18"); - } - - function test_sharePrice_zeroSupply() public { - uint256 price = vault.sharePrice(); - assertEq(price, 1e18, "Share price should default to 1e18 when supply is zero"); - } -} diff --git a/test-foundry/libraries/vault/MasterVaultSharePriceNoFee.t.sol b/test-foundry/libraries/vault/MasterVaultSharePriceNoFee.t.sol new file mode 100644 index 000000000..343ed9067 --- /dev/null +++ b/test-foundry/libraries/vault/MasterVaultSharePriceNoFee.t.sol @@ -0,0 +1,605 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import { MasterVaultCoreTest } from "./MasterVaultCore.t.sol"; +import { MasterVault } from "../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; + +contract MasterVaultSharePriceNoFeeTest is MasterVaultCoreTest { + /// @dev example 1. sharePrice = 1e18 means we need to pay 1 asset to get 1 share + function test_sharePrice_example1_oneToOne() public { + vm.startPrank(user); + token.mint(); + uint256 depositAmount = 100; + token.approve(address(vault), depositAmount); + vault.deposit(depositAmount, user); + vm.stopPrank(); + + uint256 price = vault.sharePrice(); + assertEq(price, 1e18, "Share price should be 1e18 for 1:1 ratio"); + } + + /// @dev example 2. sharePrice = 2 * 1e18 means we need to pay 2 asset to get 1 share + function test_sharePrice_example2_twoToOne() public { + vm.startPrank(user); + token.mint(); + uint256 depositAmount = token.balanceOf(user); + token.approve(address(vault), depositAmount); + vault.deposit(depositAmount, user); + vm.stopPrank(); + + // Simulate vault growth: double the assets + vm.prank(address(vault)); + token.mint(); + + // Now vault has 2x assets compared to shares + uint256 price = vault.sharePrice(); + assertEq(price, 2e18, "Share price should be 2e18 when assets are 2x shares"); + } + + /// @dev example 3. sharePrice = 0.1 * 1e18 means we need to pay 0.1 asset to get 1 share + function test_sharePrice_example3_oneToTen() public { + // This scenario would require shares > assets, which happens in loss scenarios + // We'll simulate by having 1000 shares but only 100 assets + vm.startPrank(user); + token.mint(); + uint256 depositAmount = 1000; + token.approve(address(vault), depositAmount); + vault.deposit(depositAmount, user); + vm.stopPrank(); + + // simulate vault loss: transfer out 90% of assets + vm.prank(address(vault)); + token.transfer(user, 900); + + // vault has 100 assets but 1000 shares + uint256 price = vault.sharePrice(); + assertEq(price, 0.1e18, "Share price should be 0.1e18 when assets are 1/10 of shares"); + } + + /// @dev example 4. vault holds 99 USDC and 100 shares => sharePrice = 99 * 1e18 / 100 + function test_sharePrice_example4_ninetyNineToHundred() public { + vm.startPrank(user); + token.mint(); + uint256 depositAmount = 100; + token.approve(address(vault), depositAmount); + vault.deposit(depositAmount, user); + vm.stopPrank(); + + // simulate vault loss: transfer out 1 unit + vm.prank(address(vault)); + token.transfer(user, 1); + + // vault has 99 assets and 100 shares + uint256 price = vault.sharePrice(); + uint256 expectedPrice = (99 * 1e18) / 100; + assertEq(price, expectedPrice, "Share price should be 99/100 * 1e18"); + assertEq(price, 0.99e18, "Share price should be 0.99e18"); + } + + function test_sharePrice_zeroSupply() public { + uint256 price = vault.sharePrice(); + assertEq(price, 1e18, "Share price should default to 1e18 when supply is zero"); + } + + // Tests for _convertToShares rounding scenarios + // Example 1: sharePrice = 1 * 1e18 & assets = 1; then output should be {Up: 1, Down: 1} + + function test_convertToShares_example1_deposit() public { + // Setup: sharePrice = 1e18 + vm.startPrank(user); + token.mint(); + token.approve(address(vault), type(uint256).max); + + // Deposit 1 asset should give 1 share (rounds down) + uint256 shares = vault.deposit(1, user); + assertEq(shares, 1, "Deposit with sharePrice=1e18 and assets=1 should give 1 share"); + + vm.stopPrank(); + } + + function test_convertToShares_example1_mint() public { + // Setup: sharePrice = 1e18 + vm.startPrank(user); + token.mint(); + token.approve(address(vault), type(uint256).max); + + // Mint 1 share should cost 1 asset (rounds down for user acquiring shares) + uint256 assets = vault.mint(1, user); + assertEq(assets, 1, "Mint with sharePrice=1e18 and shares=1 should cost 1 asset"); + + vm.stopPrank(); + } + + function test_convertToShares_example1_withdraw() public { + // Setup: deposit first + vm.startPrank(user); + token.mint(); + token.approve(address(vault), type(uint256).max); + vault.deposit(100, user); + + // Withdraw 1 asset should burn 1 share (rounds up) + uint256 sharesBurned = vault.withdraw(1, user, user); + assertEq(sharesBurned, 1, "Withdraw with sharePrice=1e18 and assets=1 should burn 1 share"); + + vm.stopPrank(); + } + + function test_convertToShares_example1_redeem() public { + // Setup: deposit first + vm.startPrank(user); + token.mint(); + token.approve(address(vault), type(uint256).max); + vault.deposit(100, user); + + // Redeem 1 share should give 1 asset + uint256 assetsReceived = vault.redeem(1, user, user); + assertEq(assetsReceived, 1, "Redeem with sharePrice=1e18 and shares=1 should give 1 asset"); + + vm.stopPrank(); + } + + // Example 2: sharePrice = 0.1 * 1e18 & assets = 1; then output should be {Up: 10, Down: 10} + + function test_convertToShares_example2_deposit() public { + // Setup: Create scenario where sharePrice = 0.1e18 + vm.startPrank(user); + token.mint(); + token.approve(address(vault), type(uint256).max); + vault.deposit(1000, user); // 1000 assets = 1000 shares + vm.stopPrank(); + + // Simulate loss: transfer out 90% of assets + vm.prank(address(vault)); + token.transfer(user, 900); + + // Now sharePrice = 0.1e18 + assertEq(vault.sharePrice(), 0.1e18, "Share price should be 0.1e18"); + + vm.startPrank(user); + token.approve(address(vault), 1); + + // Deposit 1 asset should give 10 shares (rounds down) + uint256 shares = vault.deposit(1, user); + assertEq(shares, 10, "Deposit with sharePrice=0.1e18 and assets=1 should give 10 shares"); + + vm.stopPrank(); + } + + function test_convertToShares_example2_mint() public { + // Setup: Create scenario where sharePrice = 0.1e18 + vm.startPrank(user); + token.mint(); + token.approve(address(vault), type(uint256).max); + vault.deposit(1000, user); + vm.stopPrank(); + + vm.prank(address(vault)); + token.transfer(user, 900); + + assertEq(vault.sharePrice(), 0.1e18, "Share price should be 0.1e18"); + + vm.startPrank(user); + token.approve(address(vault), 1); + + // Mint 10 shares should cost 1 asset + uint256 assets = vault.mint(10, user); + assertEq(assets, 1, "Mint with sharePrice=0.1e18 and shares=10 should cost 1 asset"); + + vm.stopPrank(); + } + + function test_convertToShares_example2_withdraw() public { + // Setup + vm.startPrank(user); + token.mint(); + token.approve(address(vault), type(uint256).max); + vault.deposit(1000, user); + vm.stopPrank(); + + vm.prank(address(vault)); + token.transfer(user, 900); + + assertEq(vault.sharePrice(), 0.1e18, "Share price should be 0.1e18"); + + vm.startPrank(user); + + // Withdraw 1 asset should burn 10 shares (rounds up) + uint256 sharesBurned = vault.withdraw(1, user, user); + assertEq( + sharesBurned, + 10, + "Withdraw with sharePrice=0.1e18 and assets=1 should burn 10 shares" + ); + + vm.stopPrank(); + } + + function test_convertToShares_example2_redeem() public { + // Setup + vm.startPrank(user); + token.mint(); + token.approve(address(vault), type(uint256).max); + vault.deposit(1000, user); + vm.stopPrank(); + + vm.prank(address(vault)); + token.transfer(user, 900); + + assertEq(vault.sharePrice(), 0.1e18, "Share price should be 0.1e18"); + + vm.startPrank(user); + + // Redeem 10 shares should give 1 asset + uint256 assetsReceived = vault.redeem(10, user, user); + assertEq( + assetsReceived, + 1, + "Redeem with sharePrice=0.1e18 and shares=10 should give 1 asset" + ); + + vm.stopPrank(); + } + + // Example 3: sharePrice = 10 * 1e18 & assets = 1; then output should be {Up: 1, Down: 0} + + function test_convertToShares_example3_deposit() public { + // Setup: Create scenario where sharePrice = 10e18 + vm.startPrank(user); + token.mint(); + uint256 initialDeposit = 100; + token.approve(address(vault), type(uint256).max); + vault.deposit(initialDeposit, user); + vm.stopPrank(); + + // Simulate vault growth: multiply assets by 10 + vm.prank(address(vault)); + token.mint(initialDeposit * 9); + + // Now sharePrice = 10e18 + assertEq(vault.sharePrice(), 10e18, "Share price should be 10e18"); + + vm.startPrank(user); + token.approve(address(vault), type(uint256).max); + + // Deposit 1 asset should give 0 shares (rounds down) + uint256 shares = vault.deposit(1, user); + assertEq(shares, 0, "Deposit with sharePrice=10e18 and assets=1 should give 0 shares"); + + vm.stopPrank(); + } + + function test_convertToShares_example3_mint() public { + // Setup: Create scenario where sharePrice = 10e18 + vm.startPrank(user); + token.mint(); + uint256 initialDeposit = 100; + token.approve(address(vault), type(uint256).max); + vault.deposit(initialDeposit, user); + vm.stopPrank(); + + // Simulate vault growth: multiply assets by 10 + vm.prank(address(vault)); + token.mint(initialDeposit * 9); + + assertEq(vault.sharePrice(), 10e18, "Share price should be 10e18"); + + vm.startPrank(user); + token.approve(address(vault), type(uint256).max); + + // Mint 1 share should cost 10 assets + uint256 assets = vault.mint(1, user); + assertEq(assets, 10, "Mint with sharePrice=10e18 and shares=1 should cost 10 assets"); + + vm.stopPrank(); + } + + function test_convertToShares_example3_withdraw() public { + // Setup + vm.startPrank(user); + token.mint(); + uint256 initialDeposit = token.balanceOf(user); + token.approve(address(vault), type(uint256).max); + vault.deposit(initialDeposit, user); + vm.stopPrank(); + + vm.startPrank(address(vault)); + for (uint i = 0; i < 9; i++) { + token.mint(); + } + vm.stopPrank(); + + assertEq(vault.sharePrice(), 10e18, "Share price should be 10e18"); + + vm.startPrank(user); + + // Withdraw 1 asset should burn 1 share (rounds up: 1/10 -> 1) + uint256 sharesBurned = vault.withdraw(1, user, user); + assertEq( + sharesBurned, + 1, + "Withdraw with sharePrice=10e18 and assets=1 should burn 1 share (rounds up)" + ); + + vm.stopPrank(); + } + + function test_convertToShares_example3_redeem() public { + // Setup + vm.startPrank(user); + token.mint(); + uint256 initialDeposit = token.balanceOf(user); + token.approve(address(vault), type(uint256).max); + vault.deposit(initialDeposit, user); + vm.stopPrank(); + + vm.startPrank(address(vault)); + for (uint i = 0; i < 9; i++) { + token.mint(); + } + vm.stopPrank(); + + assertEq(vault.sharePrice(), 10e18, "Share price should be 10e18"); + + vm.startPrank(user); + + // Redeem 1 share should give 10 assets + uint256 assetsReceived = vault.redeem(1, user, user); + assertEq( + assetsReceived, + 10, + "Redeem with sharePrice=10e18 and shares=1 should give 10 assets" + ); + + vm.stopPrank(); + } + + // Example 4: sharePrice = 100 * 1e18 & assets = 99; then output should be {Up: 1, Down: 0} + + function test_convertToShares_example4_deposit() public { + // Setup: Create scenario where sharePrice = 100e18 + vm.startPrank(user); + token.mint(); + uint256 initialDeposit = 1000; + token.approve(address(vault), type(uint256).max); + vault.deposit(initialDeposit, user); + vm.stopPrank(); + + // Simulate vault growth to reach sharePrice = 100e18 + // We need totalAssets = 100 * totalShares + uint256 currentAssets = vault.totalAssets(); + uint256 currentShares = vault.totalSupply(); + uint256 targetAssets = currentShares * 100; + uint256 assetsToAdd = targetAssets - currentAssets; + + vm.prank(address(vault)); + token.mint(assetsToAdd); + + assertEq(vault.sharePrice(), 100e18, "Share price should be 100e18"); + + vm.startPrank(user); + token.approve(address(vault), type(uint256).max); + + // Deposit 99 assets should give 0 shares (rounds down: 99/100 -> 0) + uint256 shares = vault.deposit(99, user); + assertEq(shares, 0, "Deposit with sharePrice=100e18 and assets=99 should give 0 shares"); + + vm.stopPrank(); + } + + function test_convertToShares_example4_mint() public { + // Setup + vm.startPrank(user); + token.mint(); + uint256 initialDeposit = 1000; + token.approve(address(vault), type(uint256).max); + vault.deposit(initialDeposit, user); + vm.stopPrank(); + + uint256 currentAssets = vault.totalAssets(); + uint256 currentShares = vault.totalSupply(); + uint256 targetAssets = currentShares * 100; + uint256 assetsToAdd = targetAssets - currentAssets; + + vm.prank(address(vault)); + token.mint(assetsToAdd); + + assertEq(vault.sharePrice(), 100e18, "Share price should be 100e18"); + + vm.startPrank(user); + token.approve(address(vault), type(uint256).max); + + // Mint 1 share should cost 100 assets + uint256 assets = vault.mint(1, user); + assertEq(assets, 100, "Mint with sharePrice=100e18 and shares=1 should cost 100 assets"); + + vm.stopPrank(); + } + + function test_convertToShares_example4_withdraw() public { + // Setup + vm.startPrank(user); + token.mint(); + uint256 initialDeposit = 1000; + token.approve(address(vault), type(uint256).max); + vault.deposit(initialDeposit, user); + vm.stopPrank(); + + uint256 currentAssets = vault.totalAssets(); + uint256 currentShares = vault.totalSupply(); + uint256 targetAssets = currentShares * 100; + uint256 assetsToAdd = targetAssets - currentAssets; + + vm.prank(address(vault)); + token.mint(assetsToAdd); + + assertEq(vault.sharePrice(), 100e18, "Share price should be 100e18"); + + vm.startPrank(user); + + // Withdraw 99 assets should burn 1 share (rounds up: 99/100 -> 1) + uint256 sharesBurned = vault.withdraw(99, user, user); + assertEq( + sharesBurned, + 1, + "Withdraw with sharePrice=100e18 and assets=99 should burn 1 share (rounds up)" + ); + + vm.stopPrank(); + } + + function test_convertToShares_example4_redeem() public { + // Setup + vm.startPrank(user); + token.mint(); + uint256 initialDeposit = 1000; + token.approve(address(vault), type(uint256).max); + vault.deposit(initialDeposit, user); + vm.stopPrank(); + + uint256 currentAssets = vault.totalAssets(); + uint256 currentShares = vault.totalSupply(); + uint256 targetAssets = currentShares * 100; + uint256 assetsToAdd = targetAssets - currentAssets; + + vm.prank(address(vault)); + token.mint(assetsToAdd); + + assertEq(vault.sharePrice(), 100e18, "Share price should be 100e18"); + + vm.startPrank(user); + + // Redeem 1 share should give 100 assets + uint256 assetsReceived = vault.redeem(1, user, user); + assertEq( + assetsReceived, + 100, + "Redeem with sharePrice=100e18 and shares=1 should give 100 assets" + ); + + vm.stopPrank(); + } + + // Example 5: sharePrice = 100 * 1e18 & assets = 199; then output should be {Up: 2, Down: 1} + + function test_convertToShares_example5_deposit() public { + // Setup: Create scenario where sharePrice = 100e18 + vm.startPrank(user); + token.mint(); + uint256 initialDeposit = 1000; + token.approve(address(vault), type(uint256).max); + vault.deposit(initialDeposit, user); + vm.stopPrank(); + + uint256 currentAssets = vault.totalAssets(); + uint256 currentShares = vault.totalSupply(); + uint256 targetAssets = currentShares * 100; + uint256 assetsToAdd = targetAssets - currentAssets; + + vm.prank(address(vault)); + token.mint(assetsToAdd); + + assertEq(vault.sharePrice(), 100e18, "Share price should be 100e18"); + + vm.startPrank(user); + token.approve(address(vault), type(uint256).max); + + // Deposit 199 assets should give 1 share (rounds down: 199/100 -> 1) + uint256 shares = vault.deposit(199, user); + assertEq(shares, 1, "Deposit with sharePrice=100e18 and assets=199 should give 1 share"); + + vm.stopPrank(); + } + + function test_convertToShares_example5_mint() public { + // Setup + vm.startPrank(user); + token.mint(); + uint256 initialDeposit = 1000; + token.approve(address(vault), type(uint256).max); + vault.deposit(initialDeposit, user); + vm.stopPrank(); + + uint256 currentAssets = vault.totalAssets(); + uint256 currentShares = vault.totalSupply(); + uint256 targetAssets = currentShares * 100; + uint256 assetsToAdd = targetAssets - currentAssets; + + vm.prank(address(vault)); + token.mint(assetsToAdd); + + assertEq(vault.sharePrice(), 100e18, "Share price should be 100e18"); + + vm.startPrank(user); + token.approve(address(vault), type(uint256).max); + + // Mint 2 shares should cost 200 assets + uint256 assets = vault.mint(2, user); + assertEq(assets, 200, "Mint with sharePrice=100e18 and shares=2 should cost 200 assets"); + + vm.stopPrank(); + } + + function test_convertToShares_example5_withdraw() public { + // Setup + vm.startPrank(user); + token.mint(); + uint256 initialDeposit = 1000; + token.approve(address(vault), type(uint256).max); + vault.deposit(initialDeposit, user); + vm.stopPrank(); + + uint256 currentAssets = vault.totalAssets(); + uint256 currentShares = vault.totalSupply(); + uint256 targetAssets = currentShares * 100; + uint256 assetsToAdd = targetAssets - currentAssets; + + vm.prank(address(vault)); + token.mint(assetsToAdd); + + assertEq(vault.sharePrice(), 100e18, "Share price should be 100e18"); + + vm.startPrank(user); + + // Withdraw 199 assets should burn 2 shares (rounds up: 199/100 -> 2) + uint256 sharesBurned = vault.withdraw(199, user, user); + assertEq( + sharesBurned, + 2, + "Withdraw with sharePrice=100e18 and assets=199 should burn 2 shares (rounds up)" + ); + + vm.stopPrank(); + } + + function test_convertToShares_example5_redeem() public { + // Setup + vm.startPrank(user); + token.mint(); + uint256 initialDeposit = 1000; + token.approve(address(vault), type(uint256).max); + vault.deposit(initialDeposit, user); + vm.stopPrank(); + + uint256 currentAssets = vault.totalAssets(); + uint256 currentShares = vault.totalSupply(); + uint256 targetAssets = currentShares * 100; + uint256 assetsToAdd = targetAssets - currentAssets; + + vm.prank(address(vault)); + token.mint(assetsToAdd); + + assertEq(vault.sharePrice(), 100e18, "Share price should be 100e18"); + + vm.startPrank(user); + + // Redeem 2 shares should give 200 assets + uint256 assetsReceived = vault.redeem(2, user, user); + assertEq( + assetsReceived, + 200, + "Redeem with sharePrice=100e18 and shares=2 should give 200 assets" + ); + + vm.stopPrank(); + } +} diff --git a/test-foundry/libraries/vault/MasterVaultSharePriceWithFee.t.sol b/test-foundry/libraries/vault/MasterVaultSharePriceWithFee.t.sol new file mode 100644 index 000000000..b50413c8f --- /dev/null +++ b/test-foundry/libraries/vault/MasterVaultSharePriceWithFee.t.sol @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import { MasterVaultCoreTest } from "./MasterVaultCore.t.sol"; +import { MasterVault } from "../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; + +contract MasterVaultSharePriceWithFeeTest is MasterVaultCoreTest { + function setUp() public override { + super.setUp(); + // Enable performance fee for all tests in this file + vault.setPerformanceFee(true); + } + + /// @dev When performance fee is enabled, sharePrice is capped at 1e18 + function test_sharePrice_cappedAt1e18_whenProfitable() public { + vm.startPrank(user); + token.mint(); + uint256 depositAmount = token.balanceOf(user); + token.approve(address(vault), depositAmount); + vault.deposit(depositAmount, user); + vm.stopPrank(); + + // Simulate vault growth: double the assets + vm.prank(address(vault)); + token.mint(); + + // With performance fee enabled, sharePrice should be capped at 1e18 even though actual ratio is 2:1 + uint256 price = vault.sharePrice(); + assertEq(price, 1e18, "Share price should be capped at 1e18 when performance fee is enabled"); + } + + /// @dev When vault has losses, sharePrice can be below 1e18 even with performance fee + function test_sharePrice_belowCap_whenLosses() public { + vm.startPrank(user); + token.mint(); + uint256 depositAmount = 1000; + token.approve(address(vault), depositAmount); + vault.deposit(depositAmount, user); + vm.stopPrank(); + + // Simulate vault loss: transfer out 10% + vm.prank(address(vault)); + token.transfer(user, 100); + + // sharePrice should be 0.9e18 (900/1000) + uint256 price = vault.sharePrice(); + assertEq(price, 0.9e18, "Share price should reflect losses even with performance fee"); + } + + /// @dev Test deposit behavior when performance fee is enabled and vault is profitable + function test_deposit_withProfitableVault() public { + // Initial deposit + vm.startPrank(user); + token.mint(); + uint256 initialDeposit = token.balanceOf(user); + token.approve(address(vault), type(uint256).max); + vault.deposit(initialDeposit, user); + vm.stopPrank(); + + // Vault gains profit + vm.prank(address(vault)); + token.mint(); + + // Now sharePrice is capped at 1e18 + assertEq(vault.sharePrice(), 1e18, "Share price should be 1e18"); + + // New user deposits + vm.startPrank(address(0x2)); + token.mint(1000); + token.approve(address(vault), 1000); + + // With sharePrice = 1e18, depositing 1000 assets should give 1000 shares + uint256 shares = vault.deposit(1000, address(0x2)); + assertEq(shares, 1000, "Should receive 1000 shares for 1000 assets at 1e18 sharePrice"); + vm.stopPrank(); + } + + /// @dev Test redeem behavior when performance fee is enabled and vault is profitable + function test_redeem_withProfitableVault() public { + // Initial deposit + vm.startPrank(user); + token.mint(); + uint256 initialDeposit = token.balanceOf(user); + token.approve(address(vault), type(uint256).max); + uint256 shares = vault.deposit(initialDeposit, user); + vm.stopPrank(); + + // Vault gains profit (doubles) + vm.prank(address(vault)); + token.mint(); + + // User redeems shares - should only get back principal (due to _effectiveAssets) + vm.prank(user); + uint256 assetsReceived = vault.redeem(shares, user, user); + + // With performance fee, user should only get their principal back, not the profits + assertEq(assetsReceived, initialDeposit, "User should only receive principal, not profits"); + } + + /// @dev Test withdraw behavior when performance fee is enabled and vault is profitable + function test_withdraw_withProfitableVault() public { + // Initial deposit + vm.startPrank(user); + token.mint(); + uint256 initialDeposit = token.balanceOf(user); + token.approve(address(vault), type(uint256).max); + vault.deposit(initialDeposit, user); + vm.stopPrank(); + + // Vault gains profit (doubles) + vm.prank(address(vault)); + token.mint(); + + // User tries to withdraw all their principal + vm.prank(user); + uint256 sharesBurned = vault.withdraw(initialDeposit, user, user); + + // Should burn all shares to get principal back + assertGt(sharesBurned, 0, "Should burn shares to withdraw principal"); + } + + /// @dev Test that users socialize losses when performance fee is enabled + function test_socializeLosses_withPerformanceFee() public { + // Two users deposit equal amounts + vm.startPrank(user); + token.mint(); + uint256 deposit1 = 1000; + token.approve(address(vault), deposit1); + uint256 shares1 = vault.deposit(deposit1, user); + vm.stopPrank(); + + vm.startPrank(address(0x2)); + token.mint(1000); + token.approve(address(vault), 1000); + uint256 shares2 = vault.deposit(1000, address(0x2)); + vm.stopPrank(); + + // Vault loses 50% of assets + vm.prank(address(vault)); + token.transfer(address(0x999), 1000); + + // Both users should be able to redeem proportionally + vm.prank(user); + uint256 assets1 = vault.redeem(shares1, user, user); + + vm.prank(address(0x2)); + uint256 assets2 = vault.redeem(shares2, address(0x2), address(0x2)); + + // Each should get 500 (50% of their original 1000) + assertEq(assets1, 500, "User 1 should get 500 assets (50% loss)"); + assertEq(assets2, 500, "User 2 should get 500 assets (50% loss)"); + } + + /// @dev Test that users DON'T socialize profits when performance fee is enabled + function test_noSocializeProfits_withPerformanceFee() public { + // User 1 deposits + vm.startPrank(user); + token.mint(); + uint256 deposit1 = 1000; + token.approve(address(vault), deposit1); + uint256 shares1 = vault.deposit(deposit1, user); + vm.stopPrank(); + + // Vault gains profit (doubles) + vm.prank(address(vault)); + token.mint(1000); + + // User 2 deposits same amount + vm.startPrank(address(0x2)); + token.mint(1000); + token.approve(address(vault), 1000); + uint256 shares2 = vault.deposit(1000, address(0x2)); + vm.stopPrank(); + + // User 1 redeems - should only get their principal back (1000) + vm.prank(user); + uint256 assets1 = vault.redeem(shares1, user, user); + assertEq(assets1, deposit1, "User 1 should only get their principal, not share in profits"); + + // User 2 redeems - should also get their principal back (1000) + vm.prank(address(0x2)); + uint256 assets2 = vault.redeem(shares2, address(0x2), address(0x2)); + assertEq(assets2, 1000, "User 2 should get their principal"); + } + + /// @dev Test sharePrice = 1e18 scenario with performance fee + function test_convertToShares_perfFee_example1() public { + vm.startPrank(user); + token.mint(); + token.approve(address(vault), type(uint256).max); + + // Deposit 1 asset should give 1 share + uint256 shares = vault.deposit(1, user); + assertEq(shares, 1, "Should receive 1 share for 1 asset"); + + vm.stopPrank(); + } + + /// @dev Test with vault losses and performance fee + function test_convertToShares_perfFee_withLosses() public { + vm.startPrank(user); + token.mint(); + token.approve(address(vault), type(uint256).max); + vault.deposit(1000, user); + vm.stopPrank(); + + // Simulate 50% loss + vm.prank(address(vault)); + token.transfer(user, 500); + + // sharePrice = 0.5e18 + assertEq(vault.sharePrice(), 0.5e18, "Share price should be 0.5e18"); + + vm.startPrank(user); + token.approve(address(vault), 1); + + // Deposit 1 asset at 0.5e18 sharePrice should give 2 shares + uint256 shares = vault.deposit(1, user); + assertEq(shares, 2, "Should receive 2 shares for 1 asset at 0.5e18 sharePrice"); + + vm.stopPrank(); + } +} From 07e567c6c63b73edd5d702d54977c9c0ad82e27b Mon Sep 17 00:00:00 2001 From: Wael Almattar Date: Sat, 29 Nov 2025 22:21:42 +0100 Subject: [PATCH 20/30] tests: add MasterVault test scenarios --- .../scenarios/MasterVaultScenario01.t.sol | 70 +++++++++ .../scenarios/MasterVaultScenario02.t.sol | 84 +++++++++++ .../scenarios/MasterVaultScenario03.t.sol | 92 ++++++++++++ .../scenarios/MasterVaultScenario04.t.sol | 114 +++++++++++++++ .../scenarios/MasterVaultScenario05.t.sol | 138 ++++++++++++++++++ .../scenarios/MasterVaultScenario06.t.sol | 102 +++++++++++++ .../scenarios/MasterVaultScenario07.t.sol | 116 +++++++++++++++ .../scenarios/MasterVaultScenario08.t.sol | 114 +++++++++++++++ 8 files changed, 830 insertions(+) create mode 100644 test-foundry/libraries/vault/scenarios/MasterVaultScenario01.t.sol create mode 100644 test-foundry/libraries/vault/scenarios/MasterVaultScenario02.t.sol create mode 100644 test-foundry/libraries/vault/scenarios/MasterVaultScenario03.t.sol create mode 100644 test-foundry/libraries/vault/scenarios/MasterVaultScenario04.t.sol create mode 100644 test-foundry/libraries/vault/scenarios/MasterVaultScenario05.t.sol create mode 100644 test-foundry/libraries/vault/scenarios/MasterVaultScenario06.t.sol create mode 100644 test-foundry/libraries/vault/scenarios/MasterVaultScenario07.t.sol create mode 100644 test-foundry/libraries/vault/scenarios/MasterVaultScenario08.t.sol diff --git a/test-foundry/libraries/vault/scenarios/MasterVaultScenario01.t.sol b/test-foundry/libraries/vault/scenarios/MasterVaultScenario01.t.sol new file mode 100644 index 000000000..ceab7a263 --- /dev/null +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario01.t.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import { MasterVaultCoreTest } from "../MasterVaultCore.t.sol"; +import { MasterVault } from "../../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; + +contract MasterVaultScenario01Test is MasterVaultCoreTest { + address public userA = address(0xA); + address public userB = address(0xB); + address public beneficiaryAddress = address(0x9999); + + function setUp() public override { + super.setUp(); + // Enable performance fee for this scenario + vault.setPerformanceFee(true); + vault.setBeneficiary(beneficiaryAddress); + } + + /// @dev Scenario: 2 users deposit and redeem with no profit/loss + /// User A deposits 100 USDC, User B deposits 300 USDC + /// User A redeems 100 shares, User B redeems 300 shares + /// Expected: All state variables return to 0, no user gains/losses + function test_scenario01_noGainNoLoss() public { + // Setup: Mint tokens for users + vm.prank(userA); + token.mint(100); + vm.prank(userB); + token.mint(300); + + uint256 userAInitialBalance = token.balanceOf(userA); + uint256 userBInitialBalance = token.balanceOf(userB); + + // Step 1: User A deposits 100 USDC + vm.startPrank(userA); + token.approve(address(vault), 100); + uint256 sharesA = vault.deposit(100, userA); + vm.stopPrank(); + + // Step 2: User B deposits 300 USDC + vm.startPrank(userB); + token.approve(address(vault), 300); + uint256 sharesB = vault.deposit(300, userB); + vm.stopPrank(); + + // Verify intermediate state + assertEq(vault.totalPrincipal(), 400, "Total principal should be 400"); + assertEq(vault.totalAssets(), 400, "Total assets should be 400"); + + // Step 3: User A redeems 100 shares + vm.prank(userA); + vault.redeem(sharesA, userA, userA); + + // Step 4: User B redeems 300 shares + vm.prank(userB); + vault.redeem(sharesB, userB, userB); + + // Verify final state + assertEq(vault.totalPrincipal(), 0, "Total principal should be 0"); + assertEq(vault.totalAssets(), 0, "Vault assets should be 0"); + assertEq(vault.totalSupply(), 0, "Total supply should be 0"); + assertEq(vault.sharePrice(), 1e18, "Share price should be 1e18"); + + // Verify user balances (no change) + assertEq(token.balanceOf(userA), userAInitialBalance, "User A should have no gain/loss"); + assertEq(token.balanceOf(userB), userBInitialBalance, "User B should have no gain/loss"); + + // Verify beneficiary received nothing + assertEq(token.balanceOf(beneficiaryAddress), 0, "Beneficiary should have 0 (nothing claimed)"); + } +} diff --git a/test-foundry/libraries/vault/scenarios/MasterVaultScenario02.t.sol b/test-foundry/libraries/vault/scenarios/MasterVaultScenario02.t.sol new file mode 100644 index 000000000..2bf27248c --- /dev/null +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario02.t.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import { MasterVaultCoreTest } from "../MasterVaultCore.t.sol"; +import { MasterVault } from "../../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; + +contract MasterVaultScenario02Test is MasterVaultCoreTest { + address public userA = address(0xA); + address public userB = address(0xB); + address public beneficiaryAddress = address(0x9999); + + function setUp() public override { + super.setUp(); + // Enable performance fee for this scenario + vault.setPerformanceFee(true); + vault.setBeneficiary(beneficiaryAddress); + } + + /// @dev Scenario: 2 users deposit, vault loses 100 USDC, users socialize losses + /// User A deposits 100 USDC, User B deposits 300 USDC + /// Vault loses 100 USDC (25% loss) + /// User A redeems 100 shares, User B redeems 300 shares + /// Expected: Users socialize the loss proportionally (25% each) + function test_scenario02_socializeLosses() public { + // Setup: Mint tokens for users + vm.prank(userA); + token.mint(100); + vm.prank(userB); + token.mint(300); + + uint256 userAInitialBalance = token.balanceOf(userA); + uint256 userBInitialBalance = token.balanceOf(userB); + + // Step 1: User A deposits 100 USDC + vm.startPrank(userA); + token.approve(address(vault), 100); + uint256 sharesA = vault.deposit(100, userA); + vm.stopPrank(); + + // Step 2: User B deposits 300 USDC + vm.startPrank(userB); + token.approve(address(vault), 300); + uint256 sharesB = vault.deposit(300, userB); + vm.stopPrank(); + + // Verify intermediate state + assertEq(vault.totalPrincipal(), 400, "Total principal should be 400"); + assertEq(vault.totalAssets(), 400, "Total assets should be 400"); + assertEq(sharesA, 100, "User A should have 100 shares"); + assertEq(sharesB, 300, "User B should have 300 shares"); + + // Step 3: Vault loses 100 USDC (25% loss) + vm.prank(address(vault)); + token.transfer(address(0xdead), 100); + + assertEq(vault.totalAssets(), 300, "Vault should have 300 USDC after loss"); + assertEq(vault.totalPrincipal(), 400, "Total principal should remain 400"); + + // Step 4: User A redeems 100 shares + vm.prank(userA); + uint256 assetsReceivedA = vault.redeem(sharesA, userA, userA); + + // Step 5: User B redeems 300 shares + vm.prank(userB); + uint256 assetsReceivedB = vault.redeem(sharesB, userB, userB); + + // Verify final state + assertEq(vault.totalPrincipal(), 100, "Total principal should be 100"); + assertEq(vault.totalAssets(), 0, "Vault assets should be 0"); + assertEq(vault.totalSupply(), 0, "Total supply should be 0"); + assertEq(vault.sharePrice(), 1e18, "Share price should be 1e18"); + + // Verify user holdings change + assertEq(token.balanceOf(userA), userAInitialBalance - 25, "User A should lose 25 USDC (25% of 100)"); + assertEq(token.balanceOf(userB), userBInitialBalance - 75, "User B should lose 75 USDC (25% of 300)"); + + // Verify assets received + assertEq(assetsReceivedA, 75, "User A should receive 75 USDC (100 - 25)"); + assertEq(assetsReceivedB, 225, "User B should receive 225 USDC (300 - 75)"); + + // Verify beneficiary received nothing + assertEq(token.balanceOf(beneficiaryAddress), 0, "Beneficiary should have 0 (nothing claimed)"); + } +} diff --git a/test-foundry/libraries/vault/scenarios/MasterVaultScenario03.t.sol b/test-foundry/libraries/vault/scenarios/MasterVaultScenario03.t.sol new file mode 100644 index 000000000..b79a7f361 --- /dev/null +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario03.t.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import { MasterVaultCoreTest } from "../MasterVaultCore.t.sol"; +import { MasterVault } from "../../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; + +contract MasterVaultScenario03Test is MasterVaultCoreTest { + address public userA = address(0xA); + address public userB = address(0xB); + address public beneficiaryAddress = address(0x9999); + + function setUp() public override { + super.setUp(); + // Enable performance fee for this scenario + vault.setPerformanceFee(true); + vault.setBeneficiary(beneficiaryAddress); + } + + /// @dev Scenario: 2 users deposit, vault gains 100 USDC, beneficiary claims fees, users don't share profits + /// User A deposits 100 USDC, User B deposits 300 USDC + /// Vault gains 100 USDC (25% profit) + /// Beneficiary claims fees + /// User A redeems 100 shares, User B redeems 300 shares + /// Expected: Beneficiary gets all profits, users only get principal back + function test_scenario03_profitToBeneficiary() public { + // Setup: Mint tokens for users + vm.prank(userA); + token.mint(100); + vm.prank(userB); + token.mint(300); + + uint256 userAInitialBalance = token.balanceOf(userA); + uint256 userBInitialBalance = token.balanceOf(userB); + + // Step 1: User A deposits 100 USDC + vm.startPrank(userA); + token.approve(address(vault), 100); + uint256 sharesA = vault.deposit(100, userA); + vm.stopPrank(); + + // Step 2: User B deposits 300 USDC + vm.startPrank(userB); + token.approve(address(vault), 300); + uint256 sharesB = vault.deposit(300, userB); + vm.stopPrank(); + + // Verify intermediate state + assertEq(vault.totalPrincipal(), 400, "Total principal should be 400"); + assertEq(vault.totalAssets(), 400, "Total assets should be 400"); + assertEq(sharesA, 100, "User A should have 100 shares"); + assertEq(sharesB, 300, "User B should have 300 shares"); + + // Step 3: Vault wins 100 USDC (25% profit) + vm.prank(address(vault)); + token.mint(100); + + assertEq(vault.totalAssets(), 500, "Vault should have 500 USDC after profit"); + assertEq(vault.totalPrincipal(), 400, "Total principal should remain 400"); + assertEq(vault.totalProfit(), 100, "Total profit should be 100 USDC"); + + // Step 4: Claim fees + vault.withdrawPerformanceFees(); + + assertEq(vault.totalAssets(), 400, "Vault should have 400 USDC after fee withdrawal"); + assertEq(token.balanceOf(beneficiaryAddress), 100, "Beneficiary should have 100 USDC"); + + // Step 5: User A redeems 100 shares + vm.prank(userA); + uint256 assetsReceivedA = vault.redeem(sharesA, userA, userA); + + // Step 6: User B redeems 300 shares + vm.prank(userB); + uint256 assetsReceivedB = vault.redeem(sharesB, userB, userB); + + // Verify final state + assertEq(vault.totalPrincipal(), 0, "Total principal should be 0"); + assertEq(vault.totalAssets(), 0, "Vault assets should be 0"); + assertEq(vault.totalSupply(), 0, "Total supply should be 0"); + assertEq(vault.sharePrice(), 1e18, "Share price should be 1e18"); + + // Verify user holdings change (no change - they only get principal back) + assertEq(token.balanceOf(userA), userAInitialBalance, "User A should have no gain/loss"); + assertEq(token.balanceOf(userB), userBInitialBalance, "User B should have no gain/loss"); + + // Verify assets received + assertEq(assetsReceivedA, 100, "User A should receive 100 USDC (their principal)"); + assertEq(assetsReceivedB, 300, "User B should receive 300 USDC (their principal)"); + + // Verify beneficiary received all profits + assertEq(token.balanceOf(beneficiaryAddress), 100, "Beneficiary should have 100 USDC (all profits)"); + } +} diff --git a/test-foundry/libraries/vault/scenarios/MasterVaultScenario04.t.sol b/test-foundry/libraries/vault/scenarios/MasterVaultScenario04.t.sol new file mode 100644 index 000000000..d89e21051 --- /dev/null +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario04.t.sol @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import { MasterVaultCoreTest } from "../MasterVaultCore.t.sol"; +import { MasterVault } from "../../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; + +contract MasterVaultScenario04Test is MasterVaultCoreTest { + address public userA = address(0xA); + address public userB = address(0xB); + address public beneficiaryAddress = address(0x9999); + + function setUp() public override { + super.setUp(); + // Enable performance fee for this scenario + vault.setPerformanceFee(true); + vault.setBeneficiary(beneficiaryAddress); + } + + /// @dev Scenario: 2 users deposit, vault gains profit, fees claimed, then users deposit again + /// User A deposits 100 USDC, User B deposits 300 USDC + /// Vault gains 100 USDC profit + /// Beneficiary claims fees (100 USDC) + /// User A deposits another 100 USDC, User B deposits another 300 USDC + /// User A redeems all 200 shares, User B redeems all 600 shares + /// Expected: Beneficiary keeps profits, users get all deposits back + function test_scenario04_secondDepositAfterFeeClaim() public { + // Setup: Mint tokens for users (800 total: 200 for A, 600 for B) + vm.prank(userA); + token.mint(200); + vm.prank(userB); + token.mint(600); + + uint256 userAInitialBalance = token.balanceOf(userA); + uint256 userBInitialBalance = token.balanceOf(userB); + + // Step 1: User A deposits 100 USDC + vm.startPrank(userA); + token.approve(address(vault), 100); + uint256 sharesA1 = vault.deposit(100, userA); + vm.stopPrank(); + + // Step 2: User B deposits 300 USDC + vm.startPrank(userB); + token.approve(address(vault), 300); + uint256 sharesB1 = vault.deposit(300, userB); + vm.stopPrank(); + + assertEq(vault.totalPrincipal(), 400, "Total principal should be 400"); + assertEq(vault.totalAssets(), 400, "Total assets should be 400"); + assertEq(sharesA1, 100, "User A should have 100 shares"); + assertEq(sharesB1, 300, "User B should have 300 shares"); + + // Step 3: Vault wins 100 USDC (25% profit) + vm.prank(address(vault)); + token.mint(100); + + assertEq(vault.totalAssets(), 500, "Vault should have 500 USDC after profit"); + assertEq(vault.totalProfit(), 100, "Total profit should be 100 USDC"); + + // Step 4: Claim fees + vault.withdrawPerformanceFees(); + + assertEq(vault.totalAssets(), 400, "Vault should have 400 USDC after fee withdrawal"); + assertEq(token.balanceOf(beneficiaryAddress), 100, "Beneficiary should have 100 USDC"); + assertEq(vault.totalPrincipal(), 400, "Total principal should still be 400"); + + // Step 5: User A deposits another 100 USDC + vm.startPrank(userA); + token.approve(address(vault), 100); + uint256 sharesA2 = vault.deposit(100, userA); + vm.stopPrank(); + + // Step 6: User B deposits another 300 USDC + vm.startPrank(userB); + token.approve(address(vault), 300); + uint256 sharesB2 = vault.deposit(300, userB); + vm.stopPrank(); + + assertEq(vault.totalPrincipal(), 800, "Total principal should be 800"); + assertEq(vault.totalAssets(), 800, "Total assets should be 800"); + assertEq(sharesA2, 100, "User A second deposit should give 100 shares"); + assertEq(sharesB2, 300, "User B second deposit should give 300 shares"); + + uint256 totalSharesA = sharesA1 + sharesA2; + uint256 totalSharesB = sharesB1 + sharesB2; + assertEq(totalSharesA, 200, "User A should have 200 total shares"); + assertEq(totalSharesB, 600, "User B should have 600 total shares"); + + // Step 7: User A redeems 200 shares + vm.prank(userA); + uint256 assetsReceivedA = vault.redeem(totalSharesA, userA, userA); + + // Step 8: User B redeems 600 shares + vm.prank(userB); + uint256 assetsReceivedB = vault.redeem(totalSharesB, userB, userB); + + // Verify final state + assertEq(vault.totalPrincipal(), 0, "Total principal should be 0"); + assertEq(vault.totalAssets(), 0, "Vault assets should be 0"); + assertEq(vault.totalSupply(), 0, "Total supply should be 0"); + assertEq(vault.sharePrice(), 1e18, "Share price should be 1e18"); + + // Verify user holdings change (no change - they get all deposits back) + assertEq(token.balanceOf(userA), userAInitialBalance, "User A should have no gain/loss"); + assertEq(token.balanceOf(userB), userBInitialBalance, "User B should have no gain/loss"); + + // Verify assets received + assertEq(assetsReceivedA, 200, "User A should receive 200 USDC (all deposits)"); + assertEq(assetsReceivedB, 600, "User B should receive 600 USDC (all deposits)"); + + // Verify beneficiary still has all profits + assertEq(token.balanceOf(beneficiaryAddress), 100, "Beneficiary should have 100 USDC (all profits)"); + } +} diff --git a/test-foundry/libraries/vault/scenarios/MasterVaultScenario05.t.sol b/test-foundry/libraries/vault/scenarios/MasterVaultScenario05.t.sol new file mode 100644 index 000000000..2c57aca3e --- /dev/null +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario05.t.sol @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import { MasterVaultCoreTest } from "../MasterVaultCore.t.sol"; +import { MasterVault } from "../../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; + +contract MasterVaultScenario05Test is MasterVaultCoreTest { + address public userA = address(0xA); + address public userB = address(0xB); + address public beneficiaryAddress = address(0x9999); + + function setUp() public override { + super.setUp(); + // Enable performance fee for this scenario + vault.setPerformanceFee(true); + vault.setBeneficiary(beneficiaryAddress); + } + + /// @dev Scenario: Complex scenario with profits, fees, second deposit, full redemption, third deposit, and losses + /// Steps 1-4: Initial deposits, profit, fee claim + /// Steps 5-8: Second deposits and full redemption + /// Steps 9-13: Third deposits, losses, and final redemption + /// Expected: Beneficiary keeps profits, users share losses proportionally + function test_scenario05_profitThenLoss() public { + // Setup: Mint tokens for users (300 total each: 100+100+100 for A, 300+300+300 for B) + vm.prank(userA); + token.mint(300); + vm.prank(userB); + token.mint(900); + + uint256 userAInitialBalance = token.balanceOf(userA); + uint256 userBInitialBalance = token.balanceOf(userB); + + // Step 1: User A deposits 100 USDC + vm.startPrank(userA); + token.approve(address(vault), 100); + vault.deposit(100, userA); + vm.stopPrank(); + + // Step 2: User B deposits 300 USDC + vm.startPrank(userB); + token.approve(address(vault), 300); + vault.deposit(300, userB); + vm.stopPrank(); + + assertEq(vault.totalPrincipal(), 400, "Total principal should be 400 after first deposits"); + assertEq(vault.totalAssets(), 400, "Total assets should be 400 after first deposits"); + + // Step 3: Vault wins 100 USDC + vm.prank(address(vault)); + token.mint(100); + + assertEq(vault.totalAssets(), 500, "Vault should have 500 USDC after profit"); + assertEq(vault.totalProfit(), 100, "Total profit should be 100 USDC"); + + // Step 4: Claim fees + vault.withdrawPerformanceFees(); + + assertEq(vault.totalAssets(), 400, "Vault should have 400 USDC after fee withdrawal"); + assertEq(token.balanceOf(beneficiaryAddress), 100, "Beneficiary should have 100 USDC"); + + // Step 5: User A deposits another 100 USDC + vm.startPrank(userA); + token.approve(address(vault), 100); + vault.deposit(100, userA); + vm.stopPrank(); + + // Step 6: User B deposits another 300 USDC + vm.startPrank(userB); + token.approve(address(vault), 300); + vault.deposit(300, userB); + vm.stopPrank(); + + assertEq(vault.totalPrincipal(), 800, "Total principal should be 800 after second deposits"); + assertEq(vault.totalAssets(), 800, "Total assets should be 800 after second deposits"); + + // Step 7: User A redeems 200 shares + vm.prank(userA); + vault.redeem(200, userA, userA); + + // Step 8: User B redeems 600 shares + vm.prank(userB); + vault.redeem(600, userB, userB); + + assertEq(vault.totalPrincipal(), 0, "Total principal should be 0 after second redemptions"); + assertEq(vault.totalAssets(), 0, "Vault should be empty after second redemptions"); + assertEq(vault.totalSupply(), 0, "Total supply should be 0 after second redemptions"); + + // Step 9: User A deposits 100 USDC + vm.startPrank(userA); + token.approve(address(vault), 100); + vault.deposit(100, userA); + vm.stopPrank(); + + // Step 10: User B deposits 300 USDC + vm.startPrank(userB); + token.approve(address(vault), 300); + vault.deposit(300, userB); + vm.stopPrank(); + + assertEq(vault.totalPrincipal(), 400, "Total principal should be 400 after third deposits"); + assertEq(vault.totalAssets(), 400, "Total assets should be 400 after third deposits"); + + // Step 11: Vault loses 100 USDC (25% loss) + vm.prank(address(vault)); + token.transfer(address(0xdead), 100); + + assertEq(vault.totalAssets(), 300, "Vault should have 300 USDC after loss"); + assertEq(vault.totalPrincipal(), 400, "Total principal should still be 400"); + + // Step 12: User A redeems 100 shares + vm.prank(userA); + uint256 assetsReceivedA = vault.redeem(100, userA, userA); + + // Step 13: User B redeems 300 shares + vm.prank(userB); + uint256 assetsReceivedB = vault.redeem(300, userB, userB); + + // Verify final state + assertEq(vault.totalPrincipal(), 100, "Total principal should be 100"); + assertEq(vault.totalAssets(), 0, "Vault assets should be 0"); + assertEq(vault.totalSupply(), 0, "Total supply should be 0"); + assertEq(vault.sharePrice(), 1e18, "Share price should be 1e18"); + + // Verify user holdings change + // User A: deposited 300 total, received back 200 (from step 7) + 75 (from step 12) = 275, loss = 25 + // User B: deposited 900 total, received back 600 (from step 8) + 225 (from step 13) = 825, loss = 75 + assertEq(token.balanceOf(userA), userAInitialBalance - 25, "User A should lose 25 USDC"); + assertEq(token.balanceOf(userB), userBInitialBalance - 75, "User B should lose 75 USDC"); + + // Verify assets received in final redemption + assertEq(assetsReceivedA, 75, "User A should receive 75 USDC in final redemption"); + assertEq(assetsReceivedB, 225, "User B should receive 225 USDC in final redemption"); + + // Verify beneficiary still has profits + assertEq(token.balanceOf(beneficiaryAddress), 100, "Beneficiary should have 100 USDC (all profits)"); + } +} diff --git a/test-foundry/libraries/vault/scenarios/MasterVaultScenario06.t.sol b/test-foundry/libraries/vault/scenarios/MasterVaultScenario06.t.sol new file mode 100644 index 000000000..ecccbc172 --- /dev/null +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario06.t.sol @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import { MasterVaultCoreTest } from "../MasterVaultCore.t.sol"; +import { MasterVault } from "../../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; + +contract MasterVaultScenario06Test is MasterVaultCoreTest { + address public userA = address(0xA); + address public userB = address(0xB); + address public beneficiaryAddress = address(0x9999); + + function setUp() public override { + super.setUp(); + // Enable performance fee for this scenario + vault.setPerformanceFee(true); + vault.setBeneficiary(beneficiaryAddress); + } + + /// @dev Scenario: Deposits, profit, fee claim, then loss before redemption + /// User A deposits 100 USDC, User B deposits 300 USDC + /// Vault gains 100 USDC profit + /// Beneficiary claims fees (100 USDC) + /// Vault loses 100 USDC (25% of remaining principal) + /// Users redeem and share the loss proportionally + /// Expected: Beneficiary keeps profits, users share losses + function test_scenario06_profitClaimedThenLoss() public { + // Setup: Mint tokens for users + vm.prank(userA); + token.mint(100); + vm.prank(userB); + token.mint(300); + + uint256 userAInitialBalance = token.balanceOf(userA); + uint256 userBInitialBalance = token.balanceOf(userB); + + // Step 1: User A deposits 100 USDC + vm.startPrank(userA); + token.approve(address(vault), 100); + uint256 sharesA = vault.deposit(100, userA); + vm.stopPrank(); + + // Step 2: User B deposits 300 USDC + vm.startPrank(userB); + token.approve(address(vault), 300); + uint256 sharesB = vault.deposit(300, userB); + vm.stopPrank(); + + assertEq(vault.totalPrincipal(), 400, "Total principal should be 400"); + assertEq(vault.totalAssets(), 400, "Total assets should be 400"); + assertEq(sharesA, 100, "User A should have 100 shares"); + assertEq(sharesB, 300, "User B should have 300 shares"); + + // Step 3: Vault wins 100 USDC (25% profit) + vm.prank(address(vault)); + token.mint(100); + + assertEq(vault.totalAssets(), 500, "Vault should have 500 USDC after profit"); + assertEq(vault.totalProfit(), 100, "Total profit should be 100 USDC"); + + // Step 4: Claim fees + vault.withdrawPerformanceFees(); + + assertEq(vault.totalAssets(), 400, "Vault should have 400 USDC after fee withdrawal"); + assertEq(token.balanceOf(beneficiaryAddress), 100, "Beneficiary should have 100 USDC"); + assertEq(vault.totalPrincipal(), 400, "Total principal should still be 400"); + + // Step 5: Vault loses 100 USDC (25% loss of principal) + vm.prank(address(vault)); + token.transfer(address(0xdead), 100); + + assertEq(vault.totalAssets(), 300, "Vault should have 300 USDC after loss"); + assertEq(vault.totalPrincipal(), 400, "Total principal should still be 400"); + assertEq(vault.totalProfit(), 0, "Total profit should be 0 after loss"); + + // Step 6: User A redeems 100 shares + vm.prank(userA); + uint256 assetsReceivedA = vault.redeem(sharesA, userA, userA); + + // Step 7: User B redeems 300 shares + vm.prank(userB); + uint256 assetsReceivedB = vault.redeem(sharesB, userB, userB); + + // Verify final state + assertEq(vault.totalPrincipal(), 100, "Total principal should be 100"); + assertEq(vault.totalAssets(), 0, "Vault assets should be 0"); + assertEq(vault.totalSupply(), 0, "Total supply should be 0"); + assertEq(vault.sharePrice(), 1e18, "Share price should be 1e18"); + + // Verify user holdings change + // User A: deposited 100, received back 75, loss = 25 + // User B: deposited 300, received back 225, loss = 75 + assertEq(token.balanceOf(userA), userAInitialBalance - 25, "User A should lose 25 USDC"); + assertEq(token.balanceOf(userB), userBInitialBalance - 75, "User B should lose 75 USDC"); + + // Verify assets received + assertEq(assetsReceivedA, 75, "User A should receive 75 USDC (100 - 25% loss)"); + assertEq(assetsReceivedB, 225, "User B should receive 225 USDC (300 - 25% loss)"); + + // Verify beneficiary keeps all profits + assertEq(token.balanceOf(beneficiaryAddress), 100, "Beneficiary should have 100 USDC (all profits)"); + } +} diff --git a/test-foundry/libraries/vault/scenarios/MasterVaultScenario07.t.sol b/test-foundry/libraries/vault/scenarios/MasterVaultScenario07.t.sol new file mode 100644 index 000000000..307d4f866 --- /dev/null +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario07.t.sol @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import { MasterVaultCoreTest } from "../MasterVaultCore.t.sol"; +import { MasterVault } from "../../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; + +contract MasterVaultScenario07Test is MasterVaultCoreTest { + address public userA = address(0xA); + address public userB = address(0xB); + address public beneficiaryAddress = address(0x9999); + + function setUp() public override { + super.setUp(); + // Enable performance fee for this scenario + vault.setPerformanceFee(true); + vault.setBeneficiary(beneficiaryAddress); + } + + /// @dev Scenario: Profit, fee claim, loss, redemption, then new deposit and redemption + /// Steps 1-7: Initial deposits, profit, fee claim, loss, full redemption + /// Steps 8-9: User A deposits and redeems again + /// Expected: Beneficiary keeps profits, users share initial loss, User A has no change on second round + function test_scenario07_afterLossNewDeposit() public { + // Setup: Mint tokens for users (200 for A: 100+100, 300 for B: 300) + vm.prank(userA); + token.mint(200); + vm.prank(userB); + token.mint(300); + + uint256 userAInitialBalance = token.balanceOf(userA); + uint256 userBInitialBalance = token.balanceOf(userB); + + // Step 1: User A deposits 100 USDC + vm.startPrank(userA); + token.approve(address(vault), 100); + uint256 sharesA1 = vault.deposit(100, userA); + vm.stopPrank(); + + // Step 2: User B deposits 300 USDC + vm.startPrank(userB); + token.approve(address(vault), 300); + uint256 sharesB = vault.deposit(300, userB); + vm.stopPrank(); + + assertEq(vault.totalPrincipal(), 400, "Total principal should be 400"); + assertEq(vault.totalAssets(), 400, "Total assets should be 400"); + assertEq(sharesA1, 100, "User A should have 100 shares"); + assertEq(sharesB, 300, "User B should have 300 shares"); + + // Step 3: Vault wins 100 USDC + vm.prank(address(vault)); + token.mint(100); + + assertEq(vault.totalAssets(), 500, "Vault should have 500 USDC after profit"); + assertEq(vault.totalProfit(), 100, "Total profit should be 100 USDC"); + + // Step 4: Claim fees + vault.withdrawPerformanceFees(); + + assertEq(vault.totalAssets(), 400, "Vault should have 400 USDC after fee withdrawal"); + assertEq(token.balanceOf(beneficiaryAddress), 100, "Beneficiary should have 100 USDC"); + + // Step 5: Vault loses 100 USDC (25% loss) + vm.prank(address(vault)); + token.transfer(address(0xdead), 100); + + assertEq(vault.totalAssets(), 300, "Vault should have 300 USDC after loss"); + assertEq(vault.totalPrincipal(), 400, "Total principal should still be 400"); + + // Step 6: User A redeems 100 shares + vm.prank(userA); + uint256 assetsReceivedA1 = vault.redeem(sharesA1, userA, userA); + + assertEq(assetsReceivedA1, 75, "User A should receive 75 USDC (25% loss)"); + + // Step 7: User B redeems 300 shares + vm.prank(userB); + uint256 assetsReceivedB = vault.redeem(sharesB, userB, userB); + + assertEq(assetsReceivedB, 225, "User B should receive 225 USDC (25% loss)"); + assertEq(vault.totalPrincipal(), 100, "Total principal should be 100"); + assertEq(vault.totalAssets(), 0, "Vault should be empty"); + assertEq(vault.totalSupply(), 0, "Total supply should be 0"); + + // Step 8: User A deposits 100 USDC + vm.startPrank(userA); + token.approve(address(vault), 100); + uint256 sharesA2 = vault.deposit(100, userA); + vm.stopPrank(); + + assertEq(sharesA2, 100, "User A should receive 100 shares for second deposit"); + assertEq(vault.totalPrincipal(), 200, "Total principal should be 200"); + assertEq(vault.totalAssets(), 100, "Total assets should be 100"); + + // Step 9: User A redeems 100 shares + vm.prank(userA); + uint256 assetsReceivedA2 = vault.redeem(sharesA2, userA, userA); + + assertEq(assetsReceivedA2, 100, "User A should receive 100 USDC for second redemption"); + + // Verify final state + assertEq(vault.totalPrincipal(), 100, "Total principal should be 100"); + assertEq(vault.totalAssets(), 0, "Vault assets should be 0"); + assertEq(vault.totalSupply(), 0, "Total supply should be 0"); + assertEq(vault.sharePrice(), 1e18, "Share price should be 1e18"); + + // Verify user holdings change + // User A: deposited 200 total (100+100), received back 175 (75+100), loss = 25 + // User B: deposited 300, received back 225, loss = 75 + assertEq(token.balanceOf(userA), userAInitialBalance - 25, "User A should lose 25 USDC"); + assertEq(token.balanceOf(userB), userBInitialBalance - 75, "User B should lose 75 USDC"); + + // Verify beneficiary keeps all profits + assertEq(token.balanceOf(beneficiaryAddress), 100, "Beneficiary should have 100 USDC (all profits)"); + } +} diff --git a/test-foundry/libraries/vault/scenarios/MasterVaultScenario08.t.sol b/test-foundry/libraries/vault/scenarios/MasterVaultScenario08.t.sol new file mode 100644 index 000000000..720159556 --- /dev/null +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario08.t.sol @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import { MasterVaultCoreTest } from "../MasterVaultCore.t.sol"; +import { MasterVault } from "../../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; + +contract MasterVaultScenario08Test is MasterVaultCoreTest { + address public userA = address(0xA); + address public userB = address(0xB); + address public beneficiaryAddress = address(0x9999); + + function setUp() public override { + super.setUp(); + // Enable performance fee for this scenario + vault.setPerformanceFee(true); + vault.setBeneficiary(beneficiaryAddress); + } + + /// @dev Scenario: Profit, fee claim, loss, then User B deposits more before redemptions + /// Steps 1-5: Initial deposits, profit, fee claim, loss + /// Step 6: User B deposits more after the loss + /// Steps 7-8: Both users redeem + /// Expected: Beneficiary keeps profits, original users share loss, User B's new deposit is fine + function test_scenario08_depositAfterLoss() public { + // Setup: Mint tokens for users (100 for A, 600 for B: 300+300) + vm.prank(userA); + token.mint(100); + vm.prank(userB); + token.mint(600); + + uint256 userAInitialBalance = token.balanceOf(userA); + uint256 userBInitialBalance = token.balanceOf(userB); + + // Step 1: User A deposits 100 USDC + vm.startPrank(userA); + token.approve(address(vault), 100); + uint256 sharesA = vault.deposit(100, userA); + vm.stopPrank(); + + // Step 2: User B deposits 300 USDC + vm.startPrank(userB); + token.approve(address(vault), 300); + uint256 sharesB1 = vault.deposit(300, userB); + vm.stopPrank(); + + assertEq(vault.totalPrincipal(), 400, "Total principal should be 400"); + assertEq(vault.totalAssets(), 400, "Total assets should be 400"); + assertEq(sharesA, 100, "User A should have 100 shares"); + assertEq(sharesB1, 300, "User B should have 300 shares initially"); + + // Step 3: Vault wins 100 USDC + vm.prank(address(vault)); + token.mint(100); + + assertEq(vault.totalAssets(), 500, "Vault should have 500 USDC after profit"); + assertEq(vault.totalProfit(), 100, "Total profit should be 100 USDC"); + + // Step 4: Claim fees + vault.withdrawPerformanceFees(); + + assertEq(vault.totalAssets(), 400, "Vault should have 400 USDC after fee withdrawal"); + assertEq(token.balanceOf(beneficiaryAddress), 100, "Beneficiary should have 100 USDC"); + + // Step 5: Vault loses 100 USDC (25% loss) + vm.prank(address(vault)); + token.transfer(address(0xdead), 100); + + assertEq(vault.totalAssets(), 300, "Vault should have 300 USDC after loss"); + assertEq(vault.totalPrincipal(), 400, "Total principal should still be 400"); + + // At this point, sharePrice is 300/400 = 0.75e18 due to the loss + // When User B deposits 300 USDC at sharePrice 0.75e18, they should get 300/0.75 = 400 shares + + // Step 6: User B deposits 300 USDC + vm.startPrank(userB); + token.approve(address(vault), 300); + uint256 sharesB2 = vault.deposit(300, userB); + vm.stopPrank(); + + assertEq(sharesB2, 400, "User B should receive 400 shares for 300 USDC deposit at 0.75e18 sharePrice"); + assertEq(vault.totalPrincipal(), 700, "Total principal should be 700"); + assertEq(vault.totalAssets(), 600, "Total assets should be 600"); + + uint256 totalSharesB = sharesB1 + sharesB2; + assertEq(totalSharesB, 700, "User B should have 700 total shares (300 + 400)"); + + // Step 7: User A redeems 100 shares + vm.prank(userA); + uint256 assetsReceivedA = vault.redeem(sharesA, userA, userA); + + // Step 8: User B redeems 700 shares + vm.prank(userB); + uint256 assetsReceivedB = vault.redeem(totalSharesB, userB, userB); + + // Verify final state + assertEq(vault.totalPrincipal(), 100, "Total principal should be 100"); + assertEq(vault.totalAssets(), 0, "Vault assets should be 0"); + assertEq(vault.totalSupply(), 0, "Total supply should be 0"); + assertEq(vault.sharePrice(), 1e18, "Share price should be 1e18"); + + // Verify user holdings change + // User A: deposited 100, received back 75, loss = 25 + // User B: deposited 600 (300+300), received back 525 (225+300), loss = 75 + assertEq(token.balanceOf(userA), userAInitialBalance - 25, "User A should lose 25 USDC"); + assertEq(token.balanceOf(userB), userBInitialBalance - 75, "User B should lose 75 USDC"); + + // Verify assets received + assertEq(assetsReceivedA, 75, "User A should receive 75 USDC"); + assertEq(assetsReceivedB, 525, "User B should receive 525 USDC (225 from first 300 shares + 300 from 400 shares)"); + + // Verify beneficiary keeps all profits + assertEq(token.balanceOf(beneficiaryAddress), 100, "Beneficiary should have 100 USDC (all profits)"); + } +} From 2ea2caf512fcbcefed81e3f107b331cad44c58fa Mon Sep 17 00:00:00 2001 From: Wael Almattar Date: Sat, 29 Nov 2025 22:42:49 +0100 Subject: [PATCH 21/30] feat: subvault management --- .../libraries/vault/MasterVault.sol | 46 +++++ .../vault/MasterVaultSubVaultManagement.t.sol | 167 ++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 test-foundry/libraries/vault/MasterVaultSubVaultManagement.t.sol diff --git a/contracts/tokenbridge/libraries/vault/MasterVault.sol b/contracts/tokenbridge/libraries/vault/MasterVault.sol index ee5646446..c8b679384 100644 --- a/contracts/tokenbridge/libraries/vault/MasterVault.sol +++ b/contracts/tokenbridge/libraries/vault/MasterVault.sol @@ -31,6 +31,7 @@ contract MasterVault is AccessControlUpgradeable, PausableUpgradeable { + using SafeERC20 for IERC20; bytes32 public constant VAULT_MANAGER_ROLE = keccak256("VAULT_MANAGER_ROLE"); bytes32 public constant FEE_MANAGER_ROLE = keccak256("FEE_MANAGER_ROLE"); bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); @@ -46,10 +47,16 @@ contract MasterVault is error ZeroAddress(); error PerformanceFeeDisabled(); error BeneficiaryNotSet(); + error SubVaultAlreadySet(); + error SubVaultAssetMismatch(); + error NewSubVaultExchangeRateTooLow(); + error NoExistingSubVault(); + error SubVaultExchangeRateTooLow(); event PerformanceFeeToggled(bool enabled); event BeneficiaryUpdated(address indexed oldBeneficiary, address indexed newBeneficiary); event PerformanceFeesWithdrawn(address indexed beneficiary, uint256 amount); + event SubvaultChanged(address indexed oldSubVault, address indexed newSubVault); // 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 @@ -58,6 +65,7 @@ contract MasterVault is bool public enablePerformanceFee; address public beneficiary; uint256 public totalPrincipal; // total assets deposited, used to calculate profit + IERC4626 public subVault; function initialize( IERC20 _asset, @@ -230,4 +238,42 @@ contract MasterVault is super._withdraw(caller, receiver, owner, assets, shares); totalPrincipal -= assets; } + + /// SubVault management methods /// + + /// @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(IERC4626 _subVault, uint256 minSubVaultExchRateWad) external onlyRole(VAULT_MANAGER_ROLE) { + IERC20 underlyingAsset = IERC20(asset()); + if (address(subVault) != address(0)) revert SubVaultAlreadySet(); + if (address(_subVault.asset()) != address(underlyingAsset)) revert SubVaultAssetMismatch(); + + subVault = _subVault; + + IERC20(asset()).safeApprove(address(_subVault), type(uint256).max); + _subVault.deposit(underlyingAsset.balanceOf(address(this)), address(this)); + + uint256 subVaultExchRateWad = MathUpgradeable.mulDiv(_subVault.balanceOf(address(this)), 1e18, totalSupply(), MathUpgradeable.Rounding.Down); + if (subVaultExchRateWad < minSubVaultExchRateWad) revert NewSubVaultExchangeRateTooLow(); + + emit SubvaultChanged(address(0), address(_subVault)); + } + + /// @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 onlyRole(VAULT_MANAGER_ROLE) { + IERC4626 oldSubVault = subVault; + if (address(oldSubVault) == address(0)) revert NoExistingSubVault(); + + subVault = IERC4626(address(0)); + + oldSubVault.redeem(oldSubVault.balanceOf(address(this)), address(this), address(this)); + IERC20(asset()).safeApprove(address(oldSubVault), 0); + + uint256 assetExchRateWad = MathUpgradeable.mulDiv(IERC20(asset()).balanceOf(address(this)), 1e18, totalSupply(), MathUpgradeable.Rounding.Down); + if (assetExchRateWad < minAssetExchRateWad) revert SubVaultExchangeRateTooLow(); + + emit SubvaultChanged(address(oldSubVault), address(0)); + } } diff --git a/test-foundry/libraries/vault/MasterVaultSubVaultManagement.t.sol b/test-foundry/libraries/vault/MasterVaultSubVaultManagement.t.sol new file mode 100644 index 000000000..2a8f2e97f --- /dev/null +++ b/test-foundry/libraries/vault/MasterVaultSubVaultManagement.t.sol @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import { MasterVaultCoreTest } from "./MasterVaultCore.t.sol"; +import { MasterVault } from "../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; +import { TestERC20 } from "../../../contracts/tokenbridge/test/TestERC20.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import { UpgradeableBeacon } from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; +import { BeaconProxyFactory } from "../../../contracts/tokenbridge/libraries/ClonableBeaconProxy.sol"; + +contract MasterVaultSubVaultManagementTest is MasterVaultCoreTest { + MasterVault public subVault; + + function setUp() public override { + super.setUp(); + + // Create a subVault (another MasterVault instance) with the same asset + MasterVault subVaultImplementation = new MasterVault(); + UpgradeableBeacon subVaultBeacon = new UpgradeableBeacon(address(subVaultImplementation)); + + BeaconProxyFactory subVaultProxyFactory = new BeaconProxyFactory(); + subVaultProxyFactory.initialize(address(subVaultBeacon)); + + bytes32 salt = keccak256("subvault"); + address subVaultProxyAddress = subVaultProxyFactory.createProxy(salt); + subVault = MasterVault(subVaultProxyAddress); + + subVault.initialize(IERC20(address(token)), "Sub Vault Token", "sST", address(this)); + } + + function test_setSubVault() public { + // Setup: User deposits into main vault first + vm.startPrank(user); + token.mint(); + uint256 depositAmount = 100; + token.approve(address(vault), depositAmount); + vault.deposit(depositAmount, user); + vm.stopPrank(); + + assertEq(address(vault.subVault()), address(0), "SubVault should be zero address initially"); + assertEq(token.balanceOf(address(vault)), depositAmount, "Main vault should hold tokens"); + + // Set subVault with minSubVaultExchRateWad = 1e18 (1:1 ratio) + vm.expectEmit(true, true, true, true); + emit SubvaultChanged(address(0), address(subVault)); + vault.setSubVault(IERC4626(address(subVault)), 1e18); + + assertEq(address(vault.subVault()), address(subVault), "SubVault should be set"); + assertEq(token.balanceOf(address(vault)), 0, "Main vault should have no tokens"); + assertEq(token.balanceOf(address(subVault)), depositAmount, "SubVault should hold the tokens"); + assertEq(subVault.balanceOf(address(vault)), depositAmount, "Main vault should have subVault shares"); + } + + function test_setSubVault_revert_SubVaultAlreadySet() public { + vm.startPrank(user); + token.mint(); + token.approve(address(vault), 100); + vault.deposit(100, user); + vm.stopPrank(); + + vault.setSubVault(IERC4626(address(subVault)), 1e18); + + // Try to set subVault again + MasterVault anotherSubVault = new MasterVault(); + + vm.expectRevert(MasterVault.SubVaultAlreadySet.selector); + vault.setSubVault(IERC4626(address(anotherSubVault)), 1e18); + } + + function test_setSubVault_revert_SubVaultAssetMismatch() public { + vm.startPrank(user); + token.mint(); + token.approve(address(vault), 100); + vault.deposit(100, user); + vm.stopPrank(); + + // Create a subVault with different asset + TestERC20 differentToken = new TestERC20(); + MasterVault differentAssetVault = new MasterVault(); + + vm.expectRevert(MasterVault.SubVaultAssetMismatch.selector); + vault.setSubVault(IERC4626(address(differentAssetVault)), 1e18); + } + + function test_setSubVault_revert_NewSubVaultExchangeRateTooLow() public { + vm.startPrank(user); + token.mint(); + token.approve(address(vault), 100); + vault.deposit(100, user); + vm.stopPrank(); + + // Try to set with unreasonably high minSubVaultExchRateWad (2e18 means expecting 2 subVault shares per 1 main vault share) + vm.expectRevert(MasterVault.NewSubVaultExchangeRateTooLow.selector); + vault.setSubVault(IERC4626(address(subVault)), 2e18); + } + + function test_revokeSubVault() public { + // Setup: Set subVault first + vm.startPrank(user); + token.mint(); + uint256 depositAmount = 100; + token.approve(address(vault), depositAmount); + vault.deposit(depositAmount, user); + vm.stopPrank(); + + vault.setSubVault(IERC4626(address(subVault)), 1e18); + + assertEq(address(vault.subVault()), address(subVault), "SubVault should be set"); + assertEq(token.balanceOf(address(vault)), 0, "Main vault should have no tokens"); + assertEq(token.balanceOf(address(subVault)), depositAmount, "SubVault should hold tokens"); + + // Revoke subVault with minAssetExchRateWad = 1e18 (expecting 1:1 ratio) + vm.expectEmit(true, true, true, true); + emit SubvaultChanged(address(subVault), address(0)); + vault.revokeSubVault(1e18); + + assertEq(address(vault.subVault()), address(0), "SubVault should be zero address"); + assertEq(token.balanceOf(address(vault)), depositAmount, "Main vault should have tokens back"); + assertEq(token.balanceOf(address(subVault)), 0, "SubVault should have no tokens"); + assertEq(subVault.balanceOf(address(vault)), 0, "Main vault should have no subVault shares"); + } + + function test_revokeSubVault_revert_NoExistingSubVault() public { + vm.expectRevert(MasterVault.NoExistingSubVault.selector); + vault.revokeSubVault(1e18); + } + + function test_revokeSubVault_revert_SubVaultExchangeRateTooLow() public { + // Setup: Set subVault first + vm.startPrank(user); + token.mint(); + token.approve(address(vault), 100); + vault.deposit(100, user); + vm.stopPrank(); + + vault.setSubVault(IERC4626(address(subVault)), 1e18); + + // Try to revoke with unreasonably high minAssetExchRateWad + vm.expectRevert(MasterVault.SubVaultExchangeRateTooLow.selector); + vault.revokeSubVault(2e18); + } + + function test_setSubVault_revert_NotVaultManager() public { + vm.prank(user); + vm.expectRevert(); + vault.setSubVault(IERC4626(address(subVault)), 1e18); + } + + function test_revokeSubVault_revert_NotVaultManager() public { + // Setup: Set subVault first + vm.startPrank(user); + token.mint(); + token.approve(address(vault), 100); + vault.deposit(100, user); + vm.stopPrank(); + + vault.setSubVault(IERC4626(address(subVault)), 1e18); + + // Try to revoke as non-vault-manager + vm.prank(user); + vm.expectRevert(); + vault.revokeSubVault(1e18); + } + + event SubvaultChanged(address indexed oldSubVault, address indexed newSubVault); +} From d0b89dd0529d51dec1263c83ca2482dcb57d785a Mon Sep 17 00:00:00 2001 From: Wael Almattar Date: Sun, 30 Nov 2025 14:15:31 +0100 Subject: [PATCH 22/30] cover negative total principal --- .../libraries/vault/MasterVault.sol | 23 +++++++++++-------- .../libraries/vault/MasterVaultFee.t.sol | 16 ++++++------- .../scenarios/MasterVaultScenario06.t.sol | 6 ++--- 3 files changed, 24 insertions(+), 21 deletions(-) diff --git a/contracts/tokenbridge/libraries/vault/MasterVault.sol b/contracts/tokenbridge/libraries/vault/MasterVault.sol index c8b679384..c97cf6d80 100644 --- a/contracts/tokenbridge/libraries/vault/MasterVault.sol +++ b/contracts/tokenbridge/libraries/vault/MasterVault.sol @@ -64,7 +64,7 @@ contract MasterVault is // however, this would require more trust in the owner bool public enablePerformanceFee; address public beneficiary; - uint256 public totalPrincipal; // total assets deposited, used to calculate profit + int256 public totalPrincipal; // total assets deposited, used to calculate profit IERC4626 public subVault; function initialize( @@ -105,6 +105,9 @@ contract MasterVault is /// @param enabled True to enable performance fees, false to disable function setPerformanceFee(bool enabled) external onlyRole(VAULT_MANAGER_ROLE) { enablePerformanceFee = enabled; + if (enabled) { + totalPrincipal = 0; + } emit PerformanceFeeToggled(enabled); } @@ -118,9 +121,9 @@ contract MasterVault is } /// @notice calculating total profit - function totalProfit() public view returns (uint256) { + function totalProfit() public view returns (int256) { uint256 _totalAssets = totalAssets(); - return _totalAssets > totalPrincipal ? _totalAssets - totalPrincipal : 0; + return int256(_totalAssets) - totalPrincipal; } /// @notice Withdraw all accumulated performance fees to beneficiary @@ -129,10 +132,10 @@ contract MasterVault is if (!enablePerformanceFee) revert PerformanceFeeDisabled(); if (beneficiary == address(0)) revert BeneficiaryNotSet(); - uint256 totalProfits = totalProfit(); - if (totalProfits > 0) { - SafeERC20.safeTransfer(IERC20(asset()), beneficiary, totalProfits); - emit PerformanceFeesWithdrawn(beneficiary, totalProfits); + int256 _totalProfits = totalProfit(); + if (_totalProfits > 0) { + SafeERC20.safeTransfer(IERC20(asset()), beneficiary, uint256(_totalProfits)); + emit PerformanceFeesWithdrawn(beneficiary, uint256(_totalProfits)); } } @@ -205,7 +208,7 @@ contract MasterVault is uint256 _totalAssets = totalAssets(); uint256 _totalSupply = totalSupply(); uint256 _effectiveAssets = enablePerformanceFee - ? MathUpgradeable.min(_totalAssets, totalPrincipal) + ? MathUpgradeable.min(_totalAssets, uint256(totalPrincipal)) : _totalAssets; if (_totalSupply == 0) { @@ -224,7 +227,7 @@ contract MasterVault is uint256 shares ) internal virtual override whenNotPaused { super._deposit(caller, receiver, assets, shares); - totalPrincipal += assets; + totalPrincipal += int256(assets); } /// @dev Override internal withdraw to track total principal @@ -236,7 +239,7 @@ contract MasterVault is uint256 shares ) internal virtual override whenNotPaused { super._withdraw(caller, receiver, owner, assets, shares); - totalPrincipal -= assets; + totalPrincipal -= int256(assets); } /// SubVault management methods /// diff --git a/test-foundry/libraries/vault/MasterVaultFee.t.sol b/test-foundry/libraries/vault/MasterVaultFee.t.sol index 7855b6d78..c202d350b 100644 --- a/test-foundry/libraries/vault/MasterVaultFee.t.sol +++ b/test-foundry/libraries/vault/MasterVaultFee.t.sol @@ -100,7 +100,7 @@ contract MasterVaultFeeTest is MasterVaultCoreTest { vault.deposit(depositAmount, user); - assertEq(vault.totalPrincipal(), depositAmount, "Total principal should equal deposit amount"); + assertEq(vault.totalPrincipal(), int256(depositAmount), "Total principal should equal deposit amount"); vm.stopPrank(); } @@ -115,7 +115,7 @@ contract MasterVaultFeeTest is MasterVaultCoreTest { uint256 assets = vault.mint(shares, user); - assertEq(vault.totalPrincipal(), assets, "Total principal should equal assets deposited"); + assertEq(vault.totalPrincipal(), int256(assets), "Total principal should equal assets deposited"); vm.stopPrank(); } @@ -127,12 +127,12 @@ contract MasterVaultFeeTest is MasterVaultCoreTest { token.approve(address(vault), depositAmount); vault.deposit(depositAmount, user); - assertEq(vault.totalPrincipal(), depositAmount, "Total principal should equal deposit amount"); + assertEq(vault.totalPrincipal(), int256(depositAmount), "Total principal should equal deposit amount"); uint256 withdrawAmount = 100; vault.withdraw(withdrawAmount, user, user); - assertEq(vault.totalPrincipal(), depositAmount - withdrawAmount, "Total principal should decrease by withdraw amount"); + assertEq(vault.totalPrincipal(), int256(depositAmount - withdrawAmount), "Total principal should decrease by withdraw amount"); vm.stopPrank(); } @@ -144,12 +144,12 @@ contract MasterVaultFeeTest is MasterVaultCoreTest { token.approve(address(vault), depositAmount); uint256 shares = vault.deposit(depositAmount, user); - assertEq(vault.totalPrincipal(), depositAmount, "Total principal should equal deposit amount"); + assertEq(vault.totalPrincipal(), int256(depositAmount), "Total principal should equal deposit amount"); uint256 sharesToRedeem = shares / 2; uint256 assetsReceived = vault.redeem(sharesToRedeem, user, user); - assertEq(vault.totalPrincipal(), depositAmount - assetsReceived, "Total principal should decrease by redeemed assets"); + assertEq(vault.totalPrincipal(), int256(depositAmount - assetsReceived), "Total principal should decrease by redeemed assets"); vm.stopPrank(); } @@ -179,7 +179,7 @@ contract MasterVaultFeeTest is MasterVaultCoreTest { vault.deposit(depositAmount, user); vm.stopPrank(); - assertEq(vault.totalPrincipal(), depositAmount, "Total principal should equal deposit"); + assertEq(vault.totalPrincipal(), int256(depositAmount), "Total principal should equal deposit"); assertEq(vault.totalAssets(), depositAmount, "Total assets should equal deposit"); assertEq(vault.totalProfit(), 0, "Should have no profit initially"); @@ -187,7 +187,7 @@ contract MasterVaultFeeTest is MasterVaultCoreTest { token.mint(); assertEq(vault.totalAssets(), depositAmount * 2, "Total assets should be doubled"); - assertEq(vault.totalProfit(), depositAmount, "Profit should equal initial deposit amount"); + assertEq(vault.totalProfit(), int256(depositAmount), "Profit should equal initial deposit amount"); uint256 beneficiaryBalanceBefore = token.balanceOf(beneficiaryAddress); diff --git a/test-foundry/libraries/vault/scenarios/MasterVaultScenario06.t.sol b/test-foundry/libraries/vault/scenarios/MasterVaultScenario06.t.sol index ecccbc172..bdc079b9b 100644 --- a/test-foundry/libraries/vault/scenarios/MasterVaultScenario06.t.sol +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario06.t.sol @@ -45,7 +45,7 @@ contract MasterVaultScenario06Test is MasterVaultCoreTest { uint256 sharesB = vault.deposit(300, userB); vm.stopPrank(); - assertEq(vault.totalPrincipal(), 400, "Total principal should be 400"); + assertEq(vault.totalPrincipal(), int256(400), "Total principal should be 400"); assertEq(vault.totalAssets(), 400, "Total assets should be 400"); assertEq(sharesA, 100, "User A should have 100 shares"); assertEq(sharesB, 300, "User B should have 300 shares"); @@ -69,8 +69,8 @@ contract MasterVaultScenario06Test is MasterVaultCoreTest { token.transfer(address(0xdead), 100); assertEq(vault.totalAssets(), 300, "Vault should have 300 USDC after loss"); - assertEq(vault.totalPrincipal(), 400, "Total principal should still be 400"); - assertEq(vault.totalProfit(), 0, "Total profit should be 0 after loss"); + assertEq(vault.totalPrincipal(), int256(400), "Total principal should still be 400"); + assertEq(vault.totalProfit(), -100, "Total profit should be -100 after loss (represents 100 loss)"); // Step 6: User A redeems 100 shares vm.prank(userA); From 566f824d769f4f4c00d40aa1844df3ca02b050e6 Mon Sep 17 00:00:00 2001 From: Wael Almattar Date: Sun, 30 Nov 2025 19:26:47 +0100 Subject: [PATCH 23/30] chore: pause vault by default --- .../libraries/vault/MasterVault.sol | 3 +++ .../libraries/vault/MasterVaultCore.t.sol | 21 +++++++++++++++++-- .../vault/MasterVaultSubVaultManagement.t.sol | 1 + 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/contracts/tokenbridge/libraries/vault/MasterVault.sol b/contracts/tokenbridge/libraries/vault/MasterVault.sol index c97cf6d80..90c614815 100644 --- a/contracts/tokenbridge/libraries/vault/MasterVault.sol +++ b/contracts/tokenbridge/libraries/vault/MasterVault.sol @@ -89,6 +89,9 @@ contract MasterVault is _grantRole(VAULT_MANAGER_ROLE, _owner); _grantRole(FEE_MANAGER_ROLE, _owner); // todo: consider permissionless by default _grantRole(PAUSER_ROLE, _owner); + + // vault paused by default to protect against first depositor attack + _pause(); } function pause() external onlyRole(PAUSER_ROLE) { diff --git a/test-foundry/libraries/vault/MasterVaultCore.t.sol b/test-foundry/libraries/vault/MasterVaultCore.t.sol index c3f7f9639..6c3ce8221 100644 --- a/test-foundry/libraries/vault/MasterVaultCore.t.sol +++ b/test-foundry/libraries/vault/MasterVaultCore.t.sol @@ -33,6 +33,7 @@ contract MasterVaultCoreTest is Test { vault = MasterVault(proxyAddress); vault.initialize(IERC20(address(token)), name, symbol, address(this)); + vault.unpause(); } /// todo: @@ -73,13 +74,29 @@ contract MasterVaultCoreTest is Test { } + function test_initialize_pausedByDefault() public { + // Deploy a fresh vault to test initial paused state + MasterVault implementation = new MasterVault(); + UpgradeableBeacon testBeacon = new UpgradeableBeacon(address(implementation)); + BeaconProxyFactory testFactory = new BeaconProxyFactory(); + testFactory.initialize(address(testBeacon)); + + bytes32 salt = keccak256("test_paused"); + address proxyAddress = testFactory.createProxy(salt); + MasterVault testVault = MasterVault(proxyAddress); + + testVault.initialize(IERC20(address(token)), "Test", "TST", address(this)); + + assertTrue(testVault.paused(), "Vault should be paused immediately after initialization"); + } + function test_initialize_pauserRole() public { assertTrue(vault.hasRole(vault.PAUSER_ROLE(), address(this)), "Should have PAUSER_ROLE"); - assertFalse(vault.paused(), "Should not be paused initially"); + assertFalse(vault.paused(), "Should not be paused after unpause in setUp"); } function test_pause() public { - assertFalse(vault.paused(), "Should not be paused initially"); + assertFalse(vault.paused(), "Should not be paused after unpause in setUp"); vault.pause(); diff --git a/test-foundry/libraries/vault/MasterVaultSubVaultManagement.t.sol b/test-foundry/libraries/vault/MasterVaultSubVaultManagement.t.sol index 2a8f2e97f..664d4d7fb 100644 --- a/test-foundry/libraries/vault/MasterVaultSubVaultManagement.t.sol +++ b/test-foundry/libraries/vault/MasterVaultSubVaultManagement.t.sol @@ -27,6 +27,7 @@ contract MasterVaultSubVaultManagementTest is MasterVaultCoreTest { subVault = MasterVault(subVaultProxyAddress); subVault.initialize(IERC20(address(token)), "Sub Vault Token", "sST", address(this)); + subVault.unpause(); } function test_setSubVault() public { From d4373aef915bbae2d2f143359f0e93870ca4796a Mon Sep 17 00:00:00 2001 From: Wael Almattar Date: Mon, 1 Dec 2025 13:56:25 +0100 Subject: [PATCH 24/30] test: first depositor --- .../scenarios/MasterVaultScenario09.t.sol | 105 ++++++++++++++++++ .../scenarios/MasterVaultScenario10.t.sol | 94 ++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 test-foundry/libraries/vault/scenarios/MasterVaultScenario09.t.sol create mode 100644 test-foundry/libraries/vault/scenarios/MasterVaultScenario10.t.sol diff --git a/test-foundry/libraries/vault/scenarios/MasterVaultScenario09.t.sol b/test-foundry/libraries/vault/scenarios/MasterVaultScenario09.t.sol new file mode 100644 index 000000000..abbeab92b --- /dev/null +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario09.t.sol @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import { MasterVaultCoreTest } from "../MasterVaultCore.t.sol"; +import { MasterVault } from "../../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; + +contract MasterVaultScenario09Test is MasterVaultCoreTest { + address public userA = address(0xA); + address public userB = address(0xB); + address public beneficiaryAddress = address(0x9999); + + function setUp() public override { + super.setUp(); + // Enable performance fee for this scenario + vault.setPerformanceFee(true); + vault.setBeneficiary(beneficiaryAddress); + } + + /// @dev Scenario 9: First depositor attack protection when performance fee is enabled + /// User A deposits 1 USDC, vault gains 1M USDC (attacker donation), User B deposits 100 USDC + /// User A redeems 1 share, User B redeems all shares + /// Expected: Performance fee mechanism prevents User A from extracting profit, both users break even + function test_scenario09_performanceFeeProtection() public { + // Setup: Mint tokens for users + vm.prank(userA); + token.mint(1); + vm.prank(userB); + token.mint(100); + + uint256 userAInitialBalance = token.balanceOf(userA); + uint256 userBInitialBalance = token.balanceOf(userB); + + // Step 1: User A deposits 1 USDC + vm.startPrank(userA); + token.approve(address(vault), 1); + uint256 sharesA = vault.deposit(1, userA); + vm.stopPrank(); + + assertEq(sharesA, 1, "User A should receive 1 share"); + assertEq(vault.totalSupply(), 1, "Total supply should be 1"); + assertEq(vault.totalAssets(), 1, "Total assets should be 1"); + assertEq(vault.totalPrincipal(), int256(1), "Total principal should be 1"); + + // Step 2: Vault wins 1,000,000 USDC (attacker donation attack) + vm.prank(address(vault)); + token.mint(1_000_000); + + assertEq(vault.totalAssets(), 1_000_001, "Vault should have 1,000,001 USDC after profit"); + assertEq(vault.totalPrincipal(), int256(1), "Total principal should still be 1"); + assertEq(vault.totalProfit(), 1_000_000, "Total profit should be 1,000,000 USDC"); + // With perf fee enabled, share price is capped at 1e18 + assertEq(vault.sharePrice(), 1e18, "Share price should be capped at 1e18 with perf fee"); + + // Step 3: User B deposits 100 USDC + vm.startPrank(userB); + token.approve(address(vault), 100); + uint256 sharesB = vault.deposit(100, userB); + vm.stopPrank(); + + // User B gets fair shares because share price is capped + assertEq(sharesB, 100, "User B should receive 100 shares"); + assertEq(vault.totalSupply(), 101, "Total supply should be 101"); + assertEq(vault.totalAssets(), 1_000_101, "Total assets should be 1,000,101"); + assertEq(vault.totalPrincipal(), int256(101), "Total principal should be 101"); + + // Step 4: User A redeems 1 share + vm.prank(userA); + uint256 assetsReceivedA = vault.redeem(sharesA, userA, userA); + + // User A gets back their principal, no profit due to perf fee protection + // effectiveAssets = min(1_000_101, 101) = 101 + // assetsReceived = (1 * 101) / 101 = 1 + assertEq(assetsReceivedA, 1, "User A should receive 1 USDC (their principal only)"); + assertEq(vault.totalSupply(), 100, "Total supply should be 100"); + assertEq(vault.totalAssets(), 1_000_100, "Total assets should be 1,000,100"); + assertEq(vault.totalPrincipal(), int256(100), "Total principal should be 100"); + + // Step 5: User B redeems 100 shares + vm.prank(userB); + uint256 assetsReceivedB = vault.redeem(sharesB, userB, userB); + + // User B gets back their principal + // effectiveAssets = min(1_000_100, 100) = 100 + // assetsReceived = (100 * 100) / 100 = 100 + assertEq(assetsReceivedB, 100, "User B should receive 100 USDC (their principal only)"); + + // Verify final state + assertEq(vault.totalPrincipal(), int256(0), "Total principal should be 0"); + assertEq(vault.totalAssets(), 1_000_000, "Vault assets should be 1,000,000 (the donated profit)"); + assertEq(vault.totalSupply(), 0, "Total supply should be 0"); + assertEq(vault.sharePrice(), 1e18, "Share price should be 1e18"); + + // Verify user holdings change + // User A: deposited 1, received back 1, change = 0 + // User B: deposited 100, received back 100, change = 0 + assertEq(token.balanceOf(userA), userAInitialBalance, "User A should break even (0 change)"); + assertEq(token.balanceOf(userB), userBInitialBalance, "User B should break even (0 change)"); + + // Verify beneficiary has not claimed fees yet + assertEq(token.balanceOf(beneficiaryAddress), 0, "Beneficiary should have 0 USDC (fees not claimed yet)"); + + // Verify the 1M USDC remains in vault as profit + assertEq(vault.totalProfit(), 1_000_000, "Total profit should be 1,000,000 USDC"); + } +} diff --git a/test-foundry/libraries/vault/scenarios/MasterVaultScenario10.t.sol b/test-foundry/libraries/vault/scenarios/MasterVaultScenario10.t.sol new file mode 100644 index 000000000..e596b4c93 --- /dev/null +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario10.t.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import { MasterVaultCoreTest } from "../MasterVaultCore.t.sol"; +import { MasterVault } from "../../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; + +contract MasterVaultScenario10Test is MasterVaultCoreTest { + address public userA = address(0xA); + address public userB = address(0xB); + + function setUp() public override { + super.setUp(); + // Performance fee is disabled by default - this makes the vault vulnerable + } + + /// @dev Scenario 10: First depositor attack when performance fee is DISABLED + /// This test demonstrates the vulnerability that exists when perf fees are off + /// User A deposits 1 USDC, attacker donates 1M USDC, User B deposits 100 USDC but gets 0 shares + /// User A redeems and steals User B's funds + /// This is why the vault is paused by default on deployment + function test_scenario10_firstDepositorAttackVulnerability() public { + // Setup: Mint tokens for users + vm.prank(userA); + token.mint(1); + vm.prank(userB); + token.mint(100); + + uint256 userAInitialBalance = token.balanceOf(userA); + uint256 userBInitialBalance = token.balanceOf(userB); + + // Step 1: User A (attacker) deposits 1 USDC + vm.startPrank(userA); + token.approve(address(vault), 1); + uint256 sharesA = vault.deposit(1, userA); + vm.stopPrank(); + + assertEq(sharesA, 1, "User A should receive 1 share"); + assertEq(vault.totalSupply(), 1, "Total supply should be 1"); + assertEq(vault.totalAssets(), 1, "Total assets should be 1"); + assertEq(vault.totalPrincipal(), int256(1), "Total principal should be 1"); + assertEq(vault.sharePrice(), 1e18, "Share price should be 1e18"); + + // Step 2: Attacker (or MEV bot) donates 1,000,000 USDC to inflate share price + vm.prank(address(vault)); + token.mint(1_000_000); + + assertEq(vault.totalAssets(), 1_000_001, "Vault should have 1,000,001 USDC after donation"); + assertEq(vault.totalPrincipal(), int256(1), "Total principal should still be 1"); + assertEq(vault.totalProfit(), 1_000_000, "Total profit should be 1,000,000 USDC"); + // Without perf fee protection, share price inflates massively + assertEq(vault.sharePrice(), 1_000_001 * 1e18, "Share price should be 1,000,001 * 1e18"); + + // Step 3: User B (victim) deposits 100 USDC but receives 0 shares due to rounding + vm.startPrank(userB); + token.approve(address(vault), 100); + uint256 sharesB = vault.deposit(100, userB); + vm.stopPrank(); + + // User B gets 0 shares: 100 / 1,000,001 rounds down to 0 + assertEq(sharesB, 0, "User B should receive 0 shares due to rounding (100 / 1,000,001 = 0)"); + assertEq(vault.totalSupply(), 1, "Total supply should still be 1 (only attacker has shares)"); + assertEq(vault.totalAssets(), 1_000_101, "Total assets should be 1,000,101"); + assertEq(vault.totalPrincipal(), int256(101), "Total principal should be 101"); + + // Step 4: User A (attacker) redeems 1 share and steals all funds + vm.prank(userA); + uint256 assetsReceivedA = vault.redeem(sharesA, userA, userA); + + // User A owns all shares, so gets all assets + assertEq(assetsReceivedA, 1_000_101, "User A should receive all 1,000,101 USDC"); + + // Verify final state + assertEq(vault.totalPrincipal(), int256(-1_000_000), "Total principal should be -1,000,000"); + assertEq(vault.totalAssets(), 0, "Vault assets should be 0"); + assertEq(vault.totalSupply(), 0, "Total supply should be 0"); + assertEq(vault.sharePrice(), 1e18, "Share price should be 1e18"); + + // Verify user holdings change - User A profits, User B loses + // User A: deposited 1, received back 1,000,101, profit = 1,000,100 + // User B: deposited 100, received back 0, loss = 100 + assertEq(token.balanceOf(userA), userAInitialBalance + 1_000_100, "User A should gain 1,000,100 USDC"); + assertEq(token.balanceOf(userB), userBInitialBalance - 100, "User B should lose 100 USDC"); + + // Verify no beneficiary involved + assertEq(token.balanceOf(address(0x9999)), 0, "Beneficiary should have 0 USDC"); + } + + /// @dev This test proves why the vault MUST be paused by default on deployment + /// The pause mechanism gives the owner time to: + /// 1. Enable performance fees (recommended) + /// 2. Make an initial deposit to set a fair share price + /// 3. Configure other security settings + /// Before allowing public deposits +} From c6a655473e1219c8d42beef5ab68b26315af7fff Mon Sep 17 00:00:00 2001 From: Wael Almattar Date: Mon, 1 Dec 2025 22:48:22 +0100 Subject: [PATCH 25/30] feat: integrate subvault logic --- .../libraries/vault/MasterVault.sol | 93 +++++++++++++++++-- 1 file changed, 85 insertions(+), 8 deletions(-) diff --git a/contracts/tokenbridge/libraries/vault/MasterVault.sol b/contracts/tokenbridge/libraries/vault/MasterVault.sol index 90c614815..f0d13dab7 100644 --- a/contracts/tokenbridge/libraries/vault/MasterVault.sol +++ b/contracts/tokenbridge/libraries/vault/MasterVault.sol @@ -90,7 +90,7 @@ contract MasterVault is _grantRole(FEE_MANAGER_ROLE, _owner); // todo: consider permissionless by default _grantRole(PAUSER_ROLE, _owner); - // vault paused by default to protect against first depositor attack + // vault paused by default to protect against first depositor attack _pause(); } @@ -123,6 +123,18 @@ contract MasterVault is emit BeneficiaryUpdated(oldBeneficiary, newBeneficiary); } + /** @dev See {IERC4626-totalAssets}. */ + function totalAssets() public view virtual override returns (uint256) { + IERC20 underlyingAsset = IERC20(asset()); + + if (address(subVault) == address(0)) { + return underlyingAsset.balanceOf(address(this)); + } + uint256 _subShares = subVault.balanceOf(address(this)); + uint256 _assets = subVault.previewRedeem(_subShares); + return _assets; + } + /// @notice calculating total profit function totalProfit() public view returns (int256) { uint256 _totalAssets = totalAssets(); @@ -137,7 +149,12 @@ contract MasterVault is int256 _totalProfits = totalProfit(); if (_totalProfits > 0) { - SafeERC20.safeTransfer(IERC20(asset()), beneficiary, uint256(_totalProfits)); + if (address(subVault) == address(0)) { + SafeERC20.safeTransfer(IERC20(asset()), beneficiary, uint256(_totalProfits)); + } else { + subVault.withdraw(uint256(_totalProfits), beneficiary, address(this)); + } + emit PerformanceFeesWithdrawn(beneficiary, uint256(_totalProfits)); } } @@ -145,9 +162,9 @@ contract MasterVault is /// @notice return share price by asset in 18 decimals /// @dev max value is 1e18 if performance fee is enabled /// @dev examples: - /// example 1. sharePrice = 1e18 means we need to pay 1 asset to get 1 share - /// example 2. sharePrice = 10 * 1e18 means we need to pay 10 asset to get 1 share - /// example 3. sharePrice = 0.1 * 1e18 means we need to pay 0.1 asset to get 1 share + /// example 1. sharePrice = 1e18 means we need to pay 1 asset to get 1 share + /// example 2. sharePrice = 10 * 1e18 means we need to pay 10 asset to get 1 share + /// example 3. sharePrice = 0.1 * 1e18 means we need to pay 0.1 asset to get 1 share /// example 4. vault holds 99 USDC and 100 shares => sharePrice = 99 * 1e18 / 100 function sharePrice() public view returns (uint256) { uint256 _totalAssets = totalAssets(); @@ -230,6 +247,14 @@ contract MasterVault is uint256 shares ) internal virtual override whenNotPaused { super._deposit(caller, receiver, assets, shares); + + if (address(subVault) != address(0)) { + IERC20 underlyingAsset = IERC20(asset()); + // todo: should we deposit only users assets and account for trasnfer fee or keep depositing _idleAssets? + uint256 _idleAssets = underlyingAsset.balanceOf(address(this)); + subVault.deposit(_idleAssets, address(this)); + } + totalPrincipal += int256(assets); } @@ -241,6 +266,11 @@ contract MasterVault is uint256 assets, uint256 shares ) internal virtual override whenNotPaused { + if (address(subVault) != address(0)) { + subVault.withdraw(assets, address(this), address(this)); + } + + // todo: account trasnfer fee? should we withdraw all? should we validate against users assets if transfer fee accure? super._withdraw(caller, receiver, owner, assets, shares); totalPrincipal -= int256(assets); } @@ -250,7 +280,10 @@ contract MasterVault is /// @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(IERC4626 _subVault, uint256 minSubVaultExchRateWad) external onlyRole(VAULT_MANAGER_ROLE) { + function setSubVault( + IERC4626 _subVault, + uint256 minSubVaultExchRateWad + ) external onlyRole(VAULT_MANAGER_ROLE) { IERC20 underlyingAsset = IERC20(asset()); if (address(subVault) != address(0)) revert SubVaultAlreadySet(); if (address(_subVault.asset()) != address(underlyingAsset)) revert SubVaultAssetMismatch(); @@ -260,7 +293,12 @@ contract MasterVault is IERC20(asset()).safeApprove(address(_subVault), type(uint256).max); _subVault.deposit(underlyingAsset.balanceOf(address(this)), address(this)); - uint256 subVaultExchRateWad = MathUpgradeable.mulDiv(_subVault.balanceOf(address(this)), 1e18, totalSupply(), MathUpgradeable.Rounding.Down); + uint256 subVaultExchRateWad = MathUpgradeable.mulDiv( + _subVault.balanceOf(address(this)), + 1e18, + totalSupply(), + MathUpgradeable.Rounding.Down + ); if (subVaultExchRateWad < minSubVaultExchRateWad) revert NewSubVaultExchangeRateTooLow(); emit SubvaultChanged(address(0), address(_subVault)); @@ -277,9 +315,48 @@ contract MasterVault is oldSubVault.redeem(oldSubVault.balanceOf(address(this)), address(this), address(this)); IERC20(asset()).safeApprove(address(oldSubVault), 0); - uint256 assetExchRateWad = MathUpgradeable.mulDiv(IERC20(asset()).balanceOf(address(this)), 1e18, totalSupply(), MathUpgradeable.Rounding.Down); + uint256 assetExchRateWad = MathUpgradeable.mulDiv( + IERC20(asset()).balanceOf(address(this)), + 1e18, + totalSupply(), + MathUpgradeable.Rounding.Down + ); if (assetExchRateWad < minAssetExchRateWad) revert SubVaultExchangeRateTooLow(); emit SubvaultChanged(address(oldSubVault), address(0)); } + + /// Max methods needed only if SubVault is set /// + + /** @dev See {IERC4626-maxDeposit}. */ + function maxDeposit(address receiver) public view virtual override returns (uint256) { + if (address(subVault) == address(0)) { + return super.maxDeposit(receiver); + } + return subVault.maxDeposit(receiver); + } + + /** @dev See {IERC4626-maxMint}. */ + function maxMint(address receiver) public view virtual override returns (uint256) { + if (address(subVault) == address(0)) { + return super.maxMint(receiver); + } + return subVault.maxMint(receiver); + } + + /** @dev See {IERC4626-maxWithdraw}. */ + function maxWithdraw(address owner) public view virtual override returns (uint256) { + if (address(subVault) == address(0)) { + return super.maxWithdraw(owner); + } + return subVault.maxWithdraw(owner); + } + + /** @dev See {IERC4626-maxRedeem}. */ + function maxRedeem(address owner) public view virtual override returns (uint256) { + if (address(subVault) == address(0)) { + return super.maxRedeem(owner); + } + return subVault.maxRedeem(owner); + } } From dc4debdf579060e55fe3c2ed1cb501f4054f000d Mon Sep 17 00:00:00 2001 From: Wael Almattar Date: Tue, 2 Dec 2025 00:14:33 +0100 Subject: [PATCH 26/30] test: subvault integration basic tests --- .../libraries/vault/MasterVault.sol | 22 +++++++----- .../libraries/vault/MasterVault.t.sol | 34 ++++++++++++++++--- 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/contracts/tokenbridge/libraries/vault/MasterVault.sol b/contracts/tokenbridge/libraries/vault/MasterVault.sol index f0d13dab7..8aa5dad96 100644 --- a/contracts/tokenbridge/libraries/vault/MasterVault.sol +++ b/contracts/tokenbridge/libraries/vault/MasterVault.sol @@ -293,13 +293,17 @@ contract MasterVault is IERC20(asset()).safeApprove(address(_subVault), type(uint256).max); _subVault.deposit(underlyingAsset.balanceOf(address(this)), address(this)); - uint256 subVaultExchRateWad = MathUpgradeable.mulDiv( - _subVault.balanceOf(address(this)), - 1e18, - totalSupply(), - MathUpgradeable.Rounding.Down - ); - if (subVaultExchRateWad < minSubVaultExchRateWad) revert NewSubVaultExchangeRateTooLow(); + uint256 _totalSupply = totalSupply(); + if (_totalSupply > 0) { + uint256 subVaultExchRateWad = MathUpgradeable.mulDiv( + _subVault.balanceOf(address(this)), + 1e18, + totalSupply(), + MathUpgradeable.Rounding.Down + ); + if (subVaultExchRateWad < minSubVaultExchRateWad) + revert NewSubVaultExchangeRateTooLow(); + } emit SubvaultChanged(address(0), address(_subVault)); } @@ -349,7 +353,7 @@ contract MasterVault is if (address(subVault) == address(0)) { return super.maxWithdraw(owner); } - return subVault.maxWithdraw(owner); + return subVault.maxWithdraw(address(this)); } /** @dev See {IERC4626-maxRedeem}. */ @@ -357,6 +361,6 @@ contract MasterVault is if (address(subVault) == address(0)) { return super.maxRedeem(owner); } - return subVault.maxRedeem(owner); + return subVault.maxRedeem(address(this)); } } diff --git a/test-foundry/libraries/vault/MasterVault.t.sol b/test-foundry/libraries/vault/MasterVault.t.sol index 89abb33dd..2419a6619 100644 --- a/test-foundry/libraries/vault/MasterVault.t.sol +++ b/test-foundry/libraries/vault/MasterVault.t.sol @@ -2,9 +2,12 @@ pragma solidity ^0.8.0; import { MasterVaultCoreTest } from "./MasterVaultCore.t.sol"; +import { MockSubVault } from "../../../contracts/tokenbridge/test/MockSubVault.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; contract MasterVaultTest is MasterVaultCoreTest { - // first deposit + // first deposit function test_deposit() public { vm.startPrank(user); token.mint(); @@ -17,12 +20,21 @@ contract MasterVaultTest is MasterVaultCoreTest { assertEq(vault.balanceOf(user), shares, "User should receive shares"); assertEq(vault.totalAssets(), depositAmount, "Vault should hold deposited assets"); assertEq(vault.totalSupply(), shares, "Total supply should equal shares minted"); - assertEq(token.balanceOf(address(vault)), depositAmount, "Vault should hold the tokens"); - assertGt(token.balanceOf(address(vault)), 0, "Vault should hold the tokens"); + address _assetsHoldingVault = address(vault.subVault()) == address(0) + ? address(vault) + : address(vault.subVault()); + + assertEq( + token.balanceOf(_assetsHoldingVault), + depositAmount, + "Vault should hold the tokens" + ); + assertGt(token.balanceOf(_assetsHoldingVault), 0, "Vault should hold the tokens"); + assertEq( vault.totalSupply(), - token.balanceOf(address(vault)), + token.balanceOf(_assetsHoldingVault), "First deposit should be at a rate of 1" ); @@ -39,10 +51,14 @@ contract MasterVaultTest is MasterVaultCoreTest { uint256 assetsCost = vault.mint(sharesToMint, user); + address _assetsHoldingVault = address(vault.subVault()) == address(0) + ? address(vault) + : address(vault.subVault()); + assertEq(vault.balanceOf(user), sharesToMint, "User should receive requested shares"); assertEq(vault.totalSupply(), sharesToMint, "Total supply should equal shares minted"); assertEq(vault.totalAssets(), assetsCost, "Vault should hold the assets deposited"); - assertEq(token.balanceOf(address(vault)), assetsCost, "Vault should hold the tokens"); + assertEq(token.balanceOf(_assetsHoldingVault), assetsCost, "Vault should hold the tokens"); vm.stopPrank(); } @@ -90,3 +106,11 @@ contract MasterVaultTest is MasterVaultCoreTest { vm.stopPrank(); } } + +contract MasterVaultTestWithSubvault is MasterVaultTest { + function setUp() public override { + super.setUp(); + MockSubVault _subvault = new MockSubVault(IERC20(address(token)), "TestSubvault", "TSV"); + vault.setSubVault(IERC4626(address(_subvault)), 0); + } +} From 8bb3e7757b4bfe72393fb8033973dee0f1226d33 Mon Sep 17 00:00:00 2001 From: Wael Almattar Date: Tue, 2 Dec 2025 00:19:31 +0100 Subject: [PATCH 27/30] npm run format --- .../libraries/vault/IMasterVaultFactory.sol | 5 +- .../libraries/vault/MasterVault.sol | 24 ++----- contracts/tokenbridge/test/MockSubVault.sol | 8 +-- .../libraries/vault/MasterVaultCore.t.sol | 42 +++++++---- .../libraries/vault/MasterVaultFactory.t.sol | 47 ++++++++---- .../libraries/vault/MasterVaultFee.t.sol | 71 +++++++++++++++---- .../vault/MasterVaultSharePriceNoFee.t.sol | 6 +- .../vault/MasterVaultSharePriceWithFee.t.sol | 6 +- .../vault/MasterVaultSubVaultManagement.t.sol | 30 ++++++-- .../scenarios/MasterVaultScenario01.t.sol | 6 +- .../scenarios/MasterVaultScenario02.t.sol | 18 ++++- .../scenarios/MasterVaultScenario03.t.sol | 6 +- .../scenarios/MasterVaultScenario04.t.sol | 6 +- .../scenarios/MasterVaultScenario05.t.sol | 12 +++- .../scenarios/MasterVaultScenario06.t.sol | 12 +++- .../scenarios/MasterVaultScenario07.t.sol | 6 +- .../scenarios/MasterVaultScenario08.t.sol | 18 ++++- .../scenarios/MasterVaultScenario09.t.sol | 24 +++++-- .../scenarios/MasterVaultScenario10.t.sol | 24 +++++-- 19 files changed, 280 insertions(+), 91 deletions(-) diff --git a/contracts/tokenbridge/libraries/vault/IMasterVaultFactory.sol b/contracts/tokenbridge/libraries/vault/IMasterVaultFactory.sol index 2a7617abc..fc9185312 100644 --- a/contracts/tokenbridge/libraries/vault/IMasterVaultFactory.sol +++ b/contracts/tokenbridge/libraries/vault/IMasterVaultFactory.sol @@ -5,7 +5,10 @@ interface IMasterVaultFactory { event VaultDeployed(address indexed token, address indexed vault); 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); -} \ No newline at end of file +} diff --git a/contracts/tokenbridge/libraries/vault/MasterVault.sol b/contracts/tokenbridge/libraries/vault/MasterVault.sol index 8aa5dad96..536ffd0ad 100644 --- a/contracts/tokenbridge/libraries/vault/MasterVault.sol +++ b/contracts/tokenbridge/libraries/vault/MasterVault.sol @@ -1,28 +1,16 @@ // SPDX-License-Identifier: Apache-2.0 pragma solidity ^0.8.0; -import { - ERC4626Upgradeable -} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.sol"; -import { - ERC20Upgradeable -} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import { ERC4626Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.sol"; +import { ERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import { - IERC20Upgradeable -} from "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; +import { IERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; -import { - AccessControlUpgradeable -} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; -import { - PausableUpgradeable -} from "@openzeppelin/contracts-upgradeable/security/PausableUpgradeable.sol"; +import { AccessControlUpgradeable } from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import { PausableUpgradeable } from "@openzeppelin/contracts-upgradeable/security/PausableUpgradeable.sol"; import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; -import { - MathUpgradeable -} from "@openzeppelin/contracts-upgradeable/utils/math/MathUpgradeable.sol"; +import { MathUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/math/MathUpgradeable.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; contract MasterVault is diff --git a/contracts/tokenbridge/test/MockSubVault.sol b/contracts/tokenbridge/test/MockSubVault.sol index 411edb61b..8d6befd1a 100644 --- a/contracts/tokenbridge/test/MockSubVault.sol +++ b/contracts/tokenbridge/test/MockSubVault.sol @@ -1,9 +1,9 @@ // 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"; +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( @@ -15,4 +15,4 @@ contract MockSubVault is ERC4626 { 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/MasterVaultCore.t.sol b/test-foundry/libraries/vault/MasterVaultCore.t.sol index 6c3ce8221..ffc56dff5 100644 --- a/test-foundry/libraries/vault/MasterVaultCore.t.sol +++ b/test-foundry/libraries/vault/MasterVaultCore.t.sol @@ -36,10 +36,10 @@ contract MasterVaultCoreTest is Test { vault.unpause(); } - /// todo: - /// test pause func with deposits/withdrawals + /// todo: + /// test pause func with deposits/withdrawals /// test deposit, withdraw, mint, redeem - + function test_initialize() public { assertEq(address(vault.asset()), address(token), "Invalid asset"); assertEq(vault.name(), name, "Invalid name"); @@ -48,11 +48,20 @@ contract MasterVaultCoreTest is Test { assertEq(vault.totalSupply(), 0, "Invalid initial supply"); assertEq(vault.totalAssets(), 0, "Invalid initial assets"); - assertTrue(vault.hasRole(vault.DEFAULT_ADMIN_ROLE(), address(this)), "Should have DEFAULT_ADMIN_ROLE"); - assertTrue(vault.hasRole(vault.VAULT_MANAGER_ROLE(), address(this)), "Should have VAULT_MANAGER_ROLE"); - assertTrue(vault.hasRole(vault.FEE_MANAGER_ROLE(), address(this)), "Should have FEE_MANAGER_ROLE"); + assertTrue( + vault.hasRole(vault.DEFAULT_ADMIN_ROLE(), address(this)), + "Should have DEFAULT_ADMIN_ROLE" + ); + assertTrue( + vault.hasRole(vault.VAULT_MANAGER_ROLE(), address(this)), + "Should have VAULT_MANAGER_ROLE" + ); + assertTrue( + vault.hasRole(vault.FEE_MANAGER_ROLE(), address(this)), + "Should have FEE_MANAGER_ROLE" + ); } - + function test_beaconUpgrade() public { vm.startPrank(user); token.mint(); @@ -62,18 +71,28 @@ contract MasterVaultCoreTest is Test { vm.stopPrank(); address oldImplementation = beacon.implementation(); - assertEq(oldImplementation, address(beacon.implementation()), "Should have initial implementation"); + assertEq( + oldImplementation, + address(beacon.implementation()), + "Should have initial implementation" + ); MasterVault newImplementation = new MasterVault(); beacon.upgradeTo(address(newImplementation)); - assertEq(beacon.implementation(), address(newImplementation), "Beacon should point to new implementation"); - assertTrue(beacon.implementation() != oldImplementation, "Implementation should have changed"); + assertEq( + beacon.implementation(), + address(newImplementation), + "Beacon should point to new implementation" + ); + assertTrue( + beacon.implementation() != oldImplementation, + "Implementation should have changed" + ); assertEq(vault.name(), name, "Name should remain after upgrade"); } - function test_initialize_pausedByDefault() public { // Deploy a fresh vault to test initial paused state MasterVault implementation = new MasterVault(); @@ -144,5 +163,4 @@ contract MasterVaultCoreTest is Test { vault.unpause(); assertFalse(vault.paused(), "Should be unpaused by pauser2"); } - } diff --git a/test-foundry/libraries/vault/MasterVaultFactory.t.sol b/test-foundry/libraries/vault/MasterVaultFactory.t.sol index 48359c390..f9b7d6a03 100644 --- a/test-foundry/libraries/vault/MasterVaultFactory.t.sol +++ b/test-foundry/libraries/vault/MasterVaultFactory.t.sol @@ -1,11 +1,11 @@ // 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"; -import {UpgradeableBeacon} from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; +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"; +import { UpgradeableBeacon } from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; contract MasterVaultFactoryTest is Test { MasterVaultFactory public factory; @@ -41,7 +41,10 @@ contract MasterVaultFactoryTest is Test { MasterVault vault = MasterVault(deployedVault); assertEq(address(vault.asset()), address(token), "Invalid vault asset"); - assertTrue(vault.hasRole(vault.DEFAULT_ADMIN_ROLE(), owner), "Factory owner should have DEFAULT_ADMIN_ROLE"); + assertTrue( + vault.hasRole(vault.DEFAULT_ADMIN_ROLE(), owner), + "Factory owner should have DEFAULT_ADMIN_ROLE" + ); } function test_deployVault_RevertZeroAddress() public { @@ -72,7 +75,11 @@ contract MasterVaultFactoryTest is Test { } function test_beaconOwnership() public { - assertEq(UpgradeableBeacon(factory.beaconProxyFactory().beacon()).owner(), owner, "Beacon owner should be the factory owner"); + assertEq( + UpgradeableBeacon(factory.beaconProxyFactory().beacon()).owner(), + owner, + "Beacon owner should be the factory owner" + ); } function test_ownerCanUpgradeBeacon() public { @@ -82,7 +89,11 @@ contract MasterVaultFactoryTest is Test { vm.prank(owner); beacon.upgradeTo(address(newImplementation)); - assertEq(UpgradeableBeacon(factory.beaconProxyFactory().beacon()).implementation(), address(newImplementation), "Beacon implementation should be updated"); + assertEq( + UpgradeableBeacon(factory.beaconProxyFactory().beacon()).implementation(), + address(newImplementation), + "Beacon implementation should be updated" + ); } function test_nonOwnerCannotUpgradeBeacon() public { @@ -106,9 +117,19 @@ contract MasterVaultFactoryTest is Test { vm.prank(owner); beacon.upgradeTo(address(newImplementation)); - assertEq(UpgradeableBeacon(factory.beaconProxyFactory().beacon()).implementation(), address(newImplementation), "Beacon should point to new implementation"); - - assertTrue(MasterVault(vault1).hasRole(MasterVault(vault1).DEFAULT_ADMIN_ROLE(), owner), "Vault1 should have owner as admin"); - assertTrue(MasterVault(vault2).hasRole(MasterVault(vault2).DEFAULT_ADMIN_ROLE(), owner), "Vault2 should have owner as admin"); + assertEq( + UpgradeableBeacon(factory.beaconProxyFactory().beacon()).implementation(), + address(newImplementation), + "Beacon should point to new implementation" + ); + + assertTrue( + MasterVault(vault1).hasRole(MasterVault(vault1).DEFAULT_ADMIN_ROLE(), owner), + "Vault1 should have owner as admin" + ); + assertTrue( + MasterVault(vault2).hasRole(MasterVault(vault2).DEFAULT_ADMIN_ROLE(), owner), + "Vault2 should have owner as admin" + ); } -} \ No newline at end of file +} diff --git a/test-foundry/libraries/vault/MasterVaultFee.t.sol b/test-foundry/libraries/vault/MasterVaultFee.t.sol index c202d350b..a7b26f187 100644 --- a/test-foundry/libraries/vault/MasterVaultFee.t.sol +++ b/test-foundry/libraries/vault/MasterVaultFee.t.sol @@ -77,7 +77,10 @@ contract MasterVaultFeeTest is MasterVaultCoreTest { vm.prank(vaultManager); vault.setPerformanceFee(true); - assertTrue(vault.enablePerformanceFee(), "Vault manager should be able to set performance fee"); + assertTrue( + vault.enablePerformanceFee(), + "Vault manager should be able to set performance fee" + ); } function test_setBeneficiary_withFeeManagerRole() public { @@ -87,7 +90,11 @@ contract MasterVaultFeeTest is MasterVaultCoreTest { vm.prank(feeManager); vault.setBeneficiary(beneficiaryAddress); - assertEq(vault.beneficiary(), beneficiaryAddress, "Fee manager should be able to set beneficiary"); + assertEq( + vault.beneficiary(), + beneficiaryAddress, + "Fee manager should be able to set beneficiary" + ); } function test_deposit_updatesTotalPrincipal() public { @@ -100,7 +107,11 @@ contract MasterVaultFeeTest is MasterVaultCoreTest { vault.deposit(depositAmount, user); - assertEq(vault.totalPrincipal(), int256(depositAmount), "Total principal should equal deposit amount"); + assertEq( + vault.totalPrincipal(), + int256(depositAmount), + "Total principal should equal deposit amount" + ); vm.stopPrank(); } @@ -115,7 +126,11 @@ contract MasterVaultFeeTest is MasterVaultCoreTest { uint256 assets = vault.mint(shares, user); - assertEq(vault.totalPrincipal(), int256(assets), "Total principal should equal assets deposited"); + assertEq( + vault.totalPrincipal(), + int256(assets), + "Total principal should equal assets deposited" + ); vm.stopPrank(); } @@ -127,12 +142,20 @@ contract MasterVaultFeeTest is MasterVaultCoreTest { token.approve(address(vault), depositAmount); vault.deposit(depositAmount, user); - assertEq(vault.totalPrincipal(), int256(depositAmount), "Total principal should equal deposit amount"); + assertEq( + vault.totalPrincipal(), + int256(depositAmount), + "Total principal should equal deposit amount" + ); uint256 withdrawAmount = 100; vault.withdraw(withdrawAmount, user, user); - assertEq(vault.totalPrincipal(), int256(depositAmount - withdrawAmount), "Total principal should decrease by withdraw amount"); + assertEq( + vault.totalPrincipal(), + int256(depositAmount - withdrawAmount), + "Total principal should decrease by withdraw amount" + ); vm.stopPrank(); } @@ -144,12 +167,20 @@ contract MasterVaultFeeTest is MasterVaultCoreTest { token.approve(address(vault), depositAmount); uint256 shares = vault.deposit(depositAmount, user); - assertEq(vault.totalPrincipal(), int256(depositAmount), "Total principal should equal deposit amount"); + assertEq( + vault.totalPrincipal(), + int256(depositAmount), + "Total principal should equal deposit amount" + ); uint256 sharesToRedeem = shares / 2; uint256 assetsReceived = vault.redeem(sharesToRedeem, user, user); - assertEq(vault.totalPrincipal(), int256(depositAmount - assetsReceived), "Total principal should decrease by redeemed assets"); + assertEq( + vault.totalPrincipal(), + int256(depositAmount - assetsReceived), + "Total principal should decrease by redeemed assets" + ); vm.stopPrank(); } @@ -179,7 +210,11 @@ contract MasterVaultFeeTest is MasterVaultCoreTest { vault.deposit(depositAmount, user); vm.stopPrank(); - assertEq(vault.totalPrincipal(), int256(depositAmount), "Total principal should equal deposit"); + assertEq( + vault.totalPrincipal(), + int256(depositAmount), + "Total principal should equal deposit" + ); assertEq(vault.totalAssets(), depositAmount, "Total assets should equal deposit"); assertEq(vault.totalProfit(), 0, "Should have no profit initially"); @@ -187,7 +222,11 @@ contract MasterVaultFeeTest is MasterVaultCoreTest { token.mint(); assertEq(vault.totalAssets(), depositAmount * 2, "Total assets should be doubled"); - assertEq(vault.totalProfit(), int256(depositAmount), "Profit should equal initial deposit amount"); + assertEq( + vault.totalProfit(), + int256(depositAmount), + "Profit should equal initial deposit amount" + ); uint256 beneficiaryBalanceBefore = token.balanceOf(beneficiaryAddress); @@ -195,8 +234,16 @@ contract MasterVaultFeeTest is MasterVaultCoreTest { emit PerformanceFeesWithdrawn(beneficiaryAddress, depositAmount); vault.withdrawPerformanceFees(); - assertEq(token.balanceOf(beneficiaryAddress), beneficiaryBalanceBefore + depositAmount, "Beneficiary should receive profit"); - assertEq(vault.totalAssets(), depositAmount, "Vault assets should decrease by profit amount"); + assertEq( + token.balanceOf(beneficiaryAddress), + beneficiaryBalanceBefore + depositAmount, + "Beneficiary should receive profit" + ); + assertEq( + vault.totalAssets(), + depositAmount, + "Vault assets should decrease by profit amount" + ); } event PerformanceFeeToggled(bool enabled); diff --git a/test-foundry/libraries/vault/MasterVaultSharePriceNoFee.t.sol b/test-foundry/libraries/vault/MasterVaultSharePriceNoFee.t.sol index 343ed9067..32df38007 100644 --- a/test-foundry/libraries/vault/MasterVaultSharePriceNoFee.t.sol +++ b/test-foundry/libraries/vault/MasterVaultSharePriceNoFee.t.sol @@ -5,7 +5,7 @@ import { MasterVaultCoreTest } from "./MasterVaultCore.t.sol"; import { MasterVault } from "../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; contract MasterVaultSharePriceNoFeeTest is MasterVaultCoreTest { - /// @dev example 1. sharePrice = 1e18 means we need to pay 1 asset to get 1 share + /// @dev example 1. sharePrice = 1e18 means we need to pay 1 asset to get 1 share function test_sharePrice_example1_oneToOne() public { vm.startPrank(user); token.mint(); @@ -18,7 +18,7 @@ contract MasterVaultSharePriceNoFeeTest is MasterVaultCoreTest { assertEq(price, 1e18, "Share price should be 1e18 for 1:1 ratio"); } - /// @dev example 2. sharePrice = 2 * 1e18 means we need to pay 2 asset to get 1 share + /// @dev example 2. sharePrice = 2 * 1e18 means we need to pay 2 asset to get 1 share function test_sharePrice_example2_twoToOne() public { vm.startPrank(user); token.mint(); @@ -36,7 +36,7 @@ contract MasterVaultSharePriceNoFeeTest is MasterVaultCoreTest { assertEq(price, 2e18, "Share price should be 2e18 when assets are 2x shares"); } - /// @dev example 3. sharePrice = 0.1 * 1e18 means we need to pay 0.1 asset to get 1 share + /// @dev example 3. sharePrice = 0.1 * 1e18 means we need to pay 0.1 asset to get 1 share function test_sharePrice_example3_oneToTen() public { // This scenario would require shares > assets, which happens in loss scenarios // We'll simulate by having 1000 shares but only 100 assets diff --git a/test-foundry/libraries/vault/MasterVaultSharePriceWithFee.t.sol b/test-foundry/libraries/vault/MasterVaultSharePriceWithFee.t.sol index b50413c8f..55c01efad 100644 --- a/test-foundry/libraries/vault/MasterVaultSharePriceWithFee.t.sol +++ b/test-foundry/libraries/vault/MasterVaultSharePriceWithFee.t.sol @@ -26,7 +26,11 @@ contract MasterVaultSharePriceWithFeeTest is MasterVaultCoreTest { // With performance fee enabled, sharePrice should be capped at 1e18 even though actual ratio is 2:1 uint256 price = vault.sharePrice(); - assertEq(price, 1e18, "Share price should be capped at 1e18 when performance fee is enabled"); + assertEq( + price, + 1e18, + "Share price should be capped at 1e18 when performance fee is enabled" + ); } /// @dev When vault has losses, sharePrice can be below 1e18 even with performance fee diff --git a/test-foundry/libraries/vault/MasterVaultSubVaultManagement.t.sol b/test-foundry/libraries/vault/MasterVaultSubVaultManagement.t.sol index 664d4d7fb..a6457f787 100644 --- a/test-foundry/libraries/vault/MasterVaultSubVaultManagement.t.sol +++ b/test-foundry/libraries/vault/MasterVaultSubVaultManagement.t.sol @@ -39,7 +39,11 @@ contract MasterVaultSubVaultManagementTest is MasterVaultCoreTest { vault.deposit(depositAmount, user); vm.stopPrank(); - assertEq(address(vault.subVault()), address(0), "SubVault should be zero address initially"); + assertEq( + address(vault.subVault()), + address(0), + "SubVault should be zero address initially" + ); assertEq(token.balanceOf(address(vault)), depositAmount, "Main vault should hold tokens"); // Set subVault with minSubVaultExchRateWad = 1e18 (1:1 ratio) @@ -49,8 +53,16 @@ contract MasterVaultSubVaultManagementTest is MasterVaultCoreTest { assertEq(address(vault.subVault()), address(subVault), "SubVault should be set"); assertEq(token.balanceOf(address(vault)), 0, "Main vault should have no tokens"); - assertEq(token.balanceOf(address(subVault)), depositAmount, "SubVault should hold the tokens"); - assertEq(subVault.balanceOf(address(vault)), depositAmount, "Main vault should have subVault shares"); + assertEq( + token.balanceOf(address(subVault)), + depositAmount, + "SubVault should hold the tokens" + ); + assertEq( + subVault.balanceOf(address(vault)), + depositAmount, + "Main vault should have subVault shares" + ); } function test_setSubVault_revert_SubVaultAlreadySet() public { @@ -117,9 +129,17 @@ contract MasterVaultSubVaultManagementTest is MasterVaultCoreTest { vault.revokeSubVault(1e18); assertEq(address(vault.subVault()), address(0), "SubVault should be zero address"); - assertEq(token.balanceOf(address(vault)), depositAmount, "Main vault should have tokens back"); + assertEq( + token.balanceOf(address(vault)), + depositAmount, + "Main vault should have tokens back" + ); assertEq(token.balanceOf(address(subVault)), 0, "SubVault should have no tokens"); - assertEq(subVault.balanceOf(address(vault)), 0, "Main vault should have no subVault shares"); + assertEq( + subVault.balanceOf(address(vault)), + 0, + "Main vault should have no subVault shares" + ); } function test_revokeSubVault_revert_NoExistingSubVault() public { diff --git a/test-foundry/libraries/vault/scenarios/MasterVaultScenario01.t.sol b/test-foundry/libraries/vault/scenarios/MasterVaultScenario01.t.sol index ceab7a263..0ce21c7bf 100644 --- a/test-foundry/libraries/vault/scenarios/MasterVaultScenario01.t.sol +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario01.t.sol @@ -65,6 +65,10 @@ contract MasterVaultScenario01Test is MasterVaultCoreTest { assertEq(token.balanceOf(userB), userBInitialBalance, "User B should have no gain/loss"); // Verify beneficiary received nothing - assertEq(token.balanceOf(beneficiaryAddress), 0, "Beneficiary should have 0 (nothing claimed)"); + assertEq( + token.balanceOf(beneficiaryAddress), + 0, + "Beneficiary should have 0 (nothing claimed)" + ); } } diff --git a/test-foundry/libraries/vault/scenarios/MasterVaultScenario02.t.sol b/test-foundry/libraries/vault/scenarios/MasterVaultScenario02.t.sol index 2bf27248c..7af484a64 100644 --- a/test-foundry/libraries/vault/scenarios/MasterVaultScenario02.t.sol +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario02.t.sol @@ -71,14 +71,26 @@ contract MasterVaultScenario02Test is MasterVaultCoreTest { assertEq(vault.sharePrice(), 1e18, "Share price should be 1e18"); // Verify user holdings change - assertEq(token.balanceOf(userA), userAInitialBalance - 25, "User A should lose 25 USDC (25% of 100)"); - assertEq(token.balanceOf(userB), userBInitialBalance - 75, "User B should lose 75 USDC (25% of 300)"); + assertEq( + token.balanceOf(userA), + userAInitialBalance - 25, + "User A should lose 25 USDC (25% of 100)" + ); + assertEq( + token.balanceOf(userB), + userBInitialBalance - 75, + "User B should lose 75 USDC (25% of 300)" + ); // Verify assets received assertEq(assetsReceivedA, 75, "User A should receive 75 USDC (100 - 25)"); assertEq(assetsReceivedB, 225, "User B should receive 225 USDC (300 - 75)"); // Verify beneficiary received nothing - assertEq(token.balanceOf(beneficiaryAddress), 0, "Beneficiary should have 0 (nothing claimed)"); + assertEq( + token.balanceOf(beneficiaryAddress), + 0, + "Beneficiary should have 0 (nothing claimed)" + ); } } diff --git a/test-foundry/libraries/vault/scenarios/MasterVaultScenario03.t.sol b/test-foundry/libraries/vault/scenarios/MasterVaultScenario03.t.sol index b79a7f361..bacb6d3bc 100644 --- a/test-foundry/libraries/vault/scenarios/MasterVaultScenario03.t.sol +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario03.t.sol @@ -87,6 +87,10 @@ contract MasterVaultScenario03Test is MasterVaultCoreTest { assertEq(assetsReceivedB, 300, "User B should receive 300 USDC (their principal)"); // Verify beneficiary received all profits - assertEq(token.balanceOf(beneficiaryAddress), 100, "Beneficiary should have 100 USDC (all profits)"); + assertEq( + token.balanceOf(beneficiaryAddress), + 100, + "Beneficiary should have 100 USDC (all profits)" + ); } } diff --git a/test-foundry/libraries/vault/scenarios/MasterVaultScenario04.t.sol b/test-foundry/libraries/vault/scenarios/MasterVaultScenario04.t.sol index d89e21051..26d5dc00d 100644 --- a/test-foundry/libraries/vault/scenarios/MasterVaultScenario04.t.sol +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario04.t.sol @@ -109,6 +109,10 @@ contract MasterVaultScenario04Test is MasterVaultCoreTest { assertEq(assetsReceivedB, 600, "User B should receive 600 USDC (all deposits)"); // Verify beneficiary still has all profits - assertEq(token.balanceOf(beneficiaryAddress), 100, "Beneficiary should have 100 USDC (all profits)"); + assertEq( + token.balanceOf(beneficiaryAddress), + 100, + "Beneficiary should have 100 USDC (all profits)" + ); } } diff --git a/test-foundry/libraries/vault/scenarios/MasterVaultScenario05.t.sol b/test-foundry/libraries/vault/scenarios/MasterVaultScenario05.t.sol index 2c57aca3e..ebcce708b 100644 --- a/test-foundry/libraries/vault/scenarios/MasterVaultScenario05.t.sol +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario05.t.sol @@ -71,7 +71,11 @@ contract MasterVaultScenario05Test is MasterVaultCoreTest { vault.deposit(300, userB); vm.stopPrank(); - assertEq(vault.totalPrincipal(), 800, "Total principal should be 800 after second deposits"); + assertEq( + vault.totalPrincipal(), + 800, + "Total principal should be 800 after second deposits" + ); assertEq(vault.totalAssets(), 800, "Total assets should be 800 after second deposits"); // Step 7: User A redeems 200 shares @@ -133,6 +137,10 @@ contract MasterVaultScenario05Test is MasterVaultCoreTest { assertEq(assetsReceivedB, 225, "User B should receive 225 USDC in final redemption"); // Verify beneficiary still has profits - assertEq(token.balanceOf(beneficiaryAddress), 100, "Beneficiary should have 100 USDC (all profits)"); + assertEq( + token.balanceOf(beneficiaryAddress), + 100, + "Beneficiary should have 100 USDC (all profits)" + ); } } diff --git a/test-foundry/libraries/vault/scenarios/MasterVaultScenario06.t.sol b/test-foundry/libraries/vault/scenarios/MasterVaultScenario06.t.sol index bdc079b9b..584af6e19 100644 --- a/test-foundry/libraries/vault/scenarios/MasterVaultScenario06.t.sol +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario06.t.sol @@ -70,7 +70,11 @@ contract MasterVaultScenario06Test is MasterVaultCoreTest { assertEq(vault.totalAssets(), 300, "Vault should have 300 USDC after loss"); assertEq(vault.totalPrincipal(), int256(400), "Total principal should still be 400"); - assertEq(vault.totalProfit(), -100, "Total profit should be -100 after loss (represents 100 loss)"); + assertEq( + vault.totalProfit(), + -100, + "Total profit should be -100 after loss (represents 100 loss)" + ); // Step 6: User A redeems 100 shares vm.prank(userA); @@ -97,6 +101,10 @@ contract MasterVaultScenario06Test is MasterVaultCoreTest { assertEq(assetsReceivedB, 225, "User B should receive 225 USDC (300 - 25% loss)"); // Verify beneficiary keeps all profits - assertEq(token.balanceOf(beneficiaryAddress), 100, "Beneficiary should have 100 USDC (all profits)"); + assertEq( + token.balanceOf(beneficiaryAddress), + 100, + "Beneficiary should have 100 USDC (all profits)" + ); } } diff --git a/test-foundry/libraries/vault/scenarios/MasterVaultScenario07.t.sol b/test-foundry/libraries/vault/scenarios/MasterVaultScenario07.t.sol index 307d4f866..06339334d 100644 --- a/test-foundry/libraries/vault/scenarios/MasterVaultScenario07.t.sol +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario07.t.sol @@ -111,6 +111,10 @@ contract MasterVaultScenario07Test is MasterVaultCoreTest { assertEq(token.balanceOf(userB), userBInitialBalance - 75, "User B should lose 75 USDC"); // Verify beneficiary keeps all profits - assertEq(token.balanceOf(beneficiaryAddress), 100, "Beneficiary should have 100 USDC (all profits)"); + assertEq( + token.balanceOf(beneficiaryAddress), + 100, + "Beneficiary should have 100 USDC (all profits)" + ); } } diff --git a/test-foundry/libraries/vault/scenarios/MasterVaultScenario08.t.sol b/test-foundry/libraries/vault/scenarios/MasterVaultScenario08.t.sol index 720159556..2765bf8b8 100644 --- a/test-foundry/libraries/vault/scenarios/MasterVaultScenario08.t.sol +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario08.t.sol @@ -77,7 +77,11 @@ contract MasterVaultScenario08Test is MasterVaultCoreTest { uint256 sharesB2 = vault.deposit(300, userB); vm.stopPrank(); - assertEq(sharesB2, 400, "User B should receive 400 shares for 300 USDC deposit at 0.75e18 sharePrice"); + assertEq( + sharesB2, + 400, + "User B should receive 400 shares for 300 USDC deposit at 0.75e18 sharePrice" + ); assertEq(vault.totalPrincipal(), 700, "Total principal should be 700"); assertEq(vault.totalAssets(), 600, "Total assets should be 600"); @@ -106,9 +110,17 @@ contract MasterVaultScenario08Test is MasterVaultCoreTest { // Verify assets received assertEq(assetsReceivedA, 75, "User A should receive 75 USDC"); - assertEq(assetsReceivedB, 525, "User B should receive 525 USDC (225 from first 300 shares + 300 from 400 shares)"); + assertEq( + assetsReceivedB, + 525, + "User B should receive 525 USDC (225 from first 300 shares + 300 from 400 shares)" + ); // Verify beneficiary keeps all profits - assertEq(token.balanceOf(beneficiaryAddress), 100, "Beneficiary should have 100 USDC (all profits)"); + assertEq( + token.balanceOf(beneficiaryAddress), + 100, + "Beneficiary should have 100 USDC (all profits)" + ); } } diff --git a/test-foundry/libraries/vault/scenarios/MasterVaultScenario09.t.sol b/test-foundry/libraries/vault/scenarios/MasterVaultScenario09.t.sol index abbeab92b..8c3f26a74 100644 --- a/test-foundry/libraries/vault/scenarios/MasterVaultScenario09.t.sol +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario09.t.sol @@ -86,18 +86,34 @@ contract MasterVaultScenario09Test is MasterVaultCoreTest { // Verify final state assertEq(vault.totalPrincipal(), int256(0), "Total principal should be 0"); - assertEq(vault.totalAssets(), 1_000_000, "Vault assets should be 1,000,000 (the donated profit)"); + assertEq( + vault.totalAssets(), + 1_000_000, + "Vault assets should be 1,000,000 (the donated profit)" + ); assertEq(vault.totalSupply(), 0, "Total supply should be 0"); assertEq(vault.sharePrice(), 1e18, "Share price should be 1e18"); // Verify user holdings change // User A: deposited 1, received back 1, change = 0 // User B: deposited 100, received back 100, change = 0 - assertEq(token.balanceOf(userA), userAInitialBalance, "User A should break even (0 change)"); - assertEq(token.balanceOf(userB), userBInitialBalance, "User B should break even (0 change)"); + assertEq( + token.balanceOf(userA), + userAInitialBalance, + "User A should break even (0 change)" + ); + assertEq( + token.balanceOf(userB), + userBInitialBalance, + "User B should break even (0 change)" + ); // Verify beneficiary has not claimed fees yet - assertEq(token.balanceOf(beneficiaryAddress), 0, "Beneficiary should have 0 USDC (fees not claimed yet)"); + assertEq( + token.balanceOf(beneficiaryAddress), + 0, + "Beneficiary should have 0 USDC (fees not claimed yet)" + ); // Verify the 1M USDC remains in vault as profit assertEq(vault.totalProfit(), 1_000_000, "Total profit should be 1,000,000 USDC"); diff --git a/test-foundry/libraries/vault/scenarios/MasterVaultScenario10.t.sol b/test-foundry/libraries/vault/scenarios/MasterVaultScenario10.t.sol index e596b4c93..ba2c6ff01 100644 --- a/test-foundry/libraries/vault/scenarios/MasterVaultScenario10.t.sol +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario10.t.sol @@ -57,8 +57,16 @@ contract MasterVaultScenario10Test is MasterVaultCoreTest { vm.stopPrank(); // User B gets 0 shares: 100 / 1,000,001 rounds down to 0 - assertEq(sharesB, 0, "User B should receive 0 shares due to rounding (100 / 1,000,001 = 0)"); - assertEq(vault.totalSupply(), 1, "Total supply should still be 1 (only attacker has shares)"); + assertEq( + sharesB, + 0, + "User B should receive 0 shares due to rounding (100 / 1,000,001 = 0)" + ); + assertEq( + vault.totalSupply(), + 1, + "Total supply should still be 1 (only attacker has shares)" + ); assertEq(vault.totalAssets(), 1_000_101, "Total assets should be 1,000,101"); assertEq(vault.totalPrincipal(), int256(101), "Total principal should be 101"); @@ -70,7 +78,11 @@ contract MasterVaultScenario10Test is MasterVaultCoreTest { assertEq(assetsReceivedA, 1_000_101, "User A should receive all 1,000,101 USDC"); // Verify final state - assertEq(vault.totalPrincipal(), int256(-1_000_000), "Total principal should be -1,000,000"); + assertEq( + vault.totalPrincipal(), + int256(-1_000_000), + "Total principal should be -1,000,000" + ); assertEq(vault.totalAssets(), 0, "Vault assets should be 0"); assertEq(vault.totalSupply(), 0, "Total supply should be 0"); assertEq(vault.sharePrice(), 1e18, "Share price should be 1e18"); @@ -78,7 +90,11 @@ contract MasterVaultScenario10Test is MasterVaultCoreTest { // Verify user holdings change - User A profits, User B loses // User A: deposited 1, received back 1,000,101, profit = 1,000,100 // User B: deposited 100, received back 0, loss = 100 - assertEq(token.balanceOf(userA), userAInitialBalance + 1_000_100, "User A should gain 1,000,100 USDC"); + assertEq( + token.balanceOf(userA), + userAInitialBalance + 1_000_100, + "User A should gain 1,000,100 USDC" + ); assertEq(token.balanceOf(userB), userBInitialBalance - 100, "User B should lose 100 USDC"); // Verify no beneficiary involved From a45181322995bbc4de7b107927675f8c0c605670 Mon Sep 17 00:00:00 2001 From: Wael Almattar Date: Tue, 2 Dec 2025 01:23:33 +0100 Subject: [PATCH 28/30] fix: return shares at 1:1 rate if total supply is zeero --- .../libraries/vault/MasterVault.sol | 2 +- .../libraries/vault/MasterVault.t.sol | 71 ++++++++++++++----- 2 files changed, 53 insertions(+), 20 deletions(-) diff --git a/contracts/tokenbridge/libraries/vault/MasterVault.sol b/contracts/tokenbridge/libraries/vault/MasterVault.sol index 536ffd0ad..0b5610acc 100644 --- a/contracts/tokenbridge/libraries/vault/MasterVault.sol +++ b/contracts/tokenbridge/libraries/vault/MasterVault.sol @@ -220,7 +220,7 @@ contract MasterVault is : _totalAssets; if (_totalSupply == 0) { - return 1; + return shares; } uint256 _assets = MathUpgradeable.mulDiv(shares, _effectiveAssets, _totalSupply, rounding); diff --git a/test-foundry/libraries/vault/MasterVault.t.sol b/test-foundry/libraries/vault/MasterVault.t.sol index 2419a6619..42563d9c2 100644 --- a/test-foundry/libraries/vault/MasterVault.t.sol +++ b/test-foundry/libraries/vault/MasterVault.t.sol @@ -9,6 +9,11 @@ import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; contract MasterVaultTest is MasterVaultCoreTest { // first deposit function test_deposit() public { + address _assetsHoldingVault = address(vault.subVault()) == address(0) + ? address(vault) + : address(vault.subVault()); + uint256 _assetsHoldingVaultBalanceBefore = token.balanceOf(_assetsHoldingVault); + vm.startPrank(user); token.mint(); uint256 depositAmount = 100; @@ -17,49 +22,53 @@ contract MasterVaultTest is MasterVaultCoreTest { uint256 shares = vault.deposit(depositAmount, user); + uint256 _assetsHoldingVaultBalanceAfter = token.balanceOf(_assetsHoldingVault); + uint256 diff = _assetsHoldingVaultBalanceAfter - _assetsHoldingVaultBalanceBefore; + assertEq(vault.balanceOf(user), shares, "User should receive shares"); assertEq(vault.totalAssets(), depositAmount, "Vault should hold deposited assets"); assertEq(vault.totalSupply(), shares, "Total supply should equal shares minted"); - address _assetsHoldingVault = address(vault.subVault()) == address(0) - ? address(vault) - : address(vault.subVault()); - - assertEq( - token.balanceOf(_assetsHoldingVault), - depositAmount, - "Vault should hold the tokens" - ); + assertEq(diff, depositAmount, "Vault should increase holding of assets"); assertGt(token.balanceOf(_assetsHoldingVault), 0, "Vault should hold the tokens"); - assertEq( - vault.totalSupply(), - token.balanceOf(_assetsHoldingVault), - "First deposit should be at a rate of 1" - ); + assertEq(vault.totalSupply(), diff, "First deposit should be at a rate of 1"); vm.stopPrank(); } // first mint function test_mint() public { + address _assetsHoldingVault = address(vault.subVault()) == address(0) + ? address(vault) + : address(vault.subVault()); + + uint256 _assetsHoldingVaultBalanceBefore = token.balanceOf(_assetsHoldingVault); + vm.startPrank(user); token.mint(); uint256 sharesToMint = 100; token.approve(address(vault), type(uint256).max); + // assertEq(1, vault.totalAssets(), "First mint should be at a rate of 1"); // 0 + // assertEq(1, vault.totalSupply(), "First mint should be at a rate of 1"); // 0 + + uint256 assetsCost = vault.mint(sharesToMint, user); - address _assetsHoldingVault = address(vault.subVault()) == address(0) - ? address(vault) - : address(vault.subVault()); + uint256 _assetsHoldingVaultBalanceAfter = token.balanceOf(_assetsHoldingVault); assertEq(vault.balanceOf(user), sharesToMint, "User should receive requested shares"); assertEq(vault.totalSupply(), sharesToMint, "Total supply should equal shares minted"); assertEq(vault.totalAssets(), assetsCost, "Vault should hold the assets deposited"); - assertEq(token.balanceOf(_assetsHoldingVault), assetsCost, "Vault should hold the tokens"); + assertEq( + _assetsHoldingVaultBalanceAfter - _assetsHoldingVaultBalanceBefore, + assetsCost, + "Vault should hold the tokens" + ); + assertEq(vault.totalSupply(), vault.totalAssets(), "First mint should be at a rate of 1"); vm.stopPrank(); } @@ -107,10 +116,34 @@ contract MasterVaultTest is MasterVaultCoreTest { } } -contract MasterVaultTestWithSubvault is MasterVaultTest { +contract MasterVaultTestWithSubvaultFresh is MasterVaultTest { + function setUp() public override { + super.setUp(); + MockSubVault _subvault = new MockSubVault(IERC20(address(token)), "TestSubvault", "TSV"); + vault.setSubVault(IERC4626(address(_subvault)), 0); + } +} + +contract MasterVaultTestWithSubvaultHoldingAssets is MasterVaultTest { function setUp() public override { super.setUp(); + MockSubVault _subvault = new MockSubVault(IERC20(address(token)), "TestSubvault", "TSV"); + uint256 _initAmount = 97659743; + token.mint(_initAmount); + token.approve(address(_subvault), _initAmount); + _subvault.deposit(_initAmount, address(this)); + assertEq( + _initAmount, + _subvault.totalAssets(), + "subvault should be initiated with assets = _initAmount" + ); + assertEq( + _initAmount, + _subvault.totalSupply(), + "subvault should be initiated with shares = _initAmount" + ); + vault.setSubVault(IERC4626(address(_subvault)), 0); } } From b96feae06e147c08adde51cd5042d743f9ec2bff Mon Sep 17 00:00:00 2001 From: Wael Almattar Date: Sat, 6 Dec 2025 07:54:34 +0100 Subject: [PATCH 29/30] tests: add more tests --- .../libraries/vault/MasterVaultCore.t.sol | 133 +---------------- .../libraries/vault/MasterVaultFee.t.sol | 46 +++++- .../libraries/vault/MasterVaultPause.t.sol | 134 ++++++++++++++++++ .../vault/MasterVaultSharePriceNoFee.t.sol | 80 ++++++++--- 4 files changed, 243 insertions(+), 150 deletions(-) create mode 100644 test-foundry/libraries/vault/MasterVaultPause.t.sol diff --git a/test-foundry/libraries/vault/MasterVaultCore.t.sol b/test-foundry/libraries/vault/MasterVaultCore.t.sol index ffc56dff5..6daf0f840 100644 --- a/test-foundry/libraries/vault/MasterVaultCore.t.sol +++ b/test-foundry/libraries/vault/MasterVaultCore.t.sol @@ -6,7 +6,10 @@ import { MasterVault } from "../../../contracts/tokenbridge/libraries/vault/Mast import { TestERC20 } from "../../../contracts/tokenbridge/test/TestERC20.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { UpgradeableBeacon } from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; -import { BeaconProxyFactory, ClonableBeaconProxy } from "../../../contracts/tokenbridge/libraries/ClonableBeaconProxy.sol"; +import { + BeaconProxyFactory, + ClonableBeaconProxy +} from "../../../contracts/tokenbridge/libraries/ClonableBeaconProxy.sol"; import { IAccessControl } from "@openzeppelin/contracts/access/IAccessControl.sol"; contract MasterVaultCoreTest is Test { @@ -35,132 +38,4 @@ contract MasterVaultCoreTest is Test { vault.initialize(IERC20(address(token)), name, symbol, address(this)); vault.unpause(); } - - /// todo: - /// test pause func with deposits/withdrawals - /// test deposit, withdraw, mint, redeem - - 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"); - - assertTrue( - vault.hasRole(vault.DEFAULT_ADMIN_ROLE(), address(this)), - "Should have DEFAULT_ADMIN_ROLE" - ); - assertTrue( - vault.hasRole(vault.VAULT_MANAGER_ROLE(), address(this)), - "Should have VAULT_MANAGER_ROLE" - ); - assertTrue( - vault.hasRole(vault.FEE_MANAGER_ROLE(), address(this)), - "Should have FEE_MANAGER_ROLE" - ); - } - - function test_beaconUpgrade() public { - vm.startPrank(user); - token.mint(); - uint256 depositAmount = token.balanceOf(user); - token.approve(address(vault), depositAmount); - vault.deposit(depositAmount, user); - vm.stopPrank(); - - address oldImplementation = beacon.implementation(); - assertEq( - oldImplementation, - address(beacon.implementation()), - "Should have initial implementation" - ); - - MasterVault newImplementation = new MasterVault(); - beacon.upgradeTo(address(newImplementation)); - - assertEq( - beacon.implementation(), - address(newImplementation), - "Beacon should point to new implementation" - ); - assertTrue( - beacon.implementation() != oldImplementation, - "Implementation should have changed" - ); - - assertEq(vault.name(), name, "Name should remain after upgrade"); - } - - function test_initialize_pausedByDefault() public { - // Deploy a fresh vault to test initial paused state - MasterVault implementation = new MasterVault(); - UpgradeableBeacon testBeacon = new UpgradeableBeacon(address(implementation)); - BeaconProxyFactory testFactory = new BeaconProxyFactory(); - testFactory.initialize(address(testBeacon)); - - bytes32 salt = keccak256("test_paused"); - address proxyAddress = testFactory.createProxy(salt); - MasterVault testVault = MasterVault(proxyAddress); - - testVault.initialize(IERC20(address(token)), "Test", "TST", address(this)); - - assertTrue(testVault.paused(), "Vault should be paused immediately after initialization"); - } - - function test_initialize_pauserRole() public { - assertTrue(vault.hasRole(vault.PAUSER_ROLE(), address(this)), "Should have PAUSER_ROLE"); - assertFalse(vault.paused(), "Should not be paused after unpause in setUp"); - } - - function test_pause() public { - assertFalse(vault.paused(), "Should not be paused after unpause in setUp"); - - vault.pause(); - - assertTrue(vault.paused(), "Should be paused"); - } - - function test_unpause() public { - vault.pause(); - assertTrue(vault.paused(), "Should be paused"); - - vault.unpause(); - - assertFalse(vault.paused(), "Should not be paused"); - } - - function test_pause_revert_NotPauser() public { - vm.prank(user); - vm.expectRevert(); - vault.pause(); - } - - function test_unpause_revert_NotPauser() public { - vault.pause(); - - vm.prank(user); - vm.expectRevert(); - vault.unpause(); - } - - function test_multiplePausers() public { - address pauser1 = address(0x3333); - address pauser2 = address(0x4444); - - vault.grantRole(vault.PAUSER_ROLE(), pauser1); - vault.grantRole(vault.PAUSER_ROLE(), pauser2); - - assertTrue(vault.hasRole(vault.PAUSER_ROLE(), pauser1), "Pauser1 should have PAUSER_ROLE"); - assertTrue(vault.hasRole(vault.PAUSER_ROLE(), pauser2), "Pauser2 should have PAUSER_ROLE"); - - vm.prank(pauser1); - vault.pause(); - assertTrue(vault.paused(), "Should be paused by pauser1"); - - vm.prank(pauser2); - vault.unpause(); - assertFalse(vault.paused(), "Should be unpaused by pauser2"); - } } diff --git a/test-foundry/libraries/vault/MasterVaultFee.t.sol b/test-foundry/libraries/vault/MasterVaultFee.t.sol index a7b26f187..93682e87e 100644 --- a/test-foundry/libraries/vault/MasterVaultFee.t.sol +++ b/test-foundry/libraries/vault/MasterVaultFee.t.sol @@ -3,6 +3,9 @@ pragma solidity ^0.8.0; import { MasterVaultCoreTest } from "./MasterVaultCore.t.sol"; import { MasterVault } from "../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; +import { MockSubVault } from "../../../contracts/tokenbridge/test/MockSubVault.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; contract MasterVaultFeeTest is MasterVaultCoreTest { address public beneficiaryAddress = address(0x9999); @@ -203,6 +206,10 @@ contract MasterVaultFeeTest is MasterVaultCoreTest { vault.setPerformanceFee(true); vault.setBeneficiary(beneficiaryAddress); + address _assetsHoldingVault = address(vault.subVault()) == address(0) + ? address(vault) + : address(vault.subVault()); + vm.startPrank(user); token.mint(); uint256 depositAmount = token.balanceOf(user); @@ -218,8 +225,11 @@ contract MasterVaultFeeTest is MasterVaultCoreTest { assertEq(vault.totalAssets(), depositAmount, "Total assets should equal deposit"); assertEq(vault.totalProfit(), 0, "Should have no profit initially"); - vm.prank(address(vault)); - token.mint(); + uint256 assetsHoldingVaultBalance = token.balanceOf(_assetsHoldingVault); + uint256 amountToMint = assetsHoldingVaultBalance; + + vm.prank(_assetsHoldingVault); + token.mint(amountToMint); assertEq(vault.totalAssets(), depositAmount * 2, "Total assets should be doubled"); assertEq( @@ -250,3 +260,35 @@ contract MasterVaultFeeTest is MasterVaultCoreTest { event BeneficiaryUpdated(address indexed oldBeneficiary, address indexed newBeneficiary); event PerformanceFeesWithdrawn(address indexed beneficiary, uint256 amount); } + +contract MasterVaultFeeTestWithSubvaultFresh is MasterVaultFeeTest { + function setUp() public override { + super.setUp(); + MockSubVault _subvault = new MockSubVault(IERC20(address(token)), "TestSubvault", "TSV"); + vault.setSubVault(IERC4626(address(_subvault)), 0); + } +} + +contract MasterVaultFeeTestWithSubvaultHoldingAssets is MasterVaultFeeTest { + function setUp() public override { + super.setUp(); + + MockSubVault _subvault = new MockSubVault(IERC20(address(token)), "TestSubvault", "TSV"); + uint256 _initAmount = 97659744; + token.mint(_initAmount); + token.approve(address(_subvault), _initAmount); + _subvault.deposit(_initAmount, address(this)); + assertEq( + _initAmount, + _subvault.totalAssets(), + "subvault should be initiated with assets = _initAmount" + ); + assertEq( + _initAmount, + _subvault.totalSupply(), + "subvault should be initiated with shares = _initAmount" + ); + + vault.setSubVault(IERC4626(address(_subvault)), 0); + } +} diff --git a/test-foundry/libraries/vault/MasterVaultPause.t.sol b/test-foundry/libraries/vault/MasterVaultPause.t.sol new file mode 100644 index 000000000..9f967f585 --- /dev/null +++ b/test-foundry/libraries/vault/MasterVaultPause.t.sol @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import { MasterVaultCoreTest } from "./MasterVaultCore.t.sol"; +import { MasterVault } from "../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { UpgradeableBeacon } from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; +import { BeaconProxyFactory } from "../../../contracts/tokenbridge/libraries/ClonableBeaconProxy.sol"; + +contract MasterVaultDeploymentPauseTest is MasterVaultCoreTest { + 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"); + + assertTrue( + vault.hasRole(vault.DEFAULT_ADMIN_ROLE(), address(this)), + "Should have DEFAULT_ADMIN_ROLE" + ); + assertTrue( + vault.hasRole(vault.VAULT_MANAGER_ROLE(), address(this)), + "Should have VAULT_MANAGER_ROLE" + ); + assertTrue( + vault.hasRole(vault.FEE_MANAGER_ROLE(), address(this)), + "Should have FEE_MANAGER_ROLE" + ); + } + + function test_beaconUpgrade() public { + vm.startPrank(user); + token.mint(); + uint256 depositAmount = token.balanceOf(user); + token.approve(address(vault), depositAmount); + vault.deposit(depositAmount, user); + vm.stopPrank(); + + address oldImplementation = beacon.implementation(); + assertEq( + oldImplementation, + address(beacon.implementation()), + "Should have initial implementation" + ); + + MasterVault newImplementation = new MasterVault(); + beacon.upgradeTo(address(newImplementation)); + + assertEq( + beacon.implementation(), + address(newImplementation), + "Beacon should point to new implementation" + ); + assertTrue( + beacon.implementation() != oldImplementation, + "Implementation should have changed" + ); + + assertEq(vault.name(), name, "Name should remain after upgrade"); + } + + function test_initialize_pausedByDefault() public { + // Deploy a fresh vault to test initial paused state + MasterVault implementation = new MasterVault(); + UpgradeableBeacon testBeacon = new UpgradeableBeacon(address(implementation)); + BeaconProxyFactory testFactory = new BeaconProxyFactory(); + testFactory.initialize(address(testBeacon)); + + bytes32 salt = keccak256("test_paused"); + address proxyAddress = testFactory.createProxy(salt); + MasterVault testVault = MasterVault(proxyAddress); + + testVault.initialize(IERC20(address(token)), "Test", "TST", address(this)); + + assertTrue(testVault.paused(), "Vault should be paused immediately after initialization"); + } + + function test_initialize_pauserRole() public { + assertTrue(vault.hasRole(vault.PAUSER_ROLE(), address(this)), "Should have PAUSER_ROLE"); + assertFalse(vault.paused(), "Should not be paused after unpause in setUp"); + } + + function test_pause() public { + assertFalse(vault.paused(), "Should not be paused after unpause in setUp"); + + vault.pause(); + + assertTrue(vault.paused(), "Should be paused"); + } + + function test_unpause() public { + vault.pause(); + assertTrue(vault.paused(), "Should be paused"); + + vault.unpause(); + + assertFalse(vault.paused(), "Should not be paused"); + } + + function test_pause_revert_NotPauser() public { + vm.prank(user); + vm.expectRevert(); + vault.pause(); + } + + function test_unpause_revert_NotPauser() public { + vault.pause(); + + vm.prank(user); + vm.expectRevert(); + vault.unpause(); + } + + function test_multiplePausers() public { + address pauser1 = address(0x3333); + address pauser2 = address(0x4444); + + vault.grantRole(vault.PAUSER_ROLE(), pauser1); + vault.grantRole(vault.PAUSER_ROLE(), pauser2); + + assertTrue(vault.hasRole(vault.PAUSER_ROLE(), pauser1), "Pauser1 should have PAUSER_ROLE"); + assertTrue(vault.hasRole(vault.PAUSER_ROLE(), pauser2), "Pauser2 should have PAUSER_ROLE"); + + vm.prank(pauser1); + vault.pause(); + assertTrue(vault.paused(), "Should be paused by pauser1"); + + vm.prank(pauser2); + vault.unpause(); + assertFalse(vault.paused(), "Should be unpaused by pauser2"); + } +} diff --git a/test-foundry/libraries/vault/MasterVaultSharePriceNoFee.t.sol b/test-foundry/libraries/vault/MasterVaultSharePriceNoFee.t.sol index 32df38007..ccc463b43 100644 --- a/test-foundry/libraries/vault/MasterVaultSharePriceNoFee.t.sol +++ b/test-foundry/libraries/vault/MasterVaultSharePriceNoFee.t.sol @@ -3,8 +3,17 @@ pragma solidity ^0.8.0; import { MasterVaultCoreTest } from "./MasterVaultCore.t.sol"; import { MasterVault } from "../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; +import { MockSubVault } from "../../../contracts/tokenbridge/test/MockSubVault.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; contract MasterVaultSharePriceNoFeeTest is MasterVaultCoreTest { + function getAssetsHoldingVault() internal view returns (address) { + return address(vault.subVault()) == address(0) + ? address(vault) + : address(vault.subVault()); + } + /// @dev example 1. sharePrice = 1e18 means we need to pay 1 asset to get 1 share function test_sharePrice_example1_oneToOne() public { vm.startPrank(user); @@ -28,7 +37,7 @@ contract MasterVaultSharePriceNoFeeTest is MasterVaultCoreTest { vm.stopPrank(); // Simulate vault growth: double the assets - vm.prank(address(vault)); + vm.prank(getAssetsHoldingVault()); token.mint(); // Now vault has 2x assets compared to shares @@ -48,7 +57,7 @@ contract MasterVaultSharePriceNoFeeTest is MasterVaultCoreTest { vm.stopPrank(); // simulate vault loss: transfer out 90% of assets - vm.prank(address(vault)); + vm.prank(getAssetsHoldingVault()); token.transfer(user, 900); // vault has 100 assets but 1000 shares @@ -66,7 +75,7 @@ contract MasterVaultSharePriceNoFeeTest is MasterVaultCoreTest { vm.stopPrank(); // simulate vault loss: transfer out 1 unit - vm.prank(address(vault)); + vm.prank(getAssetsHoldingVault()); token.transfer(user, 1); // vault has 99 assets and 100 shares @@ -149,7 +158,7 @@ contract MasterVaultSharePriceNoFeeTest is MasterVaultCoreTest { vm.stopPrank(); // Simulate loss: transfer out 90% of assets - vm.prank(address(vault)); + vm.prank(getAssetsHoldingVault()); token.transfer(user, 900); // Now sharePrice = 0.1e18 @@ -173,7 +182,7 @@ contract MasterVaultSharePriceNoFeeTest is MasterVaultCoreTest { vault.deposit(1000, user); vm.stopPrank(); - vm.prank(address(vault)); + vm.prank(getAssetsHoldingVault()); token.transfer(user, 900); assertEq(vault.sharePrice(), 0.1e18, "Share price should be 0.1e18"); @@ -196,7 +205,7 @@ contract MasterVaultSharePriceNoFeeTest is MasterVaultCoreTest { vault.deposit(1000, user); vm.stopPrank(); - vm.prank(address(vault)); + vm.prank(getAssetsHoldingVault()); token.transfer(user, 900); assertEq(vault.sharePrice(), 0.1e18, "Share price should be 0.1e18"); @@ -222,7 +231,7 @@ contract MasterVaultSharePriceNoFeeTest is MasterVaultCoreTest { vault.deposit(1000, user); vm.stopPrank(); - vm.prank(address(vault)); + vm.prank(getAssetsHoldingVault()); token.transfer(user, 900); assertEq(vault.sharePrice(), 0.1e18, "Share price should be 0.1e18"); @@ -252,7 +261,7 @@ contract MasterVaultSharePriceNoFeeTest is MasterVaultCoreTest { vm.stopPrank(); // Simulate vault growth: multiply assets by 10 - vm.prank(address(vault)); + vm.prank(getAssetsHoldingVault()); token.mint(initialDeposit * 9); // Now sharePrice = 10e18 @@ -278,7 +287,7 @@ contract MasterVaultSharePriceNoFeeTest is MasterVaultCoreTest { vm.stopPrank(); // Simulate vault growth: multiply assets by 10 - vm.prank(address(vault)); + vm.prank(getAssetsHoldingVault()); token.mint(initialDeposit * 9); assertEq(vault.sharePrice(), 10e18, "Share price should be 10e18"); @@ -302,7 +311,7 @@ contract MasterVaultSharePriceNoFeeTest is MasterVaultCoreTest { vault.deposit(initialDeposit, user); vm.stopPrank(); - vm.startPrank(address(vault)); + vm.startPrank(getAssetsHoldingVault()); for (uint i = 0; i < 9; i++) { token.mint(); } @@ -332,7 +341,7 @@ contract MasterVaultSharePriceNoFeeTest is MasterVaultCoreTest { vault.deposit(initialDeposit, user); vm.stopPrank(); - vm.startPrank(address(vault)); + vm.startPrank(getAssetsHoldingVault()); for (uint i = 0; i < 9; i++) { token.mint(); } @@ -371,7 +380,7 @@ contract MasterVaultSharePriceNoFeeTest is MasterVaultCoreTest { uint256 targetAssets = currentShares * 100; uint256 assetsToAdd = targetAssets - currentAssets; - vm.prank(address(vault)); + vm.prank(getAssetsHoldingVault()); token.mint(assetsToAdd); assertEq(vault.sharePrice(), 100e18, "Share price should be 100e18"); @@ -400,7 +409,7 @@ contract MasterVaultSharePriceNoFeeTest is MasterVaultCoreTest { uint256 targetAssets = currentShares * 100; uint256 assetsToAdd = targetAssets - currentAssets; - vm.prank(address(vault)); + vm.prank(getAssetsHoldingVault()); token.mint(assetsToAdd); assertEq(vault.sharePrice(), 100e18, "Share price should be 100e18"); @@ -429,7 +438,7 @@ contract MasterVaultSharePriceNoFeeTest is MasterVaultCoreTest { uint256 targetAssets = currentShares * 100; uint256 assetsToAdd = targetAssets - currentAssets; - vm.prank(address(vault)); + vm.prank(getAssetsHoldingVault()); token.mint(assetsToAdd); assertEq(vault.sharePrice(), 100e18, "Share price should be 100e18"); @@ -461,7 +470,7 @@ contract MasterVaultSharePriceNoFeeTest is MasterVaultCoreTest { uint256 targetAssets = currentShares * 100; uint256 assetsToAdd = targetAssets - currentAssets; - vm.prank(address(vault)); + vm.prank(getAssetsHoldingVault()); token.mint(assetsToAdd); assertEq(vault.sharePrice(), 100e18, "Share price should be 100e18"); @@ -495,7 +504,7 @@ contract MasterVaultSharePriceNoFeeTest is MasterVaultCoreTest { uint256 targetAssets = currentShares * 100; uint256 assetsToAdd = targetAssets - currentAssets; - vm.prank(address(vault)); + vm.prank(getAssetsHoldingVault()); token.mint(assetsToAdd); assertEq(vault.sharePrice(), 100e18, "Share price should be 100e18"); @@ -524,7 +533,7 @@ contract MasterVaultSharePriceNoFeeTest is MasterVaultCoreTest { uint256 targetAssets = currentShares * 100; uint256 assetsToAdd = targetAssets - currentAssets; - vm.prank(address(vault)); + vm.prank(getAssetsHoldingVault()); token.mint(assetsToAdd); assertEq(vault.sharePrice(), 100e18, "Share price should be 100e18"); @@ -553,7 +562,7 @@ contract MasterVaultSharePriceNoFeeTest is MasterVaultCoreTest { uint256 targetAssets = currentShares * 100; uint256 assetsToAdd = targetAssets - currentAssets; - vm.prank(address(vault)); + vm.prank(getAssetsHoldingVault()); token.mint(assetsToAdd); assertEq(vault.sharePrice(), 100e18, "Share price should be 100e18"); @@ -585,7 +594,7 @@ contract MasterVaultSharePriceNoFeeTest is MasterVaultCoreTest { uint256 targetAssets = currentShares * 100; uint256 assetsToAdd = targetAssets - currentAssets; - vm.prank(address(vault)); + vm.prank(getAssetsHoldingVault()); token.mint(assetsToAdd); assertEq(vault.sharePrice(), 100e18, "Share price should be 100e18"); @@ -603,3 +612,36 @@ contract MasterVaultSharePriceNoFeeTest is MasterVaultCoreTest { vm.stopPrank(); } } + +contract MasterVaultSharePriceNoFeeTestWithSubvaultFresh is MasterVaultSharePriceNoFeeTest { + function setUp() public override { + super.setUp(); + MockSubVault _subvault = new MockSubVault(IERC20(address(token)), "TestSubvault", "TSV"); + vault.setSubVault(IERC4626(address(_subvault)), 0); + } +} + +// contract MasterVaultSharePriceNoFeeTestWithSubvaultHoldingAssets is MasterVaultSharePriceNoFeeTest { +// function setUp() public override { +// super.setUp(); + +// MockSubVault _subvault = new MockSubVault(IERC20(address(token)), "TestSubvault", "TSV"); +// uint256 _initAmount = 97659744; +// token.mint(_initAmount); +// token.approve(address(_subvault), _initAmount); +// _subvault.deposit(_initAmount, address(this)); +// assertEq( +// _initAmount, +// _subvault.totalAssets(), +// "subvault should be initiated with assets = _initAmount" +// ); +// assertEq( +// _initAmount, +// _subvault.totalSupply(), +// "subvault should be initiated with shares = _initAmount" +// ); + +// vault.setSubVault(IERC4626(address(_subvault)), 0); +// } +// } + From 6499172460075bf734ec26e16ff6f98d89a74c6a Mon Sep 17 00:00:00 2001 From: Wael Almattar Date: Sat, 6 Dec 2025 17:33:07 +0100 Subject: [PATCH 30/30] fix: remove useless override maxRedeem, maxWithdraw --- .../libraries/vault/MasterVault.sol | 16 ---- .../libraries/vault/MasterVaultCore.t.sol | 4 + .../vault/MasterVaultSharePriceNoFee.t.sol | 30 ------- .../vault/MasterVaultSharePriceWithFee.t.sol | 85 ++++++++++++++++--- 4 files changed, 75 insertions(+), 60 deletions(-) diff --git a/contracts/tokenbridge/libraries/vault/MasterVault.sol b/contracts/tokenbridge/libraries/vault/MasterVault.sol index 0b5610acc..fb566e4ed 100644 --- a/contracts/tokenbridge/libraries/vault/MasterVault.sol +++ b/contracts/tokenbridge/libraries/vault/MasterVault.sol @@ -335,20 +335,4 @@ contract MasterVault is } return subVault.maxMint(receiver); } - - /** @dev See {IERC4626-maxWithdraw}. */ - function maxWithdraw(address owner) public view virtual override returns (uint256) { - if (address(subVault) == address(0)) { - return super.maxWithdraw(owner); - } - return subVault.maxWithdraw(address(this)); - } - - /** @dev See {IERC4626-maxRedeem}. */ - function maxRedeem(address owner) public view virtual override returns (uint256) { - if (address(subVault) == address(0)) { - return super.maxRedeem(owner); - } - return subVault.maxRedeem(address(this)); - } } diff --git a/test-foundry/libraries/vault/MasterVaultCore.t.sol b/test-foundry/libraries/vault/MasterVaultCore.t.sol index 6daf0f840..5fd882359 100644 --- a/test-foundry/libraries/vault/MasterVaultCore.t.sol +++ b/test-foundry/libraries/vault/MasterVaultCore.t.sol @@ -22,6 +22,10 @@ contract MasterVaultCoreTest is Test { string public name = "Master Test Token"; string public symbol = "mTST"; + function getAssetsHoldingVault() internal view virtual returns (address) { + return address(vault.subVault()) == address(0) ? address(vault) : address(vault.subVault()); + } + function setUp() public virtual { token = new TestERC20(); diff --git a/test-foundry/libraries/vault/MasterVaultSharePriceNoFee.t.sol b/test-foundry/libraries/vault/MasterVaultSharePriceNoFee.t.sol index ccc463b43..f87ddcfa5 100644 --- a/test-foundry/libraries/vault/MasterVaultSharePriceNoFee.t.sol +++ b/test-foundry/libraries/vault/MasterVaultSharePriceNoFee.t.sol @@ -8,12 +8,6 @@ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; contract MasterVaultSharePriceNoFeeTest is MasterVaultCoreTest { - function getAssetsHoldingVault() internal view returns (address) { - return address(vault.subVault()) == address(0) - ? address(vault) - : address(vault.subVault()); - } - /// @dev example 1. sharePrice = 1e18 means we need to pay 1 asset to get 1 share function test_sharePrice_example1_oneToOne() public { vm.startPrank(user); @@ -621,27 +615,3 @@ contract MasterVaultSharePriceNoFeeTestWithSubvaultFresh is MasterVaultSharePric } } -// contract MasterVaultSharePriceNoFeeTestWithSubvaultHoldingAssets is MasterVaultSharePriceNoFeeTest { -// function setUp() public override { -// super.setUp(); - -// MockSubVault _subvault = new MockSubVault(IERC20(address(token)), "TestSubvault", "TSV"); -// uint256 _initAmount = 97659744; -// token.mint(_initAmount); -// token.approve(address(_subvault), _initAmount); -// _subvault.deposit(_initAmount, address(this)); -// assertEq( -// _initAmount, -// _subvault.totalAssets(), -// "subvault should be initiated with assets = _initAmount" -// ); -// assertEq( -// _initAmount, -// _subvault.totalSupply(), -// "subvault should be initiated with shares = _initAmount" -// ); - -// vault.setSubVault(IERC4626(address(_subvault)), 0); -// } -// } - diff --git a/test-foundry/libraries/vault/MasterVaultSharePriceWithFee.t.sol b/test-foundry/libraries/vault/MasterVaultSharePriceWithFee.t.sol index 55c01efad..ced4ddda7 100644 --- a/test-foundry/libraries/vault/MasterVaultSharePriceWithFee.t.sol +++ b/test-foundry/libraries/vault/MasterVaultSharePriceWithFee.t.sol @@ -3,9 +3,12 @@ pragma solidity ^0.8.0; import { MasterVaultCoreTest } from "./MasterVaultCore.t.sol"; import { MasterVault } from "../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; +import { MockSubVault } from "../../../contracts/tokenbridge/test/MockSubVault.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; contract MasterVaultSharePriceWithFeeTest is MasterVaultCoreTest { - function setUp() public override { + function setUp() public virtual override { super.setUp(); // Enable performance fee for all tests in this file vault.setPerformanceFee(true); @@ -21,7 +24,7 @@ contract MasterVaultSharePriceWithFeeTest is MasterVaultCoreTest { vm.stopPrank(); // Simulate vault growth: double the assets - vm.prank(address(vault)); + vm.prank(getAssetsHoldingVault()); token.mint(); // With performance fee enabled, sharePrice should be capped at 1e18 even though actual ratio is 2:1 @@ -42,9 +45,17 @@ contract MasterVaultSharePriceWithFeeTest is MasterVaultCoreTest { vault.deposit(depositAmount, user); vm.stopPrank(); - // Simulate vault loss: transfer out 10% - vm.prank(address(vault)); - token.transfer(user, 100); + uint256 vaultAssetsBefore = vault.totalAssets(); + address _assetHoldingVault = getAssetsHoldingVault(); + uint256 holdingVaultBalance = token.balanceOf(_assetHoldingVault); + + // Calculate amount to transfer to achieve 10% loss for master vault + // amountToTransfer = (targetLoss * holdingVaultBalance) / vaultAssetsBefore + uint256 amountToTransfer = (100 * holdingVaultBalance) / vaultAssetsBefore; + + // Simulate vault loss: transfer out to achieve 10% loss + vm.prank(_assetHoldingVault); + token.transfer(user, amountToTransfer); // sharePrice should be 0.9e18 (900/1000) uint256 price = vault.sharePrice(); @@ -62,7 +73,7 @@ contract MasterVaultSharePriceWithFeeTest is MasterVaultCoreTest { vm.stopPrank(); // Vault gains profit - vm.prank(address(vault)); + vm.prank(getAssetsHoldingVault()); token.mint(); // Now sharePrice is capped at 1e18 @@ -90,7 +101,7 @@ contract MasterVaultSharePriceWithFeeTest is MasterVaultCoreTest { vm.stopPrank(); // Vault gains profit (doubles) - vm.prank(address(vault)); + vm.prank(getAssetsHoldingVault()); token.mint(); // User redeems shares - should only get back principal (due to _effectiveAssets) @@ -112,7 +123,7 @@ contract MasterVaultSharePriceWithFeeTest is MasterVaultCoreTest { vm.stopPrank(); // Vault gains profit (doubles) - vm.prank(address(vault)); + vm.prank(getAssetsHoldingVault()); token.mint(); // User tries to withdraw all their principal @@ -139,9 +150,16 @@ contract MasterVaultSharePriceWithFeeTest is MasterVaultCoreTest { uint256 shares2 = vault.deposit(1000, address(0x2)); vm.stopPrank(); + uint256 vaultAssetsBefore = vault.totalAssets(); + address _assetHoldingVault = getAssetsHoldingVault(); + uint256 holdingVaultBalance = token.balanceOf(_assetHoldingVault); + + // Calculate amount to transfer to achieve 50% loss for master vault + uint256 amountToTransfer = (1000 * holdingVaultBalance) / vaultAssetsBefore; + // Vault loses 50% of assets - vm.prank(address(vault)); - token.transfer(address(0x999), 1000); + vm.prank(_assetHoldingVault); + token.transfer(address(0x999), amountToTransfer); // Both users should be able to redeem proportionally vm.prank(user); @@ -165,9 +183,12 @@ contract MasterVaultSharePriceWithFeeTest is MasterVaultCoreTest { uint256 shares1 = vault.deposit(deposit1, user); vm.stopPrank(); + address _assetHoldingVault = getAssetsHoldingVault(); + uint256 holdingVaultBalance = token.balanceOf(_assetHoldingVault); + // Vault gains profit (doubles) - vm.prank(address(vault)); - token.mint(1000); + vm.prank(_assetHoldingVault); + token.mint(holdingVaultBalance); // User 2 deposits same amount vm.startPrank(address(0x2)); @@ -208,9 +229,11 @@ contract MasterVaultSharePriceWithFeeTest is MasterVaultCoreTest { vault.deposit(1000, user); vm.stopPrank(); + address _assetHoldingVault = getAssetsHoldingVault(); + // Simulate 50% loss - vm.prank(address(vault)); - token.transfer(user, 500); + vm.startPrank(_assetHoldingVault); + token.transfer(user,( token.balanceOf(_assetHoldingVault)/2)); // sharePrice = 0.5e18 assertEq(vault.sharePrice(), 0.5e18, "Share price should be 0.5e18"); @@ -225,3 +248,37 @@ contract MasterVaultSharePriceWithFeeTest is MasterVaultCoreTest { vm.stopPrank(); } } + +contract MasterVaultSharePriceWithFeeTestWithSubvaultFresh is MasterVaultSharePriceWithFeeTest { + function setUp() public override { + super.setUp(); + MockSubVault _subvault = new MockSubVault(IERC20(address(token)), "TestSubvault", "TSV"); + vault.setSubVault(IERC4626(address(_subvault)), 0); + } +} + +contract MasterVaultSharePriceWithFeeTestWithSubvaultHoldingAssets is + MasterVaultSharePriceWithFeeTest +{ + function setUp() public override { + super.setUp(); + + MockSubVault _subvault = new MockSubVault(IERC20(address(token)), "TestSubvault", "TSV"); + uint256 _initAmount = 3290234; + token.mint(_initAmount); + token.approve(address(_subvault), _initAmount); + _subvault.deposit(_initAmount, address(this)); + assertEq( + _initAmount, + _subvault.totalAssets(), + "subvault should be initiated with assets = _initAmount" + ); + assertEq( + _initAmount, + _subvault.totalSupply(), + "subvault should be initiated with shares = _initAmount" + ); + + vault.setSubVault(IERC4626(address(_subvault)), 0); + } +}