diff --git a/contracts/tokenbridge/libraries/vault/IMasterVaultFactory.sol b/contracts/tokenbridge/libraries/vault/IMasterVaultFactory.sol new file mode 100644 index 000000000..fc9185312 --- /dev/null +++ b/contracts/tokenbridge/libraries/vault/IMasterVaultFactory.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +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); +} diff --git a/contracts/tokenbridge/libraries/vault/MasterVault.sol b/contracts/tokenbridge/libraries/vault/MasterVault.sol new file mode 100644 index 000000000..fb566e4ed --- /dev/null +++ b/contracts/tokenbridge/libraries/vault/MasterVault.sol @@ -0,0 +1,338 @@ +// 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 +{ + 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"); + + uint256 public constant MULTIPLIER = 1e18; + + error TooFewSharesReceived(); + error TooManySharesBurned(); + error TooManyAssetsDeposited(); + error TooFewAssetsReceived(); + error InvalidAsset(); + error InvalidOwner(); + 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 + // this would also avoid the need for totalPrincipal tracking + // however, this would require more trust in the owner + bool public enablePerformanceFee; + address public beneficiary; + int256 public totalPrincipal; // total assets deposited, used to calculate profit + IERC4626 public subVault; + + 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(); + + __ERC20_init(_name, _symbol); + __ERC4626_init(IERC20Upgradeable(address(_asset))); + __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); + + // vault paused by default to protect against first depositor attack + _pause(); + } + + function pause() external onlyRole(PAUSER_ROLE) { + _pause(); + } + + function unpause() external onlyRole(PAUSER_ROLE) { + _unpause(); + } + + /// fee-related methods /// + + /// @notice Toggle performance fee collection on/off + /// @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); + } + + /// @notice Set the beneficiary address for performance fees + /// @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); + } + + /** @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(); + return int256(_totalAssets) - totalPrincipal; + } + + /// @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) { + if (!enablePerformanceFee) revert PerformanceFeeDisabled(); + if (beneficiary == address(0)) revert BeneficiaryNotSet(); + + int256 _totalProfits = totalProfit(); + if (_totalProfits > 0) { + 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)); + } + } + + /// @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 4. vault holds 99 USDC and 100 shares => sharePrice = 99 * 1e18 / 100 + function sharePrice() public view returns (uint256) { + uint256 _totalAssets = totalAssets(); + uint256 _totalSupply = totalSupply(); + + // todo: should we also consider _totalAssets == 0 case? + if (_totalSupply == 0 || _totalAssets == 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 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, uint256(totalPrincipal)) + : _totalAssets; + + if (_totalSupply == 0) { + return shares; + } + + uint256 _assets = MathUpgradeable.mulDiv(shares, _effectiveAssets, _totalSupply, rounding); + return _assets; + } + + /// @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); + + 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); + } + + /// @dev Override internal withdraw to track total principal + function _withdraw( + address caller, + address receiver, + address owner, + 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); + } + + /// 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 _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)); + } + + /// @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)); + } + + /// 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); + } +} diff --git a/contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol b/contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol new file mode 100644 index 000000000..f6e746b95 --- /dev/null +++ b/contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: Apache-2.0 + +pragma solidity ^0.8.0; + +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 "./IMasterVaultFactory.sol"; +import "./MasterVault.sol"; + +contract MasterVaultFactory is IMasterVaultFactory, Initializable { + error ZeroAddress(); + error BeaconNotDeployed(); + + BeaconProxyFactory public beaconProxyFactory; + address public owner; + + function initialize(address _owner) public initializer { + owner = _owner; + MasterVault masterVaultImplementation = new MasterVault(); + UpgradeableBeacon beacon = new UpgradeableBeacon(address(masterVaultImplementation)); + beaconProxyFactory = new BeaconProxyFactory(); + beaconProxyFactory.initialize(address(beacon)); + beacon.transferOwnership(_owner); + } + + function deployVault(address token) public returns (address vault) { + if (token == address(0)) { + revert ZeroAddress(); + } + if ( + address(beaconProxyFactory) == address(0) && beaconProxyFactory.beacon() == 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())); + + MasterVault(vault).initialize(IERC20(token), name, symbol, owner); + + emit VaultDeployed(token, vault); + } + + function _getUserSalt(address token) internal pure returns (bytes32) { + return keccak256(abi.encode(token)); + } + + 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) { + address vault = calculateVaultAddress(token); + if (vault.code.length == 0) { + return deployVault(token); + } + return vault; + } +} diff --git a/contracts/tokenbridge/test/MockSubVault.sol b/contracts/tokenbridge/test/MockSubVault.sol new file mode 100644 index 000000000..8d6befd1a --- /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)); + } +} 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 new file mode 100644 index 000000000..42563d9c2 --- /dev/null +++ b/test-foundry/libraries/vault/MasterVault.t.sol @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: UNLICENSED +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 + 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; + + token.approve(address(vault), depositAmount); + + 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"); + + assertEq(diff, depositAmount, "Vault should increase holding of assets"); + assertGt(token.balanceOf(_assetsHoldingVault), 0, "Vault should hold the tokens"); + + 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); + + 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( + _assetsHoldingVaultBalanceAfter - _assetsHoldingVaultBalanceBefore, + assetsCost, + "Vault should hold the tokens" + ); + + assertEq(vault.totalSupply(), vault.totalAssets(), "First mint should be at a rate of 1"); + vm.stopPrank(); + } + + function test_withdraw() public { + vm.startPrank(user); + token.mint(); + uint256 depositAmount = token.balanceOf(user); + token.approve(address(vault), depositAmount); + vault.deposit(depositAmount, user); + + uint256 userSharesBefore = vault.balanceOf(user); + uint256 withdrawAmount = depositAmount; // withdraw all assets + + 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(token.balanceOf(address(vault)), 0, "Vault should have no tokens left"); + assertEq(sharesRedeemed, userSharesBefore, "All shares should be redeemed"); + + vm.stopPrank(); + } + + function test_redeem() public { + vm.startPrank(user); + token.mint(); + uint256 depositAmount = token.balanceOf(user); + token.approve(address(vault), depositAmount); + uint256 shares = vault.deposit(depositAmount, user); + + uint256 sharesToRedeem = shares; // redeem all shares + + uint256 assetsReceived = vault.redeem(sharesToRedeem, user, user); + + 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"); + + vm.stopPrank(); + } +} + +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); + } +} diff --git a/test-foundry/libraries/vault/MasterVaultCore.t.sol b/test-foundry/libraries/vault/MasterVaultCore.t.sol new file mode 100644 index 000000000..5fd882359 --- /dev/null +++ b/test-foundry/libraries/vault/MasterVaultCore.t.sol @@ -0,0 +1,45 @@ +// 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 getAssetsHoldingVault() internal view virtual returns (address) { + return address(vault.subVault()) == address(0) ? address(vault) : address(vault.subVault()); + } + + function setUp() public virtual { + 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)); + vault.unpause(); + } +} diff --git a/test-foundry/libraries/vault/MasterVaultFactory.t.sol b/test-foundry/libraries/vault/MasterVaultFactory.t.sol new file mode 100644 index 000000000..f9b7d6a03 --- /dev/null +++ b/test-foundry/libraries/vault/MasterVaultFactory.t.sol @@ -0,0 +1,135 @@ +// 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"; + +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"); + assertTrue( + vault.hasRole(vault.DEFAULT_ADMIN_ROLE(), owner), + "Factory owner should have DEFAULT_ADMIN_ROLE" + ); + } + + 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"); + } + + function test_beaconOwnership() public { + assertEq( + UpgradeableBeacon(factory.beaconProxyFactory().beacon()).owner(), + owner, + "Beacon owner should be the factory owner" + ); + } + + function test_ownerCanUpgradeBeacon() public { + MasterVault newImplementation = new MasterVault(); + + UpgradeableBeacon beacon = UpgradeableBeacon(factory.beaconProxyFactory().beacon()); + vm.prank(owner); + beacon.upgradeTo(address(newImplementation)); + + assertEq( + UpgradeableBeacon(factory.beaconProxyFactory().beacon()).implementation(), + address(newImplementation), + "Beacon implementation should be updated" + ); + } + + function test_nonOwnerCannotUpgradeBeacon() public { + MasterVault newImplementation = new MasterVault(); + + UpgradeableBeacon beacon = UpgradeableBeacon(factory.beaconProxyFactory().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 = UpgradeableBeacon(factory.beaconProxyFactory().beacon()); + 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" + ); + } +} diff --git a/test-foundry/libraries/vault/MasterVaultFee.t.sol b/test-foundry/libraries/vault/MasterVaultFee.t.sol new file mode 100644 index 000000000..93682e87e --- /dev/null +++ b/test-foundry/libraries/vault/MasterVaultFee.t.sol @@ -0,0 +1,294 @@ +// 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 { 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); + + 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(), + int256(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(), + int256(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(), + 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" + ); + + 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(), + 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" + ); + + 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); + + address _assetsHoldingVault = address(vault.subVault()) == address(0) + ? address(vault) + : address(vault.subVault()); + + vm.startPrank(user); + token.mint(); + uint256 depositAmount = token.balanceOf(user); + token.approve(address(vault), depositAmount); + vault.deposit(depositAmount, user); + vm.stopPrank(); + + 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"); + + 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( + vault.totalProfit(), + int256(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); +} + +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 new file mode 100644 index 000000000..f87ddcfa5 --- /dev/null +++ b/test-foundry/libraries/vault/MasterVaultSharePriceNoFee.t.sol @@ -0,0 +1,617 @@ +// 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 { 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 { + /// @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(getAssetsHoldingVault()); + 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(getAssetsHoldingVault()); + 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(getAssetsHoldingVault()); + 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(getAssetsHoldingVault()); + 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(getAssetsHoldingVault()); + 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(getAssetsHoldingVault()); + 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(getAssetsHoldingVault()); + 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(getAssetsHoldingVault()); + 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(getAssetsHoldingVault()); + 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(getAssetsHoldingVault()); + 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(getAssetsHoldingVault()); + 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(getAssetsHoldingVault()); + 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(getAssetsHoldingVault()); + 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(getAssetsHoldingVault()); + 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(getAssetsHoldingVault()); + 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(getAssetsHoldingVault()); + 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(getAssetsHoldingVault()); + 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(getAssetsHoldingVault()); + 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(getAssetsHoldingVault()); + 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(); + } +} + +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); + } +} + diff --git a/test-foundry/libraries/vault/MasterVaultSharePriceWithFee.t.sol b/test-foundry/libraries/vault/MasterVaultSharePriceWithFee.t.sol new file mode 100644 index 000000000..ced4ddda7 --- /dev/null +++ b/test-foundry/libraries/vault/MasterVaultSharePriceWithFee.t.sol @@ -0,0 +1,284 @@ +// 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 { 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 virtual 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(getAssetsHoldingVault()); + 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(); + + 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(); + 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(getAssetsHoldingVault()); + 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(getAssetsHoldingVault()); + 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(getAssetsHoldingVault()); + 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(); + + 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(_assetHoldingVault); + token.transfer(address(0x999), amountToTransfer); + + // 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(); + + address _assetHoldingVault = getAssetsHoldingVault(); + uint256 holdingVaultBalance = token.balanceOf(_assetHoldingVault); + + // Vault gains profit (doubles) + vm.prank(_assetHoldingVault); + token.mint(holdingVaultBalance); + + // 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(); + + address _assetHoldingVault = getAssetsHoldingVault(); + + // Simulate 50% loss + vm.startPrank(_assetHoldingVault); + token.transfer(user,( token.balanceOf(_assetHoldingVault)/2)); + + // 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(); + } +} + +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); + } +} diff --git a/test-foundry/libraries/vault/MasterVaultSubVaultManagement.t.sol b/test-foundry/libraries/vault/MasterVaultSubVaultManagement.t.sol new file mode 100644 index 000000000..a6457f787 --- /dev/null +++ b/test-foundry/libraries/vault/MasterVaultSubVaultManagement.t.sol @@ -0,0 +1,188 @@ +// 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)); + subVault.unpause(); + } + + 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); +} 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..0ce21c7bf --- /dev/null +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario01.t.sol @@ -0,0 +1,74 @@ +// 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..7af484a64 --- /dev/null +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario02.t.sol @@ -0,0 +1,96 @@ +// 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..bacb6d3bc --- /dev/null +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario03.t.sol @@ -0,0 +1,96 @@ +// 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..26d5dc00d --- /dev/null +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario04.t.sol @@ -0,0 +1,118 @@ +// 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..ebcce708b --- /dev/null +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario05.t.sol @@ -0,0 +1,146 @@ +// 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..584af6e19 --- /dev/null +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario06.t.sol @@ -0,0 +1,110 @@ +// 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(), 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"); + + // 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(), 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); + 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..06339334d --- /dev/null +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario07.t.sol @@ -0,0 +1,120 @@ +// 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..2765bf8b8 --- /dev/null +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario08.t.sol @@ -0,0 +1,126 @@ +// 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)" + ); + } +} 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..8c3f26a74 --- /dev/null +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario09.t.sol @@ -0,0 +1,121 @@ +// 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..ba2c6ff01 --- /dev/null +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario10.t.sol @@ -0,0 +1,110 @@ +// 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 +}