Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
dc2c6ab
wip
godzillaba Dec 11, 2025
a337686
wip: conversion and deposit/withdraw functions look okay
godzillaba Dec 12, 2025
545d96d
settargetAllocationWad
godzillaba Dec 12, 2025
ec19522
deploy with initial vault
godzillaba Dec 12, 2025
69345c1
use factory in core test setup
godzillaba Dec 12, 2025
05fe9e4
slippage tolerance on setTargetAllocationWad
godzillaba Dec 15, 2025
3fa9992
big simplify
godzillaba Dec 15, 2025
9480360
WIP: mastervault donation attack mitigation (#142)
godzillaba Dec 15, 2025
cf3d27b
initial approval and small refactor
godzillaba Dec 16, 2025
e2bdd27
tests
godzillaba Dec 16, 2025
6b5e740
fix PerformanceFeesWithdrawn event
godzillaba Dec 16, 2025
71237b7
move event
godzillaba Dec 16, 2025
ba01452
rebalancing does not count profit
godzillaba Dec 17, 2025
79819e8
distribute fees before disabling
godzillaba Dec 17, 2025
83a5966
fix maxMint
godzillaba Dec 17, 2025
281bf38
fix outdated comment
godzillaba Dec 17, 2025
b49255e
remove unused errors
godzillaba Dec 17, 2025
aba0f92
nonReentrant
godzillaba Dec 17, 2025
234efe7
handle max in maxMint
godzillaba Dec 17, 2025
45e1d00
sanity check we have no sub shares when switching
godzillaba Dec 17, 2025
a023fa2
init reentrancy guard
godzillaba Dec 17, 2025
b0cf3e0
rebalance after setting target
godzillaba Dec 17, 2025
519198b
update docs
godzillaba Dec 17, 2025
101268a
update outdated comment
godzillaba Dec 17, 2025
b8f2c2f
fix nonReentrant modifier placement
godzillaba Dec 17, 2025
b601580
remove testFoo
godzillaba Dec 17, 2025
15a1121
fix tests
godzillaba Dec 23, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 115 additions & 89 deletions contracts/tokenbridge/libraries/vault/MasterVault.sol
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@
// we may need a minimum asset or master share amount when setting subvaults (bc of exchange rate calc)
IERC4626 public subVault;

uint256 targetAllocationWad;

/// @notice Flag indicating if performance fee is enabled
bool public enablePerformanceFee;

Expand Down Expand Up @@ -81,7 +83,7 @@
_grantRole(VAULT_MANAGER_ROLE, _owner);
_grantRole(PAUSER_ROLE, _owner);

_pause();
// todo: deploy initial subvault
}

function distributePerformanceFee() external whenNotPaused {
Expand All @@ -102,46 +104,22 @@
emit PerformanceFeesWithdrawn(beneficiary, profit);
}

error NonZeroTargetAllocation(uint256 targetAllocationWad);

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

address oldSubVault = address(subVault);
subVault = _subVault;

if (oldSubVault != address(0)) IERC20(asset()).safeApprove(address(oldSubVault), 0);
IERC20(asset()).safeApprove(address(_subVault), type(uint256).max);
_subVault.deposit(underlyingAsset.balanceOf(address(this)), address(this));

uint256 supply = totalSupply();
if (supply > 0) {
uint256 subVaultExchRateWad = _subVault.balanceOf(address(this)).mulDiv(1e18, supply, 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 supply = totalSupply();
if (supply > 0) {
uint256 assetExchRateWad = IERC20(asset()).balanceOf(address(this)).mulDiv(1e18, supply, MathUpgradeable.Rounding.Down);
if (assetExchRateWad < minAssetExchRateWad) revert SubVaultExchangeRateTooLow();
}

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

/// @notice Toggle performance fee collection on/off
Expand Down Expand Up @@ -207,15 +185,68 @@
return __totalAssets > totalPrincipal ? __totalAssets - totalPrincipal : 0;
}

function totalProfitInIdleAssets(MathUpgradeable.Rounding rounding) public view returns (uint256) {
return totalProfit(rounding).mulDiv(1e18 - targetAllocationWad, 1e18, rounding);
}

function totalProfitInSubVaultShares(MathUpgradeable.Rounding rounding) public view returns (uint256) {
if (address(subVault) == address(0)) {
revert("Subvault not set");
}
uint256 profitAssets = totalProfit(rounding);
if (profitAssets == 0) {
return 0;
}
return _assetsToSubVaultShares(profitAssets, rounding);
return _assetsToSubVaultShares(profitAssets.mulDiv(targetAllocationWad, 1e18, rounding), rounding);
}

/** @dev See {IERC4626-deposit}. */
function deposit(uint256 assets, address receiver) public virtual override returns (uint256) {
require(assets <= maxDeposit(receiver), "ERC4626: deposit more than max");

(uint256 shares, uint256 assetsFromSubVault) = _convertToSharesDetailed(assets, MathUpgradeable.Rounding.Down);
_deposit(_msgSender(), receiver, assets, shares, assetsFromSubVault);

return shares;
}

/** @dev See {IERC4626-mint}.
*
* As opposed to {deposit}, minting is allowed even if the vault is in a state where the price of a share is zero.
* In this case, the shares will be minted without requiring any assets to be deposited.
*/
function mint(uint256 shares, address receiver) public virtual override returns (uint256) {
require(shares <= maxMint(receiver), "ERC4626: mint more than max");

(uint256 assets, uint256 assetsFromSubVault) = _convertToAssetsDetailed(shares, MathUpgradeable.Rounding.Up);
_deposit(_msgSender(), receiver, assets, shares, assetsFromSubVault);

return assets;
}

/** @dev See {IERC4626-withdraw}. */
function withdraw(
uint256 assets,
address receiver,
address owner
) public virtual override returns (uint256) {
require(assets <= maxWithdraw(owner), "ERC4626: withdraw more than max");

(uint256 shares, uint256 assetsFromSubVault) = _convertToSharesDetailed(assets, MathUpgradeable.Rounding.Up);
_withdraw(_msgSender(), receiver, owner, assets, shares, assetsFromSubVault);

return shares;
}

/** @dev See {IERC4626-redeem}. */
function redeem(
uint256 shares,
address receiver,
address owner
) public virtual override returns (uint256) {
require(shares <= maxRedeem(owner), "ERC4626: redeem more than max");

(uint256 assets, uint256 assetsFromSubVault) = _convertToAssetsDetailed(shares, MathUpgradeable.Rounding.Down);
_withdraw(_msgSender(), receiver, owner, assets, shares, assetsFromSubVault);

return assets;
}

/**
Expand All @@ -225,16 +256,12 @@
address caller,
address receiver,
uint256 assets,
uint256 shares
) internal virtual override whenNotPaused {
super._deposit(caller, receiver, assets, shares);

uint256 shares,
uint256 assetsToDeposit
) internal whenNotPaused {
_deposit(caller, receiver, assets, shares);
if (enablePerformanceFee) totalPrincipal += assets;

IERC4626 _subVault = subVault;
if (address(_subVault) != address(0)) {
_subVault.deposit(assets, address(this));
}
subVault.deposit(assetsToDeposit, address(this));
}

/**
Expand All @@ -245,89 +272,88 @@
address receiver,
address _owner,
uint256 assets,
uint256 shares
) internal virtual override whenNotPaused {
uint256 shares,
uint256 assetsToWithdraw
) internal whenNotPaused {
if (enablePerformanceFee) totalPrincipal -= assets;

IERC4626 _subVault = subVault;
if (address(_subVault) != address(0)) {
_subVault.withdraw(assets, address(this), address(this));
}

super._withdraw(caller, receiver, _owner, assets, shares);
subVault.withdraw(assetsToWithdraw, address(this), address(this));
_withdraw(caller, receiver, _owner, assets, shares);
}

function _totalAssets(MathUpgradeable.Rounding rounding) internal view returns (uint256) {
if (address(subVault) == address(0)) {
return IERC20(asset()).balanceOf(address(this));
}
return _subVaultSharesToAssets(subVault.balanceOf(address(this)), rounding);
return IERC20(asset()).balanceOf(address(this)) + _subVaultSharesToAssets(subVault.balanceOf(address(this)), rounding);
}

// todo: question: will this drift over time? i don't think so but worth checking and testing for
/**
* @dev Internal conversion function (from assets to shares) with support for rounding direction.
*
* Will revert if assets > 0, totalSupply > 0 and totalAssets = 0. That corresponds to a case where any asset
* would represent an infinite amount of shares.
*/
function _convertToShares(uint256 assets, MathUpgradeable.Rounding rounding) internal view virtual override returns (uint256 shares) {
uint256 supply = totalSupply();

if (address(subVault) == address(0)) {
uint256 effectiveTotalAssets = enablePerformanceFee ? _min(totalAssets(), totalPrincipal) : totalAssets();

if (supply == 0 || effectiveTotalAssets == 0) {
return assets;
}
(shares,) = _convertToSharesDetailed(assets, rounding);
}

return supply.mulDiv(assets, effectiveTotalAssets, rounding);
}
function _convertToSharesDetailed(uint256 assets, MathUpgradeable.Rounding rounding) internal view returns (uint256 shares, uint256 assetsForSubVault) {
uint256 supply = totalSupply();

uint256 totalIdle = IERC20(asset()).balanceOf(address(this));
uint256 totalSubShares = subVault.balanceOf(address(this));

if (enablePerformanceFee) {
// since we use totalSubShares in the denominator of the final calculation,
// since we use totalSubShares and totalIdle in the denominators of the final calculation,
// and we are subtracting profit from it, we should use the same rounding direction for profit
totalSubShares -= totalProfitInSubVaultShares(_flipRounding(rounding));
totalIdle -= totalProfitInIdleAssets(_flipRounding(rounding));
}

uint256 subShares = _assetsToSubVaultShares(assets, rounding);
// figure out how much assets should be deposited to subvault vs kept idle
// same rounding direction since they are used in the numerators of the final calculation
uint256 assetsForIdle = assets.mulDiv(1e18 - targetAllocationWad, 1e18, rounding);
assetsForSubVault = assets.mulDiv(targetAllocationWad, 1e18, rounding);

if (supply == 0 || totalSubShares == 0) {
return subShares;
}
// figure out how many shares would be issued according to each portion
uint256 sharesFromIdle = assetsForIdle.mulDiv(supply, totalIdle, rounding);
uint256 sharesFromSubVault = _assetsToSubVaultShares(assetsForSubVault, rounding).mulDiv(supply, totalSubShares, rounding);

return supply.mulDiv(subShares, totalSubShares, rounding);
// take the min if rounding down, max if rounding up
shares = rounding == MathUpgradeable.Rounding.Down
? MathUpgradeable.min(sharesFromIdle, sharesFromSubVault)
: MathUpgradeable.max(sharesFromIdle, sharesFromSubVault);
}

/**
* @dev Internal conversion function (from shares to assets) with support for rounding direction.
*/
function _convertToAssets(uint256 shares, MathUpgradeable.Rounding rounding) internal view virtual override returns (uint256 assets) {
uint256 _totalSupply = totalSupply();

if(_totalSupply == 0) {
return shares;
}
(assets,) = _convertToAssetsDetailed(shares, rounding);
}

// if we have no subvault, we just do normal pro-rata calculation
if (address(subVault) == address(0)) {
uint256 effectiveTotalAssets = enablePerformanceFee ? _min(totalAssets(), totalPrincipal) : totalAssets();
return effectiveTotalAssets.mulDiv(shares, _totalSupply, rounding);
}
function _convertToAssetsDetailed(uint256 shares, MathUpgradeable.Rounding rounding) internal view returns (uint256 assets, uint256 assetsFromSubVault) {
uint256 supply = totalSupply();

uint256 totalIdle = IERC20(asset()).balanceOf(address(this));
uint256 totalSubShares = subVault.balanceOf(address(this));

if (enablePerformanceFee) {
// since we use totalSubShares in the numerator of the final calculation,
// since we use totalSubShares and totalIdle in the numerators of the final calculation,
// and we are subtracting profit from it, we should use the opposite rounding direction for profit
totalSubShares -= totalProfitInSubVaultShares(_flipRounding(rounding));
totalIdle -= totalProfitInIdleAssets(_flipRounding(rounding));
}

// totalSubShares * shares / totalMasterShares
uint256 subShares = totalSubShares.mulDiv(shares, _totalSupply, rounding);

return _subVaultSharesToAssets(subShares, rounding);
// figure out how many shares should be burned for subvault shares vs idle
// same rounding direction since they are used in the numerators of the final calculation (todo: confirm rounding direction)
uint256 sharesForIdle = shares.mulDiv(1e18 - targetAllocationWad, 1e18, rounding);
uint256 sharesForSubVault = shares.mulDiv(targetAllocationWad, 1e18, rounding);

// figure out how much assets would be received according to each portion
uint256 assetsFromIdle = sharesForIdle.mulDiv(totalIdle, supply, rounding);
assetsFromSubVault = _subVaultSharesToAssets(sharesForSubVault.mulDiv(totalSubShares, supply, rounding), rounding);

// total it up
assets = assetsFromIdle + assetsFromSubVault;
}

function _assetsToSubVaultShares(uint256 assets, MathUpgradeable.Rounding rounding) internal view returns (uint256 subShares) {
Expand Down
4 changes: 2 additions & 2 deletions test-foundry/libraries/vault/MasterVault.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ 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);
vault.setSubVault(IERC4626(address(_subvault)));
}
}

Expand All @@ -144,6 +144,6 @@ contract MasterVaultTestWithSubvaultHoldingAssets is MasterVaultTest {
"subvault should be initiated with shares = _initAmount"
);

vault.setSubVault(IERC4626(address(_subvault)), 0);
vault.setSubVault(IERC4626(address(_subvault)));
}
}
2 changes: 1 addition & 1 deletion test-foundry/libraries/vault/MasterVaultAttack.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ 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);
vault.setSubVault(IERC4626(address(_subvault)));
}
}

Expand Down
1 change: 0 additions & 1 deletion test-foundry/libraries/vault/MasterVaultCore.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,5 @@ contract MasterVaultCoreTest is Test {
vault = MasterVault(proxyAddress);

vault.initialize(IERC20(address(token)), name, symbol, address(this));
vault.unpause();
}
}
4 changes: 2 additions & 2 deletions test-foundry/libraries/vault/MasterVaultFee.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ 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);
vault.setSubVault(IERC4626(address(_subvault)));
}
}

Expand All @@ -275,6 +275,6 @@ contract MasterVaultFeeTestWithSubvaultHoldingAssets is MasterVaultFeeTest {
"subvault should be initiated with shares = _initAmount"
);

vault.setSubVault(IERC4626(address(_subvault)), 0);
vault.setSubVault(IERC4626(address(_subvault)));
}
}
Loading