From 179385cd3388f60f1ab7b5b7a124da785610aef4 Mon Sep 17 00:00:00 2001 From: Dmitri Tsumak Date: Mon, 30 Mar 2026 21:10:10 +0300 Subject: [PATCH 01/26] Fix MetaVault's redeem assets calculation does not account for sub-vault LTV --- contracts/vaults/SubVaultsRegistry.sol | 14 ++++-- test/SubVaultsRegistry.t.sol | 66 ++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/contracts/vaults/SubVaultsRegistry.sol b/contracts/vaults/SubVaultsRegistry.sol index d524a32c..4fd21978 100644 --- a/contracts/vaults/SubVaultsRegistry.sol +++ b/contracts/vaults/SubVaultsRegistry.sol @@ -42,6 +42,7 @@ contract SubVaultsRegistry is using DoubleEndedQueue for DoubleEndedQueue.Bytes32Deque; uint256 private constant _maxSubVaults = 50; + uint256 private constant _maxPercent = 1e18; address private immutable _curatorsRegistry; address private immutable _vaultsRegistry; @@ -939,6 +940,16 @@ contract SubVaultsRegistry is continue; } + // get shares before redemption to track actual consumption + uint256 sharesBefore = IVaultState(redeemRequest.vault).getShares(_metaVault); + + // cap redeemAssets by the sub-vault's LTV-constrained max redeemable assets + uint256 metaVaultAssets = IVaultState(redeemRequest.vault).convertToAssets(sharesBefore); + uint256 maxRedeemAssets = Math.mulDiv( + metaVaultAssets, _osTokenConfig.getConfig(redeemRequest.vault).ltvPercent, _maxPercent + ); + redeemAssets = Math.min(redeemAssets, maxRedeemAssets); + // mint osToken shares to redeemer uint256 osTokenShares = _osTokenVaultController.convertToShares(redeemAssets); if (osTokenShares == 0) { @@ -950,9 +961,6 @@ contract SubVaultsRegistry is } IVaultSubVaults(_metaVault).mintSubVaultOsToken(redeemRequest.vault, redeemer, osTokenShares); - // get shares before redemption to track actual consumption - uint256 sharesBefore = IVaultState(redeemRequest.vault).getShares(_metaVault); - // execute redeem redeemAssets = IVaultSubVaults(_metaVault).redeemSubVaultOsToken(redeemRequest.vault, redeemer, osTokenShares); diff --git a/test/SubVaultsRegistry.t.sol b/test/SubVaultsRegistry.t.sol index db3c3239..ab02796c 100644 --- a/test/SubVaultsRegistry.t.sol +++ b/test/SubVaultsRegistry.t.sol @@ -17,6 +17,10 @@ import {GnoMetaVault} from "../contracts/vaults/gnosis/GnoMetaVault.sol"; import {SubVaultsRegistry} from "../contracts/vaults/SubVaultsRegistry.sol"; import {BalancedCurator} from "../contracts/curators/BalancedCurator.sol"; import {CuratorsRegistry} from "../contracts/curators/CuratorsRegistry.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {IOsTokenConfig} from "../contracts/interfaces/IOsTokenConfig.sol"; +import {IOsTokenVaultController} from "../contracts/interfaces/IOsTokenVaultController.sol"; +import {EthOsTokenRedeemer} from "../contracts/tokens/EthOsTokenRedeemer.sol"; import {EthHelpers} from "./helpers/EthHelpers.sol"; import {GnoHelpers} from "./helpers/GnoHelpers.sol"; @@ -760,6 +764,68 @@ contract SubVaultsRegistryTest is Test, EthHelpers { // Empty requests should not revert but do nothing registry.claimSubVaultsExitedAssets(exitRequests); } + + function _harvestMetaVault() internal { + address[] memory allSubVaults = registry.getSubVaults(); + uint64 currentNonce = contracts.keeper.rewardsNonce(); + _setKeeperRewardsNonce(currentNonce + 1); + for (uint256 i = 0; i < allSubVaults.length; i++) { + _setVaultRewardsNonce(allSubVaults[i], currentNonce + 1); + } + metaVault.updateState(_getEmptyHarvestParams()); + } + + function test_redeemSubVaultsAssets_capsRedeemByLtv() public { + // Deploy osToken redeemer and set it in config + address owner = makeAddr("Owner"); + address positionsManager = makeAddr("PositionsManager"); + EthOsTokenRedeemer osTokenRedeemer = new EthOsTokenRedeemer( + address(contracts.vaultsRegistry), + _osToken, + address(contracts.osTokenVaultController), + owner, + 12 hours + ); + vm.prank(owner); + osTokenRedeemer.setPositionsManager(positionsManager); + + address configOwner = Ownable(address(contracts.osTokenConfig)).owner(); + vm.prank(configOwner); + contracts.osTokenConfig.setRedeemer(address(osTokenRedeemer)); + + // Remove fee percent for accurate calculations + vm.prank(Ownable(address(contracts.osTokenVaultController)).owner()); + contracts.osTokenVaultController.setFeePercent(0); + + // Deposit to meta vault and distribute to sub-vaults + vm.prank(admin); + metaVault.deposit{value: 10 ether}(admin, address(0)); + + _harvestMetaVault(); + registry.depositToSubVaults(); + _harvestMetaVault(); + + // Set low LTV (50%) on sub-vaults to trigger the LTV cap + for (uint256 i = 0; i < subVaults.length; i++) { + vm.prank(configOwner); + contracts.osTokenConfig.updateConfig( + subVaults[i], + IOsTokenConfig.Config({ltvPercent: 5e17, liqThresholdPercent: 6e17, liqBonusPercent: 1.1e18}) + ); + } + + // Drain meta vault withdrawable assets so redeem must go through sub-vaults + vm.deal(address(metaVault), 0); + + // Request full redemption - without the LTV cap fix this would revert with LowLtv + uint256 assetsToRedeem = 10 ether; + vm.prank(positionsManager); + uint256 totalRedeemed = osTokenRedeemer.redeemSubVaultsAssets(address(metaVault), assetsToRedeem); + + // Should redeem at most ltvPercent of the deposited assets + assertGt(totalRedeemed, 0, "Should redeem some assets"); + assertLe(totalRedeemed, assetsToRedeem, "Should not redeem more than requested"); + } } /// @title VaultSubVaultsUpgradeEthTest From e659b6c93a2ae3385594657d456f8316de490518 Mon Sep 17 00:00:00 2001 From: Dmitri Tsumak Date: Tue, 31 Mar 2026 11:07:03 +0300 Subject: [PATCH 02/26] Fix Penalty donation calls in NodesManager waste gas outside pendingPenalty > 0 blocks --- contracts/nodes/NodesManager.sol | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/contracts/nodes/NodesManager.sol b/contracts/nodes/NodesManager.sol index 6872eca6..fc7fea14 100644 --- a/contracts/nodes/NodesManager.sol +++ b/contracts/nodes/NodesManager.sol @@ -413,10 +413,7 @@ abstract contract NodesManager is pendingPenaltyAssets[operator] = 0; exitedAssets -= pendingPenalty; } - } - - // donate deducted penalty back to the vault - if (penaltyDeducted > 0) { + // donate deducted penalty back to the vault _donateAssets(penaltyDeducted); } @@ -453,11 +450,10 @@ abstract contract NodesManager is penaltyAssets = IVaultState(vault).convertToAssets(penaltyShares); pendingPenaltyAssets[msg.sender] = pendingPenalty - penaltyAssets; } - } - - if (penaltyShares > 0) { - // donate penalty shares to the vault - IVaultState(vault).donateShares(penaltyShares); + if (penaltyShares > 0) { + // donate penalty shares to the vault + IVaultState(vault).donateShares(penaltyShares); + } } // update operator's shares balance From 564006d0483c31402fac97a76e2f5f5a1ffa40fb Mon Sep 17 00:00:00 2001 From: Dmitri Tsumak Date: Tue, 31 Mar 2026 11:31:12 +0300 Subject: [PATCH 03/26] Fix dust loss in NodesManager.updateOperatorState() penalty calculation due to rounding --- contracts/nodes/NodesManager.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/nodes/NodesManager.sol b/contracts/nodes/NodesManager.sol index fc7fea14..231b3547 100644 --- a/contracts/nodes/NodesManager.sol +++ b/contracts/nodes/NodesManager.sol @@ -230,7 +230,7 @@ abstract contract NodesManager is uint256 penaltyAssetsDelta = params.cumPenaltyAssets - operatorState.cumPenaltyAssets; uint256 totalPenaltyAssets = penaltyAssetsDelta + pendingPenaltyAssets[operator]; if (totalPenaltyAssets > 0) { - totalPenaltyShares = IVaultState(vault).convertToShares(totalPenaltyAssets); + totalPenaltyShares = IVaultState(vault).convertToShares(totalPenaltyAssets + 1); } // apply penalty to balance, storing excess as pending if insufficient From 7ff7e73853965f75574073c686abf21077b196f4 Mon Sep 17 00:00:00 2001 From: Dmitri Tsumak Date: Wed, 1 Apr 2026 11:54:09 +0300 Subject: [PATCH 04/26] Fix penalty rounding tests and add audit comments --- contracts/vaults/SubVaultsRegistry.sol | 5 ++--- contracts/vaults/ethereum/EthErc20MetaVault.sol | 2 ++ contracts/vaults/ethereum/EthErc20Vault.sol | 2 ++ contracts/vaults/gnosis/GnoErc20Vault.sol | 2 ++ test/EthNodesManager.t.sol | 4 ++-- test/SubVaultsRegistry.t.sol | 15 ++++++--------- 6 files changed, 16 insertions(+), 14 deletions(-) diff --git a/contracts/vaults/SubVaultsRegistry.sol b/contracts/vaults/SubVaultsRegistry.sol index 4fd21978..7064155b 100644 --- a/contracts/vaults/SubVaultsRegistry.sol +++ b/contracts/vaults/SubVaultsRegistry.sol @@ -945,9 +945,8 @@ contract SubVaultsRegistry is // cap redeemAssets by the sub-vault's LTV-constrained max redeemable assets uint256 metaVaultAssets = IVaultState(redeemRequest.vault).convertToAssets(sharesBefore); - uint256 maxRedeemAssets = Math.mulDiv( - metaVaultAssets, _osTokenConfig.getConfig(redeemRequest.vault).ltvPercent, _maxPercent - ); + uint256 maxRedeemAssets = + Math.mulDiv(metaVaultAssets, _osTokenConfig.getConfig(redeemRequest.vault).ltvPercent, _maxPercent); redeemAssets = Math.min(redeemAssets, maxRedeemAssets); // mint osToken shares to redeemer diff --git a/contracts/vaults/ethereum/EthErc20MetaVault.sol b/contracts/vaults/ethereum/EthErc20MetaVault.sol index 89220584..2ce88d2b 100644 --- a/contracts/vaults/ethereum/EthErc20MetaVault.sol +++ b/contracts/vaults/ethereum/EthErc20MetaVault.sol @@ -157,6 +157,8 @@ contract EthErc20MetaVault is positionTicket = super.enterExitQueue(shares, receiver); // only emit Transfer if shares were queued (not directly redeemed when non-collateralized) if (positionTicket != type(uint256).max) { + // NB: queued shares are tracked in _queuedShares, not _balances[address(this)]. + // balanceOf(address(this)) will not reflect queued exit shares. emit Transfer(msg.sender, address(this), shares); } } diff --git a/contracts/vaults/ethereum/EthErc20Vault.sol b/contracts/vaults/ethereum/EthErc20Vault.sol index 8e5a812c..f64e94d2 100644 --- a/contracts/vaults/ethereum/EthErc20Vault.sol +++ b/contracts/vaults/ethereum/EthErc20Vault.sol @@ -134,6 +134,8 @@ contract EthErc20Vault is positionTicket = super.enterExitQueue(shares, receiver); // only emit Transfer if shares were queued (not directly redeemed when non-collateralized) if (positionTicket != type(uint256).max) { + // NB: queued shares are tracked in _queuedShares, not _balances[address(this)]. + // balanceOf(address(this)) will not reflect queued exit shares. emit Transfer(msg.sender, address(this), shares); } } diff --git a/contracts/vaults/gnosis/GnoErc20Vault.sol b/contracts/vaults/gnosis/GnoErc20Vault.sol index e15103a9..91b28ca4 100644 --- a/contracts/vaults/gnosis/GnoErc20Vault.sol +++ b/contracts/vaults/gnosis/GnoErc20Vault.sol @@ -111,6 +111,8 @@ contract GnoErc20Vault is returns (uint256 positionTicket) { positionTicket = super.enterExitQueue(shares, receiver); + // NB: queued shares are tracked in _queuedShares, not _balances[address(this)]. + // balanceOf(address(this)) will not reflect queued exit shares. emit Transfer(msg.sender, address(this), shares); } diff --git a/test/EthNodesManager.t.sol b/test/EthNodesManager.t.sol index 0fc6b222..d56fdbe8 100644 --- a/test/EthNodesManager.t.sol +++ b/test/EthNodesManager.t.sol @@ -620,7 +620,7 @@ contract EthNodesManagerTest is EthHelpers { _performStateUpdate(leaf, "stateIpfs"); _stopOracleImpersonate(address(contracts.keeper)); - uint256 expectedPenaltyShares = IVaultState(vault).convertToShares(cumPenaltyAssets); + uint256 expectedPenaltyShares = IVaultState(vault).convertToShares(uint256(cumPenaltyAssets) + 1); uint256 vaultTotalSharesBefore = IVaultState(vault).totalShares(); uint256 vaultTotalAssetsBefore = IVaultState(vault).totalAssets(); @@ -665,7 +665,7 @@ contract EthNodesManagerTest is EthHelpers { _performStateUpdate(leaf, "stateIpfs"); _stopOracleImpersonate(address(contracts.keeper)); - uint256 expectedPenaltyShares = IVaultState(vault).convertToShares(cumPenaltyAssets); + uint256 expectedPenaltyShares = IVaultState(vault).convertToShares(uint256(cumPenaltyAssets) + 1); uint256 vaultTotalSharesBefore = IVaultState(vault).totalShares(); INodesManager.OperatorStateUpdateParams memory params = INodesManager.OperatorStateUpdateParams({ diff --git a/test/SubVaultsRegistry.t.sol b/test/SubVaultsRegistry.t.sol index ab02796c..bf41c4b5 100644 --- a/test/SubVaultsRegistry.t.sol +++ b/test/SubVaultsRegistry.t.sol @@ -780,11 +780,7 @@ contract SubVaultsRegistryTest is Test, EthHelpers { address owner = makeAddr("Owner"); address positionsManager = makeAddr("PositionsManager"); EthOsTokenRedeemer osTokenRedeemer = new EthOsTokenRedeemer( - address(contracts.vaultsRegistry), - _osToken, - address(contracts.osTokenVaultController), - owner, - 12 hours + address(contracts.vaultsRegistry), _osToken, address(contracts.osTokenVaultController), owner, 12 hours ); vm.prank(owner); osTokenRedeemer.setPositionsManager(positionsManager); @@ -808,10 +804,11 @@ contract SubVaultsRegistryTest is Test, EthHelpers { // Set low LTV (50%) on sub-vaults to trigger the LTV cap for (uint256 i = 0; i < subVaults.length; i++) { vm.prank(configOwner); - contracts.osTokenConfig.updateConfig( - subVaults[i], - IOsTokenConfig.Config({ltvPercent: 5e17, liqThresholdPercent: 6e17, liqBonusPercent: 1.1e18}) - ); + contracts.osTokenConfig + .updateConfig( + subVaults[i], + IOsTokenConfig.Config({ltvPercent: 5e17, liqThresholdPercent: 6e17, liqBonusPercent: 1.1e18}) + ); } // Drain meta vault withdrawable assets so redeem must go through sub-vaults From 42674cbf6741eefbe2166eae3fa93bbd3d8696b3 Mon Sep 17 00:00:00 2001 From: Dmitri Tsumak Date: Wed, 1 Apr 2026 11:56:19 +0300 Subject: [PATCH 05/26] Fix GnoErc20Vault emitting Transfer on direct redeem --- contracts/vaults/gnosis/GnoErc20Vault.sol | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/contracts/vaults/gnosis/GnoErc20Vault.sol b/contracts/vaults/gnosis/GnoErc20Vault.sol index 91b28ca4..25a55e87 100644 --- a/contracts/vaults/gnosis/GnoErc20Vault.sol +++ b/contracts/vaults/gnosis/GnoErc20Vault.sol @@ -111,9 +111,12 @@ contract GnoErc20Vault is returns (uint256 positionTicket) { positionTicket = super.enterExitQueue(shares, receiver); - // NB: queued shares are tracked in _queuedShares, not _balances[address(this)]. - // balanceOf(address(this)) will not reflect queued exit shares. - emit Transfer(msg.sender, address(this), shares); + // only emit Transfer if shares were queued (not directly redeemed when non-collateralized) + if (positionTicket != type(uint256).max) { + // NB: queued shares are tracked in _queuedShares, not _balances[address(this)]. + // balanceOf(address(this)) will not reflect queued exit shares. + emit Transfer(msg.sender, address(this), shares); + } } /// @inheritdoc IVaultState From fdcbe557a594acb6fea1d2b80a700d4791f90618 Mon Sep 17 00:00:00 2001 From: Dmitri Tsumak Date: Wed, 1 Apr 2026 13:14:03 +0300 Subject: [PATCH 06/26] Fix ERC-20 vaults missing Transfer event on transferOsTokenPositionToEscrow --- .../vaults/ethereum/EthErc20MetaVault.sol | 17 ++++- contracts/vaults/ethereum/EthErc20Vault.sol | 17 ++++- contracts/vaults/gnosis/GnoErc20Vault.sol | 17 ++++- contracts/vaults/modules/VaultOsToken.sol | 7 +- test/EthErc20MetaVault.t.sol | 73 +++++++++++++++++++ test/EthErc20Vault.t.sol | 73 +++++++++++++++++++ test/gnosis/GnoErc20Vault.t.sol | 73 +++++++++++++++++++ 7 files changed, 273 insertions(+), 4 deletions(-) diff --git a/contracts/vaults/ethereum/EthErc20MetaVault.sol b/contracts/vaults/ethereum/EthErc20MetaVault.sol index 2ce88d2b..e2eb5880 100644 --- a/contracts/vaults/ethereum/EthErc20MetaVault.sol +++ b/contracts/vaults/ethereum/EthErc20MetaVault.sol @@ -20,7 +20,7 @@ import {VaultVersion, IVaultVersion} from "../modules/VaultVersion.sol"; import {VaultFee} from "../modules/VaultFee.sol"; import {VaultState, IVaultState} from "../modules/VaultState.sol"; import {VaultEnterExit, IVaultEnterExit} from "../modules/VaultEnterExit.sol"; -import {VaultOsToken} from "../modules/VaultOsToken.sol"; +import {IVaultOsToken, VaultOsToken} from "../modules/VaultOsToken.sol"; import {VaultSubVaults} from "../modules/VaultSubVaults.sol"; import {VaultToken} from "../modules/VaultToken.sol"; @@ -147,6 +147,21 @@ contract EthErc20MetaVault is return success; } + /// @inheritdoc IVaultOsToken + function transferOsTokenPositionToEscrow(uint256 osTokenShares) + public + virtual + override(IVaultOsToken, VaultOsToken) + returns (uint256 positionTicket) + { + uint256 sharesBefore = _balances[msg.sender]; + positionTicket = super.transferOsTokenPositionToEscrow(osTokenShares); + uint256 exitShares = sharesBefore - _balances[msg.sender]; + if (exitShares > 0) { + emit Transfer(msg.sender, address(this), exitShares); + } + } + /// @inheritdoc IVaultEnterExit function enterExitQueue(uint256 shares, address receiver) public diff --git a/contracts/vaults/ethereum/EthErc20Vault.sol b/contracts/vaults/ethereum/EthErc20Vault.sol index f64e94d2..dbffa23a 100644 --- a/contracts/vaults/ethereum/EthErc20Vault.sol +++ b/contracts/vaults/ethereum/EthErc20Vault.sol @@ -16,7 +16,7 @@ import {VaultVersion, IVaultVersion} from "../modules/VaultVersion.sol"; import {VaultImmutables} from "../modules/VaultImmutables.sol"; import {IVaultState, VaultState} from "../modules/VaultState.sol"; import {VaultEnterExit, IVaultEnterExit} from "../modules/VaultEnterExit.sol"; -import {VaultOsToken} from "../modules/VaultOsToken.sol"; +import {IVaultOsToken, VaultOsToken} from "../modules/VaultOsToken.sol"; import {VaultEthStaking} from "../modules/VaultEthStaking.sol"; import {VaultMev} from "../modules/VaultMev.sol"; import {VaultToken} from "../modules/VaultToken.sol"; @@ -124,6 +124,21 @@ contract EthErc20Vault is return success; } + /// @inheritdoc IVaultOsToken + function transferOsTokenPositionToEscrow(uint256 osTokenShares) + public + virtual + override(IVaultOsToken, VaultOsToken) + returns (uint256 positionTicket) + { + uint256 sharesBefore = _balances[msg.sender]; + positionTicket = super.transferOsTokenPositionToEscrow(osTokenShares); + uint256 exitShares = sharesBefore - _balances[msg.sender]; + if (exitShares > 0) { + emit Transfer(msg.sender, address(this), exitShares); + } + } + /// @inheritdoc IVaultEnterExit function enterExitQueue(uint256 shares, address receiver) public diff --git a/contracts/vaults/gnosis/GnoErc20Vault.sol b/contracts/vaults/gnosis/GnoErc20Vault.sol index 25a55e87..94aba743 100644 --- a/contracts/vaults/gnosis/GnoErc20Vault.sol +++ b/contracts/vaults/gnosis/GnoErc20Vault.sol @@ -16,7 +16,7 @@ import {VaultVersion, IVaultVersion} from "../modules/VaultVersion.sol"; import {VaultImmutables} from "../modules/VaultImmutables.sol"; import {IVaultState, VaultState} from "../modules/VaultState.sol"; import {VaultEnterExit, IVaultEnterExit} from "../modules/VaultEnterExit.sol"; -import {VaultOsToken} from "../modules/VaultOsToken.sol"; +import {IVaultOsToken, VaultOsToken} from "../modules/VaultOsToken.sol"; import {VaultGnoStaking} from "../modules/VaultGnoStaking.sol"; import {VaultMev} from "../modules/VaultMev.sol"; import {VaultToken} from "../modules/VaultToken.sol"; @@ -103,6 +103,21 @@ contract GnoErc20Vault is return success; } + /// @inheritdoc IVaultOsToken + function transferOsTokenPositionToEscrow(uint256 osTokenShares) + public + virtual + override(IVaultOsToken, VaultOsToken) + returns (uint256 positionTicket) + { + uint256 sharesBefore = _balances[msg.sender]; + positionTicket = super.transferOsTokenPositionToEscrow(osTokenShares); + uint256 exitShares = sharesBefore - _balances[msg.sender]; + if (exitShares > 0) { + emit Transfer(msg.sender, address(this), exitShares); + } + } + /// @inheritdoc IVaultEnterExit function enterExitQueue(uint256 shares, address receiver) public diff --git a/contracts/vaults/modules/VaultOsToken.sol b/contracts/vaults/modules/VaultOsToken.sol index 5cb255bf..d52ed590 100644 --- a/contracts/vaults/modules/VaultOsToken.sol +++ b/contracts/vaults/modules/VaultOsToken.sol @@ -97,7 +97,12 @@ abstract contract VaultOsToken is VaultImmutables, VaultState, VaultEnterExit, I } /// @inheritdoc IVaultOsToken - function transferOsTokenPositionToEscrow(uint256 osTokenShares) external override returns (uint256 positionTicket) { + function transferOsTokenPositionToEscrow(uint256 osTokenShares) + public + virtual + override + returns (uint256 positionTicket) + { // check whether vault assets are up to date _checkHarvested(); diff --git a/test/EthErc20MetaVault.t.sol b/test/EthErc20MetaVault.t.sol index c2be7f86..ec7f7194 100644 --- a/test/EthErc20MetaVault.t.sol +++ b/test/EthErc20MetaVault.t.sol @@ -9,13 +9,24 @@ import {IEthErc20MetaVault} from "../contracts/interfaces/IEthErc20MetaVault.sol import {IVaultState} from "../contracts/interfaces/IVaultState.sol"; import {IVaultEnterExit} from "../contracts/interfaces/IVaultEnterExit.sol"; import {IVaultOsToken} from "../contracts/interfaces/IVaultOsToken.sol"; +import {IOsTokenConfig} from "../contracts/interfaces/IOsTokenConfig.sol"; import {ISubVaultsRegistry} from "../contracts/interfaces/ISubVaultsRegistry.sol"; import {IKeeperRewards} from "../contracts/interfaces/IKeeperRewards.sol"; import {Errors} from "../contracts/libraries/Errors.sol"; import {EthErc20MetaVault} from "../contracts/vaults/ethereum/EthErc20MetaVault.sol"; import {EthHelpers} from "./helpers/EthHelpers.sol"; +interface IStrategiesRegistry { + function addStrategyProxy(bytes32 strategyProxyId, address proxy) external; + function setStrategy(address strategy, bool enabled) external; + + function owner() external view returns (address); +} + contract EthErc20MetaVaultTest is Test, EthHelpers { + IStrategiesRegistry private constant _strategiesRegistry = + IStrategiesRegistry(0x90b82E4b3aa385B4A02B7EBc1892a4BeD6B5c465); + ForkContracts public contracts; EthErc20MetaVault public metaVault; ISubVaultsRegistry public registry; @@ -452,4 +463,66 @@ contract EthErc20MetaVaultTest is Test, EthHelpers { _setKeeperRewardsNonce(initialNonce + 2); assertTrue(registry.isStateUpdateRequired(), "Should require state update when nonce is 2 higher"); } + + function test_transferOsTokenPositionToEscrow_emitsTransfer() public { + uint256 depositAmount = 10 ether; + vm.prank(sender); + metaVault.deposit{value: depositAmount}(sender, referrer); + registry.depositToSubVaults(); + + // Register sender in strategies registry for escrow + vm.prank(_strategiesRegistry.owner()); + _strategiesRegistry.setStrategy(address(this), true); + _strategiesRegistry.addStrategyProxy(keccak256(abi.encode(sender)), sender); + + // Mint osToken shares + IOsTokenConfig.Config memory vaultConfig = contracts.osTokenConfig.getConfig(address(metaVault)); + uint256 osTokenAssets = (depositAmount * vaultConfig.ltvPercent) / 1e18; + uint256 osTokenShares = contracts.osTokenVaultController.convertToShares(osTokenAssets); + vm.prank(sender); + metaVault.mintOsToken(sender, osTokenShares, referrer); + + // Calculate expected exit shares + uint256 sharesBefore = metaVault.balanceOf(sender); + + // Expect Transfer event from sender to vault (exit queue) + vm.expectEmit(true, true, true, false, address(metaVault)); + emit IERC20.Transfer(sender, address(metaVault), sharesBefore); + + // Transfer osToken position to escrow + vm.prank(sender); + metaVault.transferOsTokenPositionToEscrow(osTokenShares); + } + + function test_transferOsTokenPositionToEscrow_partialTransfer_emitsTransfer() public { + uint256 depositAmount = 10 ether; + vm.prank(sender); + metaVault.deposit{value: depositAmount}(sender, referrer); + registry.depositToSubVaults(); + + // Register sender in strategies registry for escrow + vm.prank(_strategiesRegistry.owner()); + _strategiesRegistry.setStrategy(address(this), true); + _strategiesRegistry.addStrategyProxy(keccak256(abi.encode(sender)), sender); + + // Mint osToken shares + IOsTokenConfig.Config memory vaultConfig = contracts.osTokenConfig.getConfig(address(metaVault)); + uint256 osTokenAssets = (depositAmount * vaultConfig.ltvPercent) / 1e18; + uint256 osTokenShares = contracts.osTokenVaultController.convertToShares(osTokenAssets); + vm.prank(sender); + metaVault.mintOsToken(sender, osTokenShares, referrer); + + // Transfer half of the osToken position + uint256 transferAmount = osTokenShares / 2; + uint256 sharesBefore = metaVault.balanceOf(sender); + + // Transfer partial osToken position to escrow + vm.prank(sender); + metaVault.transferOsTokenPositionToEscrow(transferAmount); + + // Verify sender's balance decreased proportionally + uint256 sharesAfter = metaVault.balanceOf(sender); + assertLt(sharesAfter, sharesBefore, "Balance should decrease after partial transfer"); + assertGt(sharesAfter, 0, "Balance should not be zero after partial transfer"); + } } diff --git a/test/EthErc20Vault.t.sol b/test/EthErc20Vault.t.sol index 837060f5..bb3d7c43 100644 --- a/test/EthErc20Vault.t.sol +++ b/test/EthErc20Vault.t.sol @@ -7,16 +7,27 @@ import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import {IKeeperRewards} from "../contracts/interfaces/IKeeperRewards.sol"; import {IEthErc20Vault} from "../contracts/interfaces/IEthErc20Vault.sol"; +import {IOsTokenConfig} from "../contracts/interfaces/IOsTokenConfig.sol"; import {Errors} from "../contracts/libraries/Errors.sol"; import {EthErc20Vault} from "../contracts/vaults/ethereum/EthErc20Vault.sol"; import {EthHelpers} from "./helpers/EthHelpers.sol"; +interface IStrategiesRegistry { + function addStrategyProxy(bytes32 strategyProxyId, address proxy) external; + function setStrategy(address strategy, bool enabled) external; + + function owner() external view returns (address); +} + interface IVaultStateV4 { function totalExitingAssets() external view returns (uint128); function queuedShares() external view returns (uint128); } contract EthErc20VaultTest is Test, EthHelpers { + IStrategiesRegistry private constant _strategiesRegistry = + IStrategiesRegistry(0x90b82E4b3aa385B4A02B7EBc1892a4BeD6B5c465); + ForkContracts public contracts; EthErc20Vault public vault; @@ -452,6 +463,68 @@ contract EthErc20VaultTest is Test, EthHelpers { assertLt(queuedShares, exitShares, "Exit queue should be at least partially processed"); } + function test_transferOsTokenPositionToEscrow_emitsTransfer() public { + _collateralizeEthVault(address(vault)); + + uint256 depositAmount = 10 ether; + _depositEth(depositAmount, sender, sender); + + // Register sender in strategies registry for escrow + vm.prank(_strategiesRegistry.owner()); + _strategiesRegistry.setStrategy(address(this), true); + _strategiesRegistry.addStrategyProxy(keccak256(abi.encode(sender)), sender); + + // Mint osToken shares + IOsTokenConfig.Config memory vaultConfig = contracts.osTokenConfig.getConfig(address(vault)); + uint256 osTokenAssets = (depositAmount * vaultConfig.ltvPercent) / 1e18; + uint256 osTokenShares = contracts.osTokenVaultController.convertToShares(osTokenAssets); + vm.prank(sender); + vault.mintOsToken(sender, osTokenShares, referrer); + + // Calculate expected exit shares + uint256 sharesBefore = vault.balanceOf(sender); + + // Expect Transfer event from sender to vault (exit queue) + vm.expectEmit(true, true, true, false, address(vault)); + emit IERC20.Transfer(sender, address(vault), sharesBefore); + + // Transfer osToken position to escrow + vm.prank(sender); + vault.transferOsTokenPositionToEscrow(osTokenShares); + } + + function test_transferOsTokenPositionToEscrow_partialTransfer_emitsTransfer() public { + _collateralizeEthVault(address(vault)); + + uint256 depositAmount = 10 ether; + _depositEth(depositAmount, sender, sender); + + // Register sender in strategies registry for escrow + vm.prank(_strategiesRegistry.owner()); + _strategiesRegistry.setStrategy(address(this), true); + _strategiesRegistry.addStrategyProxy(keccak256(abi.encode(sender)), sender); + + // Mint osToken shares + IOsTokenConfig.Config memory vaultConfig = contracts.osTokenConfig.getConfig(address(vault)); + uint256 osTokenAssets = (depositAmount * vaultConfig.ltvPercent) / 1e18; + uint256 osTokenShares = contracts.osTokenVaultController.convertToShares(osTokenAssets); + vm.prank(sender); + vault.mintOsToken(sender, osTokenShares, referrer); + + // Transfer half of the osToken position + uint256 transferAmount = osTokenShares / 2; + uint256 sharesBefore = vault.balanceOf(sender); + + // Transfer partial osToken position to escrow + vm.prank(sender); + vault.transferOsTokenPositionToEscrow(transferAmount); + + // Verify sender's balance decreased proportionally + uint256 sharesAfter = vault.balanceOf(sender); + assertLt(sharesAfter, sharesBefore, "Balance should decrease after partial transfer"); + assertGt(sharesAfter, 0, "Balance should not be zero after partial transfer"); + } + // Helper function to deposit ETH to the vault function _depositEth(uint256 amount, address from, address to) internal { vm.prank(from); diff --git a/test/gnosis/GnoErc20Vault.t.sol b/test/gnosis/GnoErc20Vault.t.sol index 68bf8a30..f62e51a4 100644 --- a/test/gnosis/GnoErc20Vault.t.sol +++ b/test/gnosis/GnoErc20Vault.t.sol @@ -7,6 +7,7 @@ import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import {IKeeperRewards} from "../../contracts/interfaces/IKeeperRewards.sol"; import {IGnoErc20Vault} from "../../contracts/interfaces/IGnoErc20Vault.sol"; +import {IOsTokenConfig} from "../../contracts/interfaces/IOsTokenConfig.sol"; import {Errors} from "../../contracts/libraries/Errors.sol"; import {GnoErc20Vault} from "../../contracts/vaults/gnosis/GnoErc20Vault.sol"; import {GnoHelpers} from "../helpers/GnoHelpers.sol"; @@ -16,7 +17,17 @@ interface IVaultStateV2 { function queuedShares() external view returns (uint128); } +interface IStrategiesRegistry { + function addStrategyProxy(bytes32 strategyProxyId, address proxy) external; + function setStrategy(address strategy, bool enabled) external; + + function owner() external view returns (address); +} + contract GnoErc20VaultTest is Test, GnoHelpers { + IStrategiesRegistry private constant _strategiesRegistry = + IStrategiesRegistry(0x4abB9BBb82922A6893A5d6890cd2eE94610BEc48); + ForkContracts public contracts; GnoErc20Vault public vault; @@ -348,6 +359,68 @@ contract GnoErc20VaultTest is Test, GnoHelpers { _stopSnapshotGas(); } + function test_transferOsTokenPositionToEscrow_emitsTransfer() public { + _collateralizeGnoVault(address(vault)); + + uint256 depositAmount = 1 ether; + _depositGno(depositAmount, sender, sender); + + // Register sender in strategies registry for escrow + vm.prank(_strategiesRegistry.owner()); + _strategiesRegistry.setStrategy(address(this), true); + _strategiesRegistry.addStrategyProxy(keccak256(abi.encode(sender)), sender); + + // Mint osToken shares + IOsTokenConfig.Config memory vaultConfig = contracts.osTokenConfig.getConfig(address(vault)); + uint256 osTokenAssets = (depositAmount * vaultConfig.ltvPercent) / 1e18; + uint256 osTokenShares = contracts.osTokenVaultController.convertToShares(osTokenAssets); + vm.prank(sender); + vault.mintOsToken(sender, osTokenShares, referrer); + + // Calculate expected exit shares + uint256 sharesBefore = vault.balanceOf(sender); + + // Expect Transfer event from sender to vault (exit queue) + vm.expectEmit(true, true, true, false, address(vault)); + emit IERC20.Transfer(sender, address(vault), sharesBefore); + + // Transfer osToken position to escrow + vm.prank(sender); + vault.transferOsTokenPositionToEscrow(osTokenShares); + } + + function test_transferOsTokenPositionToEscrow_partialTransfer_emitsTransfer() public { + _collateralizeGnoVault(address(vault)); + + uint256 depositAmount = 1 ether; + _depositGno(depositAmount, sender, sender); + + // Register sender in strategies registry for escrow + vm.prank(_strategiesRegistry.owner()); + _strategiesRegistry.setStrategy(address(this), true); + _strategiesRegistry.addStrategyProxy(keccak256(abi.encode(sender)), sender); + + // Mint osToken shares + IOsTokenConfig.Config memory vaultConfig = contracts.osTokenConfig.getConfig(address(vault)); + uint256 osTokenAssets = (depositAmount * vaultConfig.ltvPercent) / 1e18; + uint256 osTokenShares = contracts.osTokenVaultController.convertToShares(osTokenAssets); + vm.prank(sender); + vault.mintOsToken(sender, osTokenShares, referrer); + + // Transfer half of the osToken position + uint256 transferAmount = osTokenShares / 2; + uint256 sharesBefore = vault.balanceOf(sender); + + // Transfer partial osToken position to escrow + vm.prank(sender); + vault.transferOsTokenPositionToEscrow(transferAmount); + + // Verify sender's balance decreased proportionally + uint256 sharesAfter = vault.balanceOf(sender); + assertLt(sharesAfter, sharesBefore, "Balance should decrease after partial transfer"); + assertGt(sharesAfter, 0, "Balance should not be zero after partial transfer"); + } + // Helper function to deposit GNO to the vault function _depositGno(uint256 amount, address from, address to) internal { _depositToVault(address(vault), amount, from, to); From f9d7156a0aae4d68948e9c74def1f6c763bb75d4 Mon Sep 17 00:00:00 2001 From: Dmitri Tsumak Date: Wed, 1 Apr 2026 16:48:57 +0300 Subject: [PATCH 07/26] Fix user assets are transferred unnecessarily when the swap converts to zero osToken shares --- contracts/tokens/OsTokenRedeemer.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/tokens/OsTokenRedeemer.sol b/contracts/tokens/OsTokenRedeemer.sol index 9c624890..5b48844f 100644 --- a/contracts/tokens/OsTokenRedeemer.sol +++ b/contracts/tokens/OsTokenRedeemer.sol @@ -445,7 +445,7 @@ abstract contract OsTokenRedeemer is Ownable2Step, Multicall, IOsTokenRedeemer { osTokenShares = _osTokenVaultController.convertToShares(assets); if (osTokenShares == 0) { - return 0; // nothing to swap + revert Errors.InvalidShares(); } // update state From 173fe6987c301dd886ce84fba1347cfc8ae28d45 Mon Sep 17 00:00:00 2001 From: Dmitri Tsumak Date: Wed, 1 Apr 2026 17:14:30 +0300 Subject: [PATCH 08/26] Fix stale pendingPenaltyAssets in NodesManager._deposit() by adding nonce sync check --- contracts/nodes/NodesManager.sol | 8 ++++++++ test/EthNodesManager.t.sol | 34 ++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/contracts/nodes/NodesManager.sol b/contracts/nodes/NodesManager.sol index 231b3547..e476f7bc 100644 --- a/contracts/nodes/NodesManager.sol +++ b/contracts/nodes/NodesManager.sol @@ -433,6 +433,14 @@ abstract contract NodesManager is function _deposit(uint256 assets) internal returns (uint256 addedShares) { if (assets < minDepositAssets) revert Errors.InvalidAssets(); + // check whether the operator has synced the latest state + if ( + operatorStates[msg.sender].totalAssets > 0 + && operatorNonces[msg.sender][OperatorNonceType.LastStateUpdate] != stateData.currentNonce + ) { + revert Errors.NotHarvested(); + } + // deposit assets to the vault uint256 depositShares = _depositToVault(assets); diff --git a/test/EthNodesManager.t.sol b/test/EthNodesManager.t.sol index d56fdbe8..91e98ef0 100644 --- a/test/EthNodesManager.t.sol +++ b/test/EthNodesManager.t.sol @@ -256,6 +256,40 @@ contract EthNodesManagerTest is EthHelpers { ); } + function test_deposit_notSyncedState() public { + // first deposit - should succeed without state sync + vm.prank(user1); + nodesManager.deposit{value: 2 ether}(); + + _harvestVault(); + + // perform state update with operator data + uint128 opTotalAssets = 32 ether; + uint128 cumPenaltyAssets = 1 ether; + bytes32 leaf = _computeOperatorLeaf(user1, opTotalAssets, cumPenaltyAssets, 0); + _startOracleImpersonate(address(contracts.keeper)); + _performStateUpdate(leaf, "stateIpfs"); + _stopOracleImpersonate(address(contracts.keeper)); + + // sync operator state so totalAssets > 0 + _updateOperatorState(user1, opTotalAssets, cumPenaltyAssets, 0); + + // advance time past state update delay + vm.warp(block.timestamp + STATE_UPDATE_DELAY + 1); + + // perform another state update without syncing operator + uint128 cumPenaltyAssets2 = 2 ether; + bytes32 leaf2 = _computeOperatorLeaf(user1, opTotalAssets, cumPenaltyAssets2, 0); + _startOracleImpersonate(address(contracts.keeper)); + _performStateUpdate(leaf2, "stateIpfs2"); + _stopOracleImpersonate(address(contracts.keeper)); + + // deposit should revert because operator has totalAssets > 0 but state is not synced + vm.prank(user1); + vm.expectRevert(Errors.NotHarvested.selector); + nodesManager.deposit{value: 5 ether}(); + } + // ======== setMinDepositAssets ======== function test_setMinDepositAssets() public { From 03df924d22fd27a899187b0ee79b2cb5dde76e8f Mon Sep 17 00:00:00 2001 From: Dmitri Tsumak Date: Wed, 1 Apr 2026 17:34:44 +0300 Subject: [PATCH 09/26] Fix surplus ETH stuck in NodesManager on withdrawValidators --- contracts/nodes/NodesManager.sol | 13 +++++++++++- test/EthNodesManager.t.sol | 36 ++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/contracts/nodes/NodesManager.sol b/contracts/nodes/NodesManager.sol index e476f7bc..70179e67 100644 --- a/contracts/nodes/NodesManager.sol +++ b/contracts/nodes/NodesManager.sol @@ -318,8 +318,19 @@ abstract contract NodesManager is } /// @inheritdoc INodesManager - function withdrawValidators(bytes calldata validators) external payable override onlyWithdrawalsManager { + function withdrawValidators(bytes calldata validators) + external + payable + override + nonReentrant + onlyWithdrawalsManager + { + uint256 balanceBefore = address(this).balance - msg.value; IVaultValidators(vault).withdrawValidators{value: msg.value}(validators, bytes("")); + uint256 surplus = address(this).balance - balanceBefore; + if (surplus > 0) { + _transferAssets(msg.sender, surplus); + } emit ValidatorWithdrawalSubmitted(msg.sender); } diff --git a/test/EthNodesManager.t.sol b/test/EthNodesManager.t.sol index 91e98ef0..ade19371 100644 --- a/test/EthNodesManager.t.sol +++ b/test/EthNodesManager.t.sol @@ -1239,6 +1239,42 @@ contract EthNodesManagerTest is EthHelpers { _stopSnapshotGas(); } + function test_withdrawValidators_surplusRefund() public { + _addWithdrawableAssets(1); + _startOracleImpersonate(address(contracts.keeper)); + + // Register a validator to collateralize the vault + IKeeperValidators.ApprovalParams memory approvalParams = + _getEthValidatorApproval(vault, VALIDATOR_DEPOSIT, "ipfsHash", false); + + bytes memory registerSignatures = + _getRegisterValidatorsSignature(user1, approvalParams.validators, _oraclePrivateKey); + + vm.prank(validatorsManager1); + nodesManager.registerValidators(user1, approvalParams, registerSignatures); + _stopOracleImpersonate(address(contracts.keeper)); + + // Set withdrawals manager + address wManager = makeAddr("WithdrawalsManager"); + vm.prank(owner); + nodesManager.setWithdrawalsManager(wManager); + + // Construct withdrawal data: 48 bytes pubkey + 8 bytes amount (gwei) + bytes memory pubKey = new bytes(48); + bytes memory withdrawalData = bytes.concat(pubKey, bytes8(uint64(32 ether / 1 gwei))); + + // Fee is 0.1 ETH per validator in the mock, send double + uint256 fee = 0.1 ether; + uint256 surplus = 0.1 ether; + vm.deal(wManager, fee + surplus); + + vm.prank(wManager); + nodesManager.withdrawValidators{value: fee + surplus}(withdrawalData); + + // Verify surplus was refunded to the withdrawals manager + assertEq(wManager.balance, surplus, "Surplus ETH not refunded to withdrawals manager"); + } + function test_withdrawValidators_notWithdrawalsManager() public { // Set withdrawals manager address wManager = makeAddr("WithdrawalsManager"); From 7258b0ecd5fba6fde85122c0d2cc2b56878ca7cb Mon Sep 17 00:00:00 2001 From: Dmitri Tsumak Date: Wed, 1 Apr 2026 20:25:21 +0300 Subject: [PATCH 10/26] Skip redundant computation in getExitQueueMissingAssets when queuedShares is zero --- contracts/tokens/OsTokenRedeemer.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/tokens/OsTokenRedeemer.sol b/contracts/tokens/OsTokenRedeemer.sol index 5b48844f..2199ab29 100644 --- a/contracts/tokens/OsTokenRedeemer.sol +++ b/contracts/tokens/OsTokenRedeemer.sol @@ -173,7 +173,7 @@ abstract contract OsTokenRedeemer is Ownable2Step, Multicall, IOsTokenRedeemer { (uint256 _queuedShares, uint256 _unclaimedAssets, uint256 totalTickets) = getExitQueueData(); // check whether already covered - if (totalTickets >= targetCumulativeTickets) { + if (totalTickets >= targetCumulativeTickets || _queuedShares == 0) { return 0; } From 263429d27270cac27013ca82433fc377a1e9c082 Mon Sep 17 00:00:00 2001 From: Dmitri Tsumak Date: Wed, 1 Apr 2026 20:45:05 +0300 Subject: [PATCH 11/26] Remove incorrect availableAssets adjustment in getExitQueueMissingAssets --- contracts/tokens/OsTokenRedeemer.sol | 6 +--- test/EthOsTokenRedeemer.t.sol | 46 +++------------------------- 2 files changed, 5 insertions(+), 47 deletions(-) diff --git a/contracts/tokens/OsTokenRedeemer.sol b/contracts/tokens/OsTokenRedeemer.sol index 2199ab29..64d191f9 100644 --- a/contracts/tokens/OsTokenRedeemer.sol +++ b/contracts/tokens/OsTokenRedeemer.sol @@ -170,7 +170,7 @@ abstract contract OsTokenRedeemer is Ownable2Step, Multicall, IOsTokenRedeemer { returns (uint256 missingAssets) { // SLOAD to memory - (uint256 _queuedShares, uint256 _unclaimedAssets, uint256 totalTickets) = getExitQueueData(); + (uint256 _queuedShares,, uint256 totalTickets) = getExitQueueData(); // check whether already covered if (totalTickets >= targetCumulativeTickets || _queuedShares == 0) { @@ -182,10 +182,6 @@ abstract contract OsTokenRedeemer is Ownable2Step, Multicall, IOsTokenRedeemer { // calculate missing assets missingAssets = _osTokenVaultController.convertToAssets(Math.min(totalTicketsToCover, _queuedShares)); - - // check whether there is enough available assets - uint256 availableAssets = _getAssets(address(this)) - _unclaimedAssets; - return availableAssets >= missingAssets ? 0 : missingAssets - availableAssets; } /// @inheritdoc IOsTokenRedeemer diff --git a/test/EthOsTokenRedeemer.t.sol b/test/EthOsTokenRedeemer.t.sol index cd07ce94..e23f2148 100644 --- a/test/EthOsTokenRedeemer.t.sol +++ b/test/EthOsTokenRedeemer.t.sol @@ -771,40 +771,12 @@ contract EthOsTokenRedeemerTest is Test, EthHelpers { assertEq(missingAssets, 0, "Missing assets should be 0 when target < totalTickets"); } - function test_getExitQueueMissingAssets_availableAssetsExceedMissing() public { - // Setup: Enter exit queue with some shares - uint256 sharesToQueue = 10 ether; - address user = makeAddr("User"); - _enterExitQueue(user, sharesToQueue); - - // Send ETH directly to the redeemer contract (not via swap) to create available assets - // This way availableAssets > unclaimedAssets - uint256 directDeposit = 20 ether; - vm.deal(address(this), directDeposit); - (bool success,) = address(osTokenRedeemer).call{value: directDeposit}(""); - require(success, "Transfer failed"); - - // Get exit queue data - (,, uint256 totalTickets) = osTokenRedeemer.getExitQueueData(); - - // Target slightly above totalTickets - the direct deposit should cover this - uint256 ticketsToCover = 1 ether; - uint256 targetCumulativeTickets = totalTickets + ticketsToCover; - - // Query missing assets - should be 0 because available assets exceed the missing amount - uint256 missingAssets = osTokenRedeemer.getExitQueueMissingAssets(targetCumulativeTickets); - - // The contract has enough ETH to cover the missing assets - assertEq(missingAssets, 0, "Missing assets should be 0 when available assets exceed missing"); - } - - function test_getExitQueueMissingAssets_missingAssetsExceedAvailable() public { + function test_getExitQueueMissingAssets() public { // Setup: Enter exit queue with shares uint256 sharesToQueue = 100 ether; address user = makeAddr("User"); _enterExitQueue(user, sharesToQueue); - // Don't add any available assets - the redeemer contract has no ETH // Get exit queue data (,, uint256 totalTickets) = osTokenRedeemer.getExitQueueData(); uint256 cumulativeTickets = osTokenRedeemer.getExitQueueCumulativeTickets(); @@ -1156,23 +1128,13 @@ contract EthOsTokenRedeemerTest is Test, EthHelpers { } function test_swapAssetsToOsTokenShares_zeroOsTokenShares() public { - // Try swapping a very small amount that would result in 0 shares + // Try swapping a very small amount uint256 tinyAmount = 1 wei; vm.deal(address(this), tinyAmount); - // Store initial states - uint256 queuedSharesBefore = osTokenRedeemer.queuedShares(); - uint256 swappedSharesBefore = osTokenRedeemer.swappedShares(); - uint256 swappedAssetsBefore = osTokenRedeemer.swappedAssets(); - - // Call swap - should return 0 and not revert + // Call swap - should revert + vm.expectRevert(Errors.InvalidShares.selector); uint256 osTokenShares = osTokenRedeemer.swapAssetsToOsTokenShares{value: tinyAmount}(user1); - - // Verify no shares were swapped - assertEq(osTokenShares, 0, "Should return 0 shares for tiny amount"); - assertEq(osTokenRedeemer.queuedShares(), queuedSharesBefore, "Queued shares should not change"); - assertEq(osTokenRedeemer.swappedShares(), swappedSharesBefore, "Swapped shares should not change"); - assertEq(osTokenRedeemer.swappedAssets(), swappedAssetsBefore, "Swapped assets should not change"); } function test_swapAssetsToOsTokenShares_success() public { From 8523498d5581474455d3b45582f08bfde58a0600 Mon Sep 17 00:00:00 2001 From: Dmitri Tsumak Date: Wed, 1 Apr 2026 21:39:33 +0300 Subject: [PATCH 12/26] Fix token transfer ordering in GnoMetaVault.__GnoMetaVault_init() --- contracts/vaults/gnosis/GnoMetaVault.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/vaults/gnosis/GnoMetaVault.sol b/contracts/vaults/gnosis/GnoMetaVault.sol index bf3f4c8b..1cc04272 100644 --- a/contracts/vaults/gnosis/GnoMetaVault.sol +++ b/contracts/vaults/gnosis/GnoMetaVault.sol @@ -188,9 +188,9 @@ contract GnoMetaVault is __VaultFee_init(_admin, params.feePercent); __VaultState_init(params.capacity); - _deposit(address(this), _securityDeposit, address(0)); // see https://github.com/OpenZeppelin/openzeppelin-contracts/issues/3706 SafeERC20.safeTransferFrom(_gnoToken, msg.sender, address(this), _securityDeposit); + _deposit(address(this), _securityDeposit, address(0)); } /** From 0e9a4844bb0cb0cf5362545c9692a24e22986fd9 Mon Sep 17 00:00:00 2001 From: Dmitri Tsumak Date: Wed, 1 Apr 2026 22:32:53 +0300 Subject: [PATCH 13/26] Reduce redundant memory operations in OsTokenRedeemer and SubVaultsRegistry --- contracts/tokens/OsTokenRedeemer.sol | 15 ++++++--------- contracts/vaults/SubVaultsRegistry.sol | 3 +-- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/contracts/tokens/OsTokenRedeemer.sol b/contracts/tokens/OsTokenRedeemer.sol index 64d191f9..dfe5a3ac 100644 --- a/contracts/tokens/OsTokenRedeemer.sol +++ b/contracts/tokens/OsTokenRedeemer.sol @@ -190,9 +190,7 @@ abstract contract OsTokenRedeemer is Ownable2Step, Multicall, IOsTokenRedeemer { revert Errors.InvalidRedeemablePositions(); } - // SLOAD to memory - RedeemablePositions memory currentPositions = _redeemablePositions; - if (newPositions.merkleRoot == currentPositions.merkleRoot) { + if (newPositions.merkleRoot == _redeemablePositions.merkleRoot) { revert Errors.ValueNotChanged(); } @@ -337,17 +335,16 @@ abstract contract OsTokenRedeemer is Ownable2Step, Multicall, IOsTokenRedeemer { Math.min(position.leafShares - processedPositionShares, position.sharesToRedeem), Math.min(_queuedShares, IVaultOsToken(position.vault).osTokenPositions(position.owner)) ); - position.sharesToRedeem = sharesToRedeem; - // update state - if (position.sharesToRedeem > 0) { + if (sharesToRedeem > 0) { unchecked { - // position.sharesToRedeem <= _queuedShares checked above - _queuedShares -= position.sharesToRedeem; + // sharesToRedeem <= _queuedShares checked above + _queuedShares -= sharesToRedeem; // cannot realistically overflow - leafToProcessedShares[leaf] = processedPositionShares + position.sharesToRedeem; + leafToProcessedShares[leaf] = processedPositionShares + sharesToRedeem; } } + position.sharesToRedeem = sharesToRedeem; unchecked { // cannot realistically overflow diff --git a/contracts/vaults/SubVaultsRegistry.sol b/contracts/vaults/SubVaultsRegistry.sol index 7064155b..ddd203fd 100644 --- a/contracts/vaults/SubVaultsRegistry.sol +++ b/contracts/vaults/SubVaultsRegistry.sol @@ -448,9 +448,8 @@ contract SubVaultsRegistry is _checkSubVaultsExitClaims(vaults); // calculate new total assets and save balances in each sub vault - uint256[] memory balances; uint256 newSubVaultsTotalAssets; - (balances, newSubVaultsTotalAssets) = _getSubVaultsBalances(vaults, true); + (, newSubVaultsTotalAssets) = _getSubVaultsBalances(vaults, true); // store new sub vaults total assets delta totalAssetsDelta = SafeCast.toInt256(newSubVaultsTotalAssets) - SafeCast.toInt256(subVaultsTotalAssets); From b8395a7506fbc9ebf086f22c2c27ac9da23f0d76 Mon Sep 17 00:00:00 2001 From: Dmitri Tsumak Date: Wed, 1 Apr 2026 22:36:03 +0300 Subject: [PATCH 14/26] Cache metaVault SLOAD before access check in enterSubVaultsExitQueue --- contracts/vaults/SubVaultsRegistry.sol | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/contracts/vaults/SubVaultsRegistry.sol b/contracts/vaults/SubVaultsRegistry.sol index ddd203fd..0f81689c 100644 --- a/contracts/vaults/SubVaultsRegistry.sol +++ b/contracts/vaults/SubVaultsRegistry.sol @@ -468,12 +468,11 @@ contract SubVaultsRegistry is /// @inheritdoc ISubVaultsRegistry function enterSubVaultsExitQueue() external override nonReentrant { - if (msg.sender != metaVault) { - revert Errors.AccessDenied(); - } - // SLOAD to memory address _metaVault = metaVault; + if (msg.sender != _metaVault) { + revert Errors.AccessDenied(); + } (uint128 queuedShares,,,, uint256 totalExitedTickets) = IVaultState(_metaVault).getExitQueueData(); uint256 totalProcessedTickets = Math.max(_totalProcessedExitQueueTickets, totalExitedTickets); From 1a2ed7acc63e042354323fab275dd7deb82877ee Mon Sep 17 00:00:00 2001 From: Dmitri Tsumak Date: Wed, 1 Apr 2026 22:44:52 +0300 Subject: [PATCH 15/26] Remove misleading SLOAD comments from non-SLOAD operations --- contracts/keeper/KeeperRewards.sol | 1 - contracts/libraries/OsTokenUtils.sol | 1 - contracts/vaults/SubVaultsRegistry.sol | 1 - 3 files changed, 3 deletions(-) diff --git a/contracts/keeper/KeeperRewards.sol b/contracts/keeper/KeeperRewards.sol index 0f3f668a..02d61b7c 100644 --- a/contracts/keeper/KeeperRewards.sol +++ b/contracts/keeper/KeeperRewards.sol @@ -186,7 +186,6 @@ abstract contract KeeperRewards is KeeperOracles, IKeeperRewards { revert Errors.InvalidProof(); } - // SLOAD to memory Reward storage lastReward = rewards[msg.sender]; // check whether Vault's nonce is smaller that the current, otherwise it's already harvested if (lastReward.nonce >= currentNonce) return (0, 0, false); diff --git a/contracts/libraries/OsTokenUtils.sol b/contracts/libraries/OsTokenUtils.sol index fd1965a0..a551e3f9 100644 --- a/contracts/libraries/OsTokenUtils.sol +++ b/contracts/libraries/OsTokenUtils.sol @@ -46,7 +46,6 @@ library OsTokenUtils { IOsTokenVaultController osTokenVaultController, RedemptionData memory data ) external view returns (uint256 receivedAssets) { - // SLOAD to memory IOsTokenConfig.Config memory config = osTokenConfig.getConfig(address(this)); // calculate received assets diff --git a/contracts/vaults/SubVaultsRegistry.sol b/contracts/vaults/SubVaultsRegistry.sol index 0f81689c..2dee4e8c 100644 --- a/contracts/vaults/SubVaultsRegistry.sol +++ b/contracts/vaults/SubVaultsRegistry.sol @@ -309,7 +309,6 @@ contract SubVaultsRegistry is /// @inheritdoc ISubVaultsRegistry function isStateUpdateRequired() public view override returns (bool) { - // SLOAD to memory uint256 currentNonce = _getCurrentRewardsNonce(); unchecked { // cannot realistically overflow From 5e8d7ab45ef2b5a0fca16c7ae808057f7c93c2dc Mon Sep 17 00:00:00 2001 From: Dmitri Tsumak Date: Thu, 2 Apr 2026 16:34:06 +0300 Subject: [PATCH 16/26] Add vault harvest freshness check in NodesManager.claimExitedAssets --- contracts/nodes/NodesManager.sol | 3 +++ test/EthNodesManager.t.sol | 44 ++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/contracts/nodes/NodesManager.sol b/contracts/nodes/NodesManager.sol index 70179e67..54d3dbcb 100644 --- a/contracts/nodes/NodesManager.sol +++ b/contracts/nodes/NodesManager.sol @@ -371,6 +371,9 @@ abstract contract NodesManager is address operator = _exitPositions[positionTicket]; if (operator == address(0)) revert Errors.InvalidTicket(); + // check whether the vault is harvested + if (_keeper.isHarvestRequired(vault)) revert Errors.NotHarvested(); + // check whether the operator has synced the latest state uint128 currentNonce = stateData.currentNonce; if (operatorNonces[operator][OperatorNonceType.LastStateUpdate] != currentNonce) { diff --git a/test/EthNodesManager.t.sol b/test/EthNodesManager.t.sol index ade19371..d6d3864d 100644 --- a/test/EthNodesManager.t.sol +++ b/test/EthNodesManager.t.sol @@ -1679,6 +1679,50 @@ contract EthNodesManagerTest is EthHelpers { nodesManager.claimExitedAssets(999, block.timestamp, 0); } + function test_claimExitedAssets_notHarvested() public { + // deposit + sync state + vm.prank(user1); + nodesManager.deposit{value: 10 ether}(); + + _harvestVault(); + uint128 opTotalAssets = 1 ether; + bytes32 leaf = _computeOperatorLeaf(user1, opTotalAssets, 0, 0); + _startOracleImpersonate(address(contracts.keeper)); + _performStateUpdate(leaf, "stateIpfs"); + _stopOracleImpersonate(address(contracts.keeper)); + _updateOperatorState(user1, opTotalAssets, 0, 0); + + _collateralizeEthVault(vault); + + // enter exit queue + uint256 exitShares = _getBalanceShares(user1) / 2; + vm.prank(user1); + uint256 positionTicket = nodesManager.enterExitQueue(exitShares); + + // advance state nonces to satisfy _validatorChangeClaimDelay + _harvestVault(); + leaf = _computeOperatorLeaf(user1, opTotalAssets, 0, 0); + _startOracleImpersonate(address(contracts.keeper)); + vm.warp(block.timestamp + STATE_UPDATE_DELAY + 1); + _performStateUpdate(leaf, "stateIpfs2"); + _stopOracleImpersonate(address(contracts.keeper)); + _updateOperatorState(user1, opTotalAssets, 0, 0); + + _harvestVault(); + _startOracleImpersonate(address(contracts.keeper)); + vm.warp(block.timestamp + STATE_UPDATE_DELAY + 1); + _performStateUpdate(leaf, "stateIpfs3"); + _stopOracleImpersonate(address(contracts.keeper)); + _updateOperatorState(user1, opTotalAssets, 0, 0); + + // make vault harvest required (stale state) + _makeHarvestRequired(); + + // should revert because vault is not harvested + vm.expectRevert(Errors.NotHarvested.selector); + nodesManager.claimExitedAssets(positionTicket, block.timestamp, 0); + } + function test_claimExitedAssets_notSyncedState() public { // deposit + sync state vm.prank(user1); From be518bcc94ef870913f4f910313b8f51678cd36a Mon Sep 17 00:00:00 2001 From: Dmitri Tsumak Date: Thu, 2 Apr 2026 18:11:04 +0300 Subject: [PATCH 17/26] Skip harvest check for uncollateralized meta vaults --- contracts/vaults/SubVaultsRegistry.sol | 5 +- test/EthMetaVault.t.sol | 77 ++++++++++++++++++++++---- 2 files changed, 69 insertions(+), 13 deletions(-) diff --git a/contracts/vaults/SubVaultsRegistry.sol b/contracts/vaults/SubVaultsRegistry.sol index 2dee4e8c..42b58621 100644 --- a/contracts/vaults/SubVaultsRegistry.sol +++ b/contracts/vaults/SubVaultsRegistry.sol @@ -298,17 +298,20 @@ contract SubVaultsRegistry is /// @inheritdoc ISubVaultsRegistry function canUpdateState() external view override returns (bool) { + if (!isCollateralized()) return false; uint256 nonce = subVaultsRewardsNonce; return nonce != 0 && nonce < _getCurrentRewardsNonce(); } /// @inheritdoc ISubVaultsRegistry - function isCollateralized() external view override returns (bool) { + function isCollateralized() public view override returns (bool) { return _subVaults.length() > 0; } /// @inheritdoc ISubVaultsRegistry function isStateUpdateRequired() public view override returns (bool) { + if (!isCollateralized()) return false; + uint256 currentNonce = _getCurrentRewardsNonce(); unchecked { // cannot realistically overflow diff --git a/test/EthMetaVault.t.sol b/test/EthMetaVault.t.sol index 2a62d881..a29a5456 100644 --- a/test/EthMetaVault.t.sol +++ b/test/EthMetaVault.t.sol @@ -198,6 +198,62 @@ contract EthMetaVaultTest is Test, EthHelpers { } } + function _createEmptyMetaVault() internal returns (EthMetaVault) { + bytes memory emptyInitParams = abi.encode( + IEthMetaVault.EthMetaVaultInitParams({ + subVaultsCurator: _balancedCurator, + capacity: type(uint256).max, + feePercent: 0, + metadataIpfsHash: "bafkreidivzimqfqtoqxkrpge6bjyhlvxqs3rhe73owtmdulaxr5do5in7u" + }) + ); + return EthMetaVault(payable(_createVault(VaultType.EthMetaVault, admin, emptyInitParams, false))); + } + + function test_depositWithNoSubVaultsAfterNonceAdvance() public { + // Create a new meta vault without sub vaults + EthMetaVault emptyMetaVault = _createEmptyMetaVault(); + + // Advance keeper nonce by 2 so isStateUpdateRequired would have returned true without the fix + uint64 initialNonce = contracts.keeper.rewardsNonce(); + _setKeeperRewardsNonce(initialNonce + 2); + + // Deposit should succeed despite nonce being 2+ ahead + uint256 depositAmount = 1 ether; + uint256 totalAssetsBefore = emptyMetaVault.totalAssets(); + vm.prank(sender); + uint256 shares = emptyMetaVault.deposit{value: depositAmount}(sender, referrer); + assertGt(shares, 0, "Should have received shares"); + assertEq(emptyMetaVault.totalAssets(), totalAssetsBefore + depositAmount, "Incorrect total assets"); + } + + function test_withdrawWithNoSubVaultsAfterNonceAdvance() public { + // Create a new meta vault without sub vaults and deposit + EthMetaVault emptyMetaVault = _createEmptyMetaVault(); + uint256 depositAmount = 1 ether; + uint256 totalAssetsBefore = emptyMetaVault.totalAssets(); + vm.prank(sender); + uint256 shares = emptyMetaVault.deposit{value: depositAmount}(sender, referrer); + + // Advance keeper nonce by 2 so isStateUpdateRequired would have returned true without the fix + uint64 initialNonce = contracts.keeper.rewardsNonce(); + _setKeeperRewardsNonce(initialNonce + 2); + + // Withdraw should succeed with instant redemption (no exit queue for uncollateralized vaults) + uint256 senderBalanceBefore = sender.balance; + vm.prank(sender); + uint256 positionTicket = emptyMetaVault.enterExitQueue(shares, sender); + + // Uncollateralized vaults return max uint256 (instant redemption, no queue) + assertEq(positionTicket, type(uint256).max, "Should return max uint256 for instant redemption"); + + // Verify assets were transferred immediately + uint256 senderBalanceAfter = sender.balance; + assertEq(senderBalanceAfter - senderBalanceBefore, depositAmount, "Should have received deposited ETH"); + assertEq(emptyMetaVault.totalAssets(), totalAssetsBefore, "Total assets should return to pre-deposit level"); + assertEq(emptyMetaVault.getShares(sender), 0, "Shares should be zero after full withdrawal"); + } + function test_deposit() public { uint256 totalAssetsBefore = metaVault.totalAssets(); uint256 totalSharesBefore = metaVault.totalShares(); @@ -382,20 +438,17 @@ contract EthMetaVaultTest is Test, EthHelpers { assertFalse(registry.isStateUpdateRequired(), "Should not require state update after updating"); // Test with empty sub vaults - // Create a new meta vault without sub vaults - bytes memory emptyInitParams = abi.encode( - IEthMetaVault.EthMetaVaultInitParams({ - subVaultsCurator: _balancedCurator, - capacity: 1000 ether, - feePercent: 1000, - metadataIpfsHash: "bafkreidivzimqfqtoqxkrpge6bjyhlvxqs3rhe73owtmdulaxr5do5in7u" - }) - ); - EthMetaVault emptyMetaVault = - EthMetaVault(payable(_getOrCreateVault(VaultType.EthMetaVault, admin, emptyInitParams, false))); + EthMetaVault emptyMetaVault = _createEmptyMetaVault(); + ISubVaultsRegistry emptyRegistry = _getRegistry(address(emptyMetaVault)); // Verify empty vault behavior - assertFalse(emptyMetaVault.isStateUpdateRequired(), "Empty vault should not require state update"); + assertFalse(emptyRegistry.isStateUpdateRequired(), "Empty vault should not require state update"); + + // Advance keeper nonce by 2 - empty vault should still not require update + _setKeeperRewardsNonce(initialNonce + 4); + assertFalse( + emptyRegistry.isStateUpdateRequired(), "Empty vault should not require state update after nonce advance" + ); // Test when keeper nonce is less than meta vault nonce (this shouldn't happen in practice) // First update meta vault state From 5c7e0d0b18cfd958fa8b93dea9188586aa70d8b7 Mon Sep 17 00:00:00 2001 From: Dmitri Tsumak Date: Thu, 2 Apr 2026 21:32:21 +0300 Subject: [PATCH 18/26] Align leftShares threshold in SubVaultsRegistry with subvault exit logic --- contracts/vaults/SubVaultsRegistry.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/vaults/SubVaultsRegistry.sol b/contracts/vaults/SubVaultsRegistry.sol index 42b58621..3e921283 100644 --- a/contracts/vaults/SubVaultsRegistry.sol +++ b/contracts/vaults/SubVaultsRegistry.sol @@ -384,7 +384,7 @@ contract SubVaultsRegistry is .calculateExitedAssets(_metaVault, positionTicket, exitRequest.timestamp, exitRequest.exitQueueIndex); subVaultState.queuedShares -= SafeCast.toUint128(positionShares); - if (leftShares > 0) { + if (leftShares > 1) { // exit request was not processed in full SubVaultExits.pushSubVaultExit( _subVaultsExits, From 127faa2f59dd525a76cfb9bada73060287186d16 Mon Sep 17 00:00:00 2001 From: Dmitri Tsumak Date: Thu, 2 Apr 2026 22:11:21 +0300 Subject: [PATCH 19/26] Respect sub-vault capacities in BalancedCurator deposit distribution --- contracts/curators/BalancedCurator.sol | 80 +++++++++--- contracts/interfaces/ISubVaultsCurator.sol | 2 +- test/BalancedCurator.t.sol | 142 ++++++++++++++++++++- 3 files changed, 201 insertions(+), 23 deletions(-) diff --git a/contracts/curators/BalancedCurator.sol b/contracts/curators/BalancedCurator.sol index d1e6b163..29f4136d 100644 --- a/contracts/curators/BalancedCurator.sol +++ b/contracts/curators/BalancedCurator.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.22; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {ISubVaultsCurator} from "../interfaces/ISubVaultsCurator.sol"; +import {IVaultState} from "../interfaces/IVaultState.sol"; import {Errors} from "../libraries/Errors.sol"; /** @@ -15,7 +16,7 @@ contract BalancedCurator is ISubVaultsCurator { /// @inheritdoc ISubVaultsCurator function getDeposits(uint256 assetsToDeposit, address[] calldata subVaults, address ejectingVault) external - pure + view override returns (Deposit[] memory deposits) { @@ -24,42 +25,85 @@ contract BalancedCurator is ISubVaultsCurator { } uint256 subVaultsCount = subVaults.length; - // the deposits should not be made to the vault that is being ejected - uint256 depositSubVaultsCount = ejectingVault != address(0) ? subVaultsCount - 1 : subVaultsCount; - if (depositSubVaultsCount == 0) { - revert Errors.EmptySubVaults(); - } - uint256 amountPerVault = assetsToDeposit / depositSubVaultsCount; - uint256 dust = assetsToDeposit % depositSubVaultsCount; - - // distribute assets evenly across sub-vaults deposits = new Deposit[](subVaultsCount); bool ejectingVaultFound = false; + + // fetch remaining capacities and validate vaults + uint256[] memory capacities = new uint256[](subVaultsCount); for (uint256 i = 0; i < subVaultsCount;) { address subVault = subVaults[i]; if (subVault == address(0)) { revert Errors.ZeroAddress(); - } else if (subVault == ejectingVault) { + } + deposits[i].vault = subVault; + if (subVault == ejectingVault) { if (ejectingVaultFound) { - // only one vault can be ejected at a time revert Errors.RepeatedEjectingVault(); } - deposits[i] = Deposit({vault: subVault, assets: 0}); ejectingVaultFound = true; - } else if (dust > 0) { - deposits[i] = Deposit({vault: subVault, assets: amountPerVault + dust}); - dust = 0; // only one vault can receive dust } else { - deposits[i] = Deposit({vault: subVault, assets: amountPerVault}); + uint256 capacity = IVaultState(subVault).capacity(); + uint256 totalAssets = IVaultState(subVault).totalAssets(); + capacities[i] = capacity > totalAssets ? capacity - totalAssets : 0; } unchecked { - // cannot realistically overflow ++i; } } if (ejectingVault != address(0) && !ejectingVaultFound) { revert Errors.EjectingVaultNotFound(); } + + // count sub-vaults with available capacity + uint256 depositSubVaultsCount; + for (uint256 i = 0; i < subVaultsCount;) { + if (capacities[i] > 0) { + depositSubVaultsCount += 1; + } + unchecked { + ++i; + } + } + + // distribute assets evenly across sub-vaults, respecting capacities + while (assetsToDeposit > 0) { + if (depositSubVaultsCount == 0) { + revert Errors.EmptySubVaults(); + } + uint256 amountPerVault = + assetsToDeposit > depositSubVaultsCount ? assetsToDeposit / depositSubVaultsCount : assetsToDeposit; + + depositSubVaultsCount = 0; + for (uint256 i = 0; i < subVaultsCount;) { + uint256 subVaultCapacity = capacities[i]; + + if (subVaultCapacity == 0) { + unchecked { + ++i; + } + continue; + } + + uint256 depositAmount = Math.min(Math.min(subVaultCapacity, amountPerVault), assetsToDeposit); + + deposits[i].assets += depositAmount; + assetsToDeposit -= depositAmount; + if (assetsToDeposit == 0) { + return deposits; + } + + subVaultCapacity -= depositAmount; + capacities[i] = subVaultCapacity; + + if (subVaultCapacity > 0) { + depositSubVaultsCount += 1; + } + + unchecked { + ++i; + } + } + } } /// @inheritdoc ISubVaultsCurator diff --git a/contracts/interfaces/ISubVaultsCurator.sol b/contracts/interfaces/ISubVaultsCurator.sol index 19455ea8..feab55f4 100644 --- a/contracts/interfaces/ISubVaultsCurator.sol +++ b/contracts/interfaces/ISubVaultsCurator.sol @@ -37,7 +37,7 @@ interface ISubVaultsCurator { */ function getDeposits(uint256 assetsToDeposit, address[] calldata subVaults, address ejectingVault) external - pure + view returns (Deposit[] memory deposits); /** diff --git a/test/BalancedCurator.t.sol b/test/BalancedCurator.t.sol index 96f8f3b4..cd7fd6a8 100644 --- a/test/BalancedCurator.t.sol +++ b/test/BalancedCurator.t.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.22; import {Test} from "forge-std/Test.sol"; import {ISubVaultsCurator} from "../contracts/interfaces/ISubVaultsCurator.sol"; +import {IVaultState} from "../contracts/interfaces/IVaultState.sol"; import {BalancedCurator} from "../contracts/curators/BalancedCurator.sol"; import {Errors} from "../contracts/libraries/Errors.sol"; @@ -27,10 +28,22 @@ contract BalancedCuratorTest is Test { ejectingVault = address(uint160(0x2000)); } - function test_getDeposits_normalDistribution() public view { + function _mockVaultCapacity(address vault, uint256 capacity, uint256 totalAssets) internal { + vm.mockCall(vault, abi.encodeWithSelector(IVaultState.capacity.selector), abi.encode(capacity)); + vm.mockCall(vault, abi.encodeWithSelector(IVaultState.totalAssets.selector), abi.encode(totalAssets)); + } + + function _mockUnlimitedCapacities(address[] memory vaults) internal { + for (uint256 i = 0; i < vaults.length; i++) { + _mockVaultCapacity(vaults[i], type(uint256).max, 0); + } + } + + function test_getDeposits_normalDistribution() public { // 100 ETH to distribute across 5 vaults uint256 assetsToDeposit = 100 ether; address[] memory vaults = subVaults; + _mockUnlimitedCapacities(vaults); // No ejecting vault ISubVaultsCurator.Deposit[] memory deposits = curator.getDeposits(assetsToDeposit, vaults, address(0)); @@ -47,10 +60,11 @@ contract BalancedCuratorTest is Test { } } - function test_getDeposits_withEjectingVault() public view { + function test_getDeposits_withEjectingVault() public { // 100 ETH to distribute across 5 vaults, but one is ejecting uint256 assetsToDeposit = 100 ether; address[] memory vaults = subVaults; + _mockUnlimitedCapacities(vaults); address ejecting = vaults[2]; // The third vault is ejecting ISubVaultsCurator.Deposit[] memory deposits = curator.getDeposits(assetsToDeposit, vaults, ejecting); @@ -75,6 +89,7 @@ contract BalancedCuratorTest is Test { // 100 ETH to distribute across 5 vaults, but one is ejecting uint256 assetsToDeposit = 100 ether; address[] memory vaults = subVaults; + _mockUnlimitedCapacities(vaults); address ejecting = makeAddr("Unknown"); // Should revert with EjectingVaultNotFound error @@ -82,10 +97,11 @@ contract BalancedCuratorTest is Test { curator.getDeposits(assetsToDeposit, vaults, ejecting); } - function test_getDeposits_smallAmount() public view { + function test_getDeposits_smallAmount() public { // 5 ETH to distribute across 5 vaults uint256 assetsToDeposit = 5 ether; address[] memory vaults = subVaults; + _mockUnlimitedCapacities(vaults); ISubVaultsCurator.Deposit[] memory deposits = curator.getDeposits(assetsToDeposit, vaults, address(0)); @@ -101,10 +117,11 @@ contract BalancedCuratorTest is Test { } } - function test_getDeposits_unevenDivision() public view { + function test_getDeposits_unevenDivision() public { // 103 ETH to distribute across 5 vaults uint256 assetsToDeposit = 103 ether; address[] memory vaults = subVaults; + _mockUnlimitedCapacities(vaults); ISubVaultsCurator.Deposit[] memory deposits = curator.getDeposits(assetsToDeposit, vaults, address(0)); @@ -140,6 +157,7 @@ contract BalancedCuratorTest is Test { uint256 assetsToDeposit = 100 ether; address[] memory vaults = new address[](1); vaults[0] = address(uint160(0x1000)); + _mockUnlimitedCapacities(vaults); // Should revert with EmptySubVaults error because all vaults are ejecting vm.expectRevert(Errors.EmptySubVaults.selector); @@ -157,6 +175,117 @@ contract BalancedCuratorTest is Test { assertEq(deposits.length, 0, "Should return 0 deposit structs"); } + function test_getDeposits_respectsCapacities() public { + // 100 ETH to distribute across 5 vaults with limited capacities + uint256 assetsToDeposit = 100 ether; + address[] memory vaults = subVaults; + + uint256[] memory remainingCapacities = new uint256[](5); + remainingCapacities[0] = 10 ether; + remainingCapacities[1] = 20 ether; + remainingCapacities[2] = 30 ether; + remainingCapacities[3] = 40 ether; + remainingCapacities[4] = 50 ether; + + for (uint256 i = 0; i < 5; i++) { + _mockVaultCapacity(vaults[i], remainingCapacities[i], 0); + } + + ISubVaultsCurator.Deposit[] memory deposits = curator.getDeposits(assetsToDeposit, vaults, address(0)); + + assertEq(deposits.length, 5, "Should return 5 deposit structs"); + + uint256 totalDistributed = 0; + for (uint256 i = 0; i < deposits.length; i++) { + assertEq(deposits[i].vault, vaults[i], "Vault address mismatch"); + assertLe(deposits[i].assets, remainingCapacities[i], "Cannot deposit more than capacity"); + totalDistributed += deposits[i].assets; + } + + assertEq(totalDistributed, assetsToDeposit, "Total distributed amount incorrect"); + } + + function test_getDeposits_redistributesWhenCapacityLimited() public { + // 100 ETH to distribute across 3 vaults where first has low capacity + address[] memory vaults = new address[](3); + vaults[0] = address(uint160(0x1000)); + vaults[1] = address(uint160(0x1001)); + vaults[2] = address(uint160(0x1002)); + + _mockVaultCapacity(vaults[0], 5 ether, 0); // can only take 5 + _mockVaultCapacity(vaults[1], type(uint256).max, 0); + _mockVaultCapacity(vaults[2], type(uint256).max, 0); + + ISubVaultsCurator.Deposit[] memory deposits = curator.getDeposits(100 ether, vaults, address(0)); + + // vault[0] should get at most 5 ETH + assertEq(deposits[0].assets, 5 ether, "Should be capped at capacity"); + + // remaining 95 ETH should be split between vaults 1 and 2 + uint256 totalDistributed = deposits[0].assets + deposits[1].assets + deposits[2].assets; + assertEq(totalDistributed, 100 ether, "Total distributed amount incorrect"); + } + + function test_getDeposits_allVaultsAtCapacity() public { + // 100 ETH to distribute but all vaults are full + address[] memory vaults = new address[](3); + vaults[0] = address(uint160(0x1000)); + vaults[1] = address(uint160(0x1001)); + vaults[2] = address(uint160(0x1002)); + + _mockVaultCapacity(vaults[0], 50 ether, 50 ether); + _mockVaultCapacity(vaults[1], 50 ether, 50 ether); + _mockVaultCapacity(vaults[2], 50 ether, 50 ether); + + vm.expectRevert(Errors.EmptySubVaults.selector); + curator.getDeposits(100 ether, vaults, address(0)); + } + + function test_getDeposits_capacityWithEjectingVault() public { + // 100 ETH, 3 vaults, one ejecting, one with limited capacity + address[] memory vaults = new address[](3); + vaults[0] = address(uint160(0x1000)); + vaults[1] = address(uint160(0x1001)); + vaults[2] = address(uint160(0x1002)); + + _mockVaultCapacity(vaults[0], 10 ether, 0); + _mockVaultCapacity(vaults[1], type(uint256).max, 0); // ejecting + _mockVaultCapacity(vaults[2], type(uint256).max, 0); + + ISubVaultsCurator.Deposit[] memory deposits = curator.getDeposits(100 ether, vaults, vaults[1]); + + assertEq(deposits[1].assets, 0, "Ejecting vault should receive 0"); + assertEq(deposits[0].assets, 10 ether, "Should be capped at capacity"); + assertEq(deposits[2].assets, 90 ether, "Should receive remaining assets"); + + uint256 totalDistributed = deposits[0].assets + deposits[1].assets + deposits[2].assets; + assertEq(totalDistributed, 100 ether, "Total distributed amount incorrect"); + } + + function test_getDeposits_partialCapacity() public { + // Sub-vaults with existing assets reducing remaining capacity + address[] memory vaults = new address[](3); + vaults[0] = address(uint160(0x1000)); + vaults[1] = address(uint160(0x1001)); + vaults[2] = address(uint160(0x1002)); + + // vault[0]: capacity 100, already has 90 => remaining 10 + // vault[1]: capacity 100, already has 50 => remaining 50 + // vault[2]: capacity 100, already has 0 => remaining 100 + _mockVaultCapacity(vaults[0], 100 ether, 90 ether); + _mockVaultCapacity(vaults[1], 100 ether, 50 ether); + _mockVaultCapacity(vaults[2], 100 ether, 0); + + ISubVaultsCurator.Deposit[] memory deposits = curator.getDeposits(60 ether, vaults, address(0)); + + assertLe(deposits[0].assets, 10 ether, "Cannot exceed remaining capacity"); + assertLe(deposits[1].assets, 50 ether, "Cannot exceed remaining capacity"); + assertLe(deposits[2].assets, 100 ether, "Cannot exceed remaining capacity"); + + uint256 totalDistributed = deposits[0].assets + deposits[1].assets + deposits[2].assets; + assertEq(totalDistributed, 60 ether, "Total distributed amount incorrect"); + } + function test_getExitRequests_normalDistribution() public view { // 100 ETH to exit from 5 vaults uint256 assetsToExit = 100 ether; @@ -353,6 +482,9 @@ contract BalancedCuratorTest is Test { vaults[3] = address(uint160(0x1003)); vaults[4] = address(uint160(0x1004)); + _mockVaultCapacity(vaults[0], type(uint256).max, 0); + _mockVaultCapacity(vaults[1], type(uint256).max, 0); + // Should revert with ZeroAddress error vm.expectRevert(Errors.ZeroAddress.selector); curator.getDeposits(assetsToDeposit, vaults, address(0)); @@ -371,6 +503,8 @@ contract BalancedCuratorTest is Test { vaults[3] = address(uint160(0x1003)); vaults[4] = address(uint160(0x1004)); + _mockUnlimitedCapacities(vaults); + // Try to eject the duplicate vault - should revert vm.expectRevert(Errors.RepeatedEjectingVault.selector); curator.getDeposits(assetsToDeposit, vaults, duplicateVault); From 27261c3202df2474f82d7dfc69e23f52e6d2e04c Mon Sep 17 00:00:00 2001 From: Dmitri Tsumak Date: Fri, 3 Apr 2026 12:29:51 +0300 Subject: [PATCH 20/26] Revert on empty processExitQueue in OsTokenRedeemer --- contracts/tokens/OsTokenRedeemer.sol | 14 +++++++++----- snapshots/EthOsTokenRedeemerTest.json | 1 - test/EthOsTokenRedeemer.t.sol | 17 ++--------------- 3 files changed, 11 insertions(+), 21 deletions(-) diff --git a/contracts/tokens/OsTokenRedeemer.sol b/contracts/tokens/OsTokenRedeemer.sol index dfe5a3ac..92f12db9 100644 --- a/contracts/tokens/OsTokenRedeemer.sol +++ b/contracts/tokens/OsTokenRedeemer.sol @@ -126,7 +126,9 @@ abstract contract OsTokenRedeemer is Ownable2Step, Multicall, IOsTokenRedeemer { if (exitQueueTimestamp + exitQueueUpdateDelay > block.timestamp) { return false; } - return swappedShares > 0 || redeemedShares > 0; + uint256 processedShares = swappedShares + redeemedShares; + uint256 processedAssets = swappedAssets + redeemedAssets; + return processedShares > 0 && processedAssets > 0; } /// @inheritdoc IOsTokenRedeemer @@ -398,15 +400,17 @@ abstract contract OsTokenRedeemer is Ownable2Step, Multicall, IOsTokenRedeemer { // update state uint256 processedShares = swappedShares + redeemedShares; uint256 processedAssets = swappedAssets + redeemedAssets; + if (processedShares == 0) { + revert Errors.InvalidShares(); + } + if (processedAssets == 0) { + revert Errors.InvalidAssets(); + } swappedShares = 0; swappedAssets = 0; redeemedShares = 0; redeemedAssets = 0; - if (processedShares == 0 || processedAssets == 0) { - return; // nothing to process - } - unclaimedAssets += SafeCast.toUint128(processedAssets); // push checkpoint so that exited assets could be claimed diff --git a/snapshots/EthOsTokenRedeemerTest.json b/snapshots/EthOsTokenRedeemerTest.json index 6e23c2d5..56413668 100644 --- a/snapshots/EthOsTokenRedeemerTest.json +++ b/snapshots/EthOsTokenRedeemerTest.json @@ -1,7 +1,6 @@ { "EthOsTokenRedeemerTest_test_enterExitQueue_success": "109662", "EthOsTokenRedeemerTest_test_permitOsToken_success": "82575", - "EthOsTokenRedeemerTest_test_processExitQueue_nothingToProcess": "33998", "EthOsTokenRedeemerTest_test_processExitQueue_success": "101715", "EthOsTokenRedeemerTest_test_redeemOsTokenPositions_success_multiplePositions": "297544", "EthOsTokenRedeemerTest_test_redeemOsTokenPositions_success_singlePosition": "201471", diff --git a/test/EthOsTokenRedeemer.t.sol b/test/EthOsTokenRedeemer.t.sol index e23f2148..4da68d46 100644 --- a/test/EthOsTokenRedeemer.t.sol +++ b/test/EthOsTokenRedeemer.t.sol @@ -1205,22 +1205,9 @@ contract EthOsTokenRedeemerTest is Test, EthHelpers { // Ensure enough time has passed vm.warp(block.timestamp + EXIT_QUEUE_UPDATE_DELAY + 1); - // Store initial states - uint256 unclaimedAssetsBefore = osTokenRedeemer.unclaimedAssets(); - - // Process exit queue - should not revert even with nothing to process - _startSnapshotGas("EthOsTokenRedeemerTest_test_processExitQueue_nothingToProcess"); + // Process exit queue - should revert with InvalidShares when nothing to process + vm.expectRevert(Errors.InvalidShares.selector); osTokenRedeemer.processExitQueue(); - _stopSnapshotGas(); - - // Verify no changes occurred - assertEq(osTokenRedeemer.unclaimedAssets(), unclaimedAssetsBefore, "Unclaimed assets should not change"); - - // Verify all counters were reset to 0 - assertEq(osTokenRedeemer.swappedShares(), 0, "Swapped shares should be 0"); - assertEq(osTokenRedeemer.swappedAssets(), 0, "Swapped assets should be 0"); - assertEq(osTokenRedeemer.redeemedShares(), 0, "Redeemed shares should be 0"); - assertEq(osTokenRedeemer.redeemedAssets(), 0, "Redeemed assets should be 0"); } function test_processExitQueue_success() public { From ed9a9a91502ea00fb5eb16530721fb4dd1a99200 Mon Sep 17 00:00:00 2001 From: Dmitri Tsumak Date: Fri, 3 Apr 2026 13:08:39 +0300 Subject: [PATCH 21/26] Pin commit hashes for github actions --- .github/workflows/coverage.yaml | 8 ++++---- .github/workflows/lint.yaml | 4 ++-- .github/workflows/slither.yaml | 6 +++--- .github/workflows/test-fork.yaml | 4 ++-- .github/workflows/test.yaml | 4 ++-- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index 93e1348b..3d9a65ae 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -12,12 +12,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: submodules: recursive - name: Install foundry - uses: foundry-rs/foundry-toolchain@v1 + uses: foundry-rs/foundry-toolchain@8789b3e21e6c11b2697f5eb56eddae542f746c10 with: version: stable @@ -43,7 +43,7 @@ jobs: TEST_USE_FORK_VAULTS: false - name: Check coverage is updated - uses: actions/github-script@v5 + uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -61,7 +61,7 @@ jobs: - name: Comment on PR id: comment - uses: actions/github-script@v5 + uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 668b1b64..4e0cecc8 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -13,10 +13,10 @@ jobs: steps: - name: Check out Git repository - uses: actions/checkout@v3 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 + uses: foundry-rs/foundry-toolchain@8789b3e21e6c11b2697f5eb56eddae542f746c10 with: version: stable diff --git a/.github/workflows/slither.yaml b/.github/workflows/slither.yaml index 46c8655f..ee335bfc 100644 --- a/.github/workflows/slither.yaml +++ b/.github/workflows/slither.yaml @@ -12,17 +12,17 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: submodules: recursive - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 + uses: foundry-rs/foundry-toolchain@8789b3e21e6c11b2697f5eb56eddae542f746c10 with: version: stable - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 with: python-version: '3.10' diff --git a/.github/workflows/test-fork.yaml b/.github/workflows/test-fork.yaml index bfd96d15..ccf6795d 100644 --- a/.github/workflows/test-fork.yaml +++ b/.github/workflows/test-fork.yaml @@ -12,12 +12,12 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: submodules: recursive - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 + uses: foundry-rs/foundry-toolchain@8789b3e21e6c11b2697f5eb56eddae542f746c10 with: version: stable diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index fc22b751..39d23642 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -12,12 +12,12 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: submodules: recursive - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 + uses: foundry-rs/foundry-toolchain@8789b3e21e6c11b2697f5eb56eddae542f746c10 with: version: stable From f7573fb87e6b150e587cb022926e1a16aa161608 Mon Sep 17 00:00:00 2001 From: Dmitri Tsumak Date: Fri, 3 Apr 2026 13:12:57 +0300 Subject: [PATCH 22/26] Update snapshots --- snapshots/EthErc20MetaVaultTest.json | 10 ++++----- snapshots/EthErc20VaultTest.json | 2 +- snapshots/EthMetaVaultTest.json | 28 ++++++++++++------------ snapshots/EthNodesManagerTest.json | 10 ++++----- snapshots/EthOsTokenRedeemerTest.json | 12 +++++----- snapshots/EthPrivErc20MetaVaultTest.json | 6 ++--- snapshots/EthPrivMetaVaultTest.json | 12 +++++----- snapshots/GnoErc20VaultTest.json | 4 ++-- snapshots/GnoMetaVaultTest.json | 6 ++--- snapshots/GnoOsTokenRedeemerTest.json | 2 +- snapshots/GnoRewardSplitterTest.json | 6 ++--- snapshots/VaultSubVaultsTest.json | 22 +++++++++---------- 12 files changed, 60 insertions(+), 60 deletions(-) diff --git a/snapshots/EthErc20MetaVaultTest.json b/snapshots/EthErc20MetaVaultTest.json index ab084116..102b4c15 100644 --- a/snapshots/EthErc20MetaVaultTest.json +++ b/snapshots/EthErc20MetaVaultTest.json @@ -1,11 +1,11 @@ { - "EthErc20MetaVaultTest_test_deposit": "93642", - "EthErc20MetaVaultTest_test_depositAndMintOsToken": "194416", - "EthErc20MetaVaultTest_test_depositViaFallback": "95642", + "EthErc20MetaVaultTest_test_deposit": "95852", + "EthErc20MetaVaultTest_test_depositAndMintOsToken": "194905", + "EthErc20MetaVaultTest_test_depositViaFallback": "97852", "EthErc20MetaVaultTest_test_enterExitQueue": "106106", "EthErc20MetaVaultTest_test_enterExitQueue_nonCollateralized": "106106", "EthErc20MetaVaultTest_test_transfer": "64426", "EthErc20MetaVaultTest_test_transferFrom": "61139", - "EthErc20MetaVaultTest_test_updateStateAndDeposit": "214653", - "EthErc20MetaVaultTest_test_updateStateAndDepositAndMintOsToken": "218872" + "EthErc20MetaVaultTest_test_updateStateAndDeposit": "214701", + "EthErc20MetaVaultTest_test_updateStateAndDepositAndMintOsToken": "219292" } \ No newline at end of file diff --git a/snapshots/EthErc20VaultTest.json b/snapshots/EthErc20VaultTest.json index 598bf61f..e957563f 100644 --- a/snapshots/EthErc20VaultTest.json +++ b/snapshots/EthErc20VaultTest.json @@ -2,7 +2,7 @@ "EthErc20VaultTest_test_canTransferFromSharesWithHighLtv": "90055", "EthErc20VaultTest_test_cannotTransferFromSharesWithLowLtv": "96586", "EthErc20VaultTest_test_deploysCorrectly": "455298", - "EthErc20VaultTest_test_depositAndMintOsToken": "203725", + "EthErc20VaultTest_test_depositAndMintOsToken": "203794", "EthErc20VaultTest_test_depositViaReceiveFallback_emitsTransfer": "77951", "EthErc20VaultTest_test_deposit_emitsTransfer": "81413", "EthErc20VaultTest_test_enterExitQueue_emitsTransfer": "89801", diff --git a/snapshots/EthMetaVaultTest.json b/snapshots/EthMetaVaultTest.json index c263df1e..59433a0d 100644 --- a/snapshots/EthMetaVaultTest.json +++ b/snapshots/EthMetaVaultTest.json @@ -1,17 +1,17 @@ { - "EthMetaVaultTest_test_calculateSubVaultsRedemptions_exactWithdrawableAssets": "38967", - "EthMetaVaultTest_test_calculateSubVaultsRedemptions_insufficientWithdrawableAssets": "102725", - "EthMetaVaultTest_test_calculateSubVaultsRedemptions_success": "102754", - "EthMetaVaultTest_test_deposit": "91733", - "EthMetaVaultTest_test_depositAndMintOsToken": "192440", - "EthMetaVaultTest_test_depositViaFallback": "93711", - "EthMetaVaultTest_test_isStateUpdateRequired_true": "135911", - "EthMetaVaultTest_test_redeemSubVaultsAssets_noRedeemRequests": "64955", - "EthMetaVaultTest_test_redeemSubVaultsAssets_noRoundingErrors": "561233", - "EthMetaVaultTest_test_redeemSubVaultsAssets_redeemAssetsExceedSubVaultsWithdrawableAssets": "560191", - "EthMetaVaultTest_test_redeemSubVaultsAssets_redeemAssetsLessThanSubVaultsWithdrawableAssets": "561209", - "EthMetaVaultTest_test_redeemSubVaultsAssets_success": "561209", - "EthMetaVaultTest_test_updateStateAndDeposit": "212545", - "EthMetaVaultTest_test_updateStateAndDepositAndMintOsToken": "216899", + "EthMetaVaultTest_test_calculateSubVaultsRedemptions_exactWithdrawableAssets": "41177", + "EthMetaVaultTest_test_calculateSubVaultsRedemptions_insufficientWithdrawableAssets": "102935", + "EthMetaVaultTest_test_calculateSubVaultsRedemptions_success": "102964", + "EthMetaVaultTest_test_deposit": "93943", + "EthMetaVaultTest_test_depositAndMintOsToken": "192860", + "EthMetaVaultTest_test_depositViaFallback": "95921", + "EthMetaVaultTest_test_isStateUpdateRequired_true": "138121", + "EthMetaVaultTest_test_redeemSubVaultsAssets_noRedeemRequests": "67165", + "EthMetaVaultTest_test_redeemSubVaultsAssets_noRoundingErrors": "573842", + "EthMetaVaultTest_test_redeemSubVaultsAssets_redeemAssetsExceedSubVaultsWithdrawableAssets": "572800", + "EthMetaVaultTest_test_redeemSubVaultsAssets_redeemAssetsLessThanSubVaultsWithdrawableAssets": "573818", + "EthMetaVaultTest_test_redeemSubVaultsAssets_success": "573818", + "EthMetaVaultTest_test_updateStateAndDeposit": "212593", + "EthMetaVaultTest_test_updateStateAndDepositAndMintOsToken": "217319", "EthMetaVaultTest_test_userClaimExitedAssets": "54232" } \ No newline at end of file diff --git a/snapshots/EthNodesManagerTest.json b/snapshots/EthNodesManagerTest.json index 23549872..d5bd7f24 100644 --- a/snapshots/EthNodesManagerTest.json +++ b/snapshots/EthNodesManagerTest.json @@ -1,13 +1,13 @@ { - "EthNodesManagerTest_test_claimExitedAssets": "99124", - "EthNodesManagerTest_test_deposit": "124404", - "EthNodesManagerTest_test_deposit_multipleDeposits_first": "124404", - "EthNodesManagerTest_test_deposit_multipleDeposits_second": "106655", + "EthNodesManagerTest_test_claimExitedAssets": "106977", + "EthNodesManagerTest_test_deposit": "124618", + "EthNodesManagerTest_test_deposit_multipleDeposits_first": "124618", + "EthNodesManagerTest_test_deposit_multipleDeposits_second": "106869", "EthNodesManagerTest_test_enterExitQueue": "138581", "EthNodesManagerTest_test_fundValidators": "164727", "EthNodesManagerTest_test_registerValidators": "324473", "EthNodesManagerTest_test_setMinBalancePercent": "40850", "EthNodesManagerTest_test_setMinDepositAssets": "40222", "EthNodesManagerTest_test_setWithdrawalsManager": "40580", - "EthNodesManagerTest_test_withdrawValidators": "87361" + "EthNodesManagerTest_test_withdrawValidators": "90005" } \ No newline at end of file diff --git a/snapshots/EthOsTokenRedeemerTest.json b/snapshots/EthOsTokenRedeemerTest.json index 56413668..69f05efb 100644 --- a/snapshots/EthOsTokenRedeemerTest.json +++ b/snapshots/EthOsTokenRedeemerTest.json @@ -1,16 +1,16 @@ { "EthOsTokenRedeemerTest_test_enterExitQueue_success": "109662", "EthOsTokenRedeemerTest_test_permitOsToken_success": "82575", - "EthOsTokenRedeemerTest_test_processExitQueue_success": "101715", - "EthOsTokenRedeemerTest_test_redeemOsTokenPositions_success_multiplePositions": "297544", - "EthOsTokenRedeemerTest_test_redeemOsTokenPositions_success_singlePosition": "201471", + "EthOsTokenRedeemerTest_test_processExitQueue_success": "101711", + "EthOsTokenRedeemerTest_test_redeemOsTokenPositions_success_multiplePositions": "297514", + "EthOsTokenRedeemerTest_test_redeemOsTokenPositions_success_singlePosition": "201456", "EthOsTokenRedeemerTest_test_setPositionsManager": "35834", - "EthOsTokenRedeemerTest_test_setRedeemablePositions_success": "100649", + "EthOsTokenRedeemerTest_test_setRedeemablePositions_success": "99995", "EthOsTokenRedeemerTest_test_swapAssetsToOsTokenShares_success": "101264", "EthOsTokenRedeemerTest_test_updateVaultState_success": "129553", "test_claimExitedAssets_fullWithdrawal": "53564", "test_claimExitedAssets_noPosition": "30222", "test_claimExitedAssets_partialWithdrawal": "75901", - "test_redeemSubVaultsAssets_concurrentMultipleSubVaults": "684971", - "test_redeemSubVaultsAssets_largeAmountAcrossAllSubVaults": "824797" + "test_redeemSubVaultsAssets_concurrentMultipleSubVaults": "701715", + "test_redeemSubVaultsAssets_largeAmountAcrossAllSubVaults": "845678" } \ No newline at end of file diff --git a/snapshots/EthPrivErc20MetaVaultTest.json b/snapshots/EthPrivErc20MetaVaultTest.json index 21a7cda0..e87f1da5 100644 --- a/snapshots/EthPrivErc20MetaVaultTest.json +++ b/snapshots/EthPrivErc20MetaVaultTest.json @@ -1,7 +1,7 @@ { - "EthPrivErc20MetaVaultTest_test_depositViaFallback_whitelistedUser": "97878", - "EthPrivErc20MetaVaultTest_test_deposit_whitelistedUser": "98144", - "EthPrivErc20MetaVaultTest_test_mintOsToken_whitelistedUser": "172567", + "EthPrivErc20MetaVaultTest_test_depositViaFallback_whitelistedUser": "100088", + "EthPrivErc20MetaVaultTest_test_deposit_whitelistedUser": "100354", + "EthPrivErc20MetaVaultTest_test_mintOsToken_whitelistedUser": "172777", "EthPrivErc20MetaVaultTest_test_setWhitelister": "41109", "EthPrivErc20MetaVaultTest_test_transferFrom_bothWhitelisted": "65676", "EthPrivErc20MetaVaultTest_test_transfer_bothWhitelisted": "64486", diff --git a/snapshots/EthPrivMetaVaultTest.json b/snapshots/EthPrivMetaVaultTest.json index cacd54ef..19be356d 100644 --- a/snapshots/EthPrivMetaVaultTest.json +++ b/snapshots/EthPrivMetaVaultTest.json @@ -1,10 +1,10 @@ { - "EthPrivMetaVaultTest_test_canDepositAndMintOsTokenAsWhitelistedUser": "195318", - "EthPrivMetaVaultTest_test_canDepositAsWhitelistedUser": "91702", - "EthPrivMetaVaultTest_test_canDepositUsingReceiveAsWhitelistedUser": "91436", - "EthPrivMetaVaultTest_test_canMintOsTokenAsWhitelistedUser": "172616", - "EthPrivMetaVaultTest_test_canUpdateStateAndDepositAsWhitelistedUser": "126573", - "EthPrivMetaVaultTest_test_depositToSubVaultsWorksWithWhitelistedUser": "331209", + "EthPrivMetaVaultTest_test_canDepositAndMintOsTokenAsWhitelistedUser": "195738", + "EthPrivMetaVaultTest_test_canDepositAsWhitelistedUser": "93912", + "EthPrivMetaVaultTest_test_canDepositUsingReceiveAsWhitelistedUser": "93646", + "EthPrivMetaVaultTest_test_canMintOsTokenAsWhitelistedUser": "172826", + "EthPrivMetaVaultTest_test_canUpdateStateAndDepositAsWhitelistedUser": "126783", + "EthPrivMetaVaultTest_test_depositToSubVaultsWorksWithWhitelistedUser": "331419", "EthPrivMetaVaultTest_test_enterExitQueueWorksForWhitelistedUserAfterRemoval": "94916", "EthPrivMetaVaultTest_test_setWhitelister": "36608", "EthPrivMetaVaultTest_test_updateWhitelist": "54543" diff --git a/snapshots/GnoErc20VaultTest.json b/snapshots/GnoErc20VaultTest.json index fad693f6..9123a036 100644 --- a/snapshots/GnoErc20VaultTest.json +++ b/snapshots/GnoErc20VaultTest.json @@ -3,8 +3,8 @@ "GnoErc20VaultTest_test_cannotTransferFromSharesWithLowLtv": "96637", "GnoErc20VaultTest_test_deploysCorrectly": "753197", "GnoErc20VaultTest_test_deposit_emitsTransfer": "100710", - "GnoErc20VaultTest_test_enterExitQueue_emitsTransfer": "89780", - "GnoErc20VaultTest_test_redeem_emitsEvent": "77069", + "GnoErc20VaultTest_test_enterExitQueue_emitsTransfer": "89801", + "GnoErc20VaultTest_test_redeem_emitsEvent": "75248", "GnoErc20VaultTest_test_upgradesCorrectly": "339680", "VaultGnoErc20VaultTest_test_withdrawValidator_unknown": "55973", "VaultGnoErc20VaultTest_test_withdrawValidator_validatorsManager": "74114" diff --git a/snapshots/GnoMetaVaultTest.json b/snapshots/GnoMetaVaultTest.json index 5cfcce79..c94e0bae 100644 --- a/snapshots/GnoMetaVaultTest.json +++ b/snapshots/GnoMetaVaultTest.json @@ -1,9 +1,9 @@ { "GnoMetaVaultTest_test_addSubVault": "132818", "GnoMetaVaultTest_test_claimExitedAssets": "67817", - "GnoMetaVaultTest_test_deposit": "109532", - "GnoMetaVaultTest_test_depositToSubVaults": "376486", + "GnoMetaVaultTest_test_deposit": "111742", + "GnoMetaVaultTest_test_depositToSubVaults": "387318", "GnoMetaVaultTest_test_ejectSubVault": "216850", "GnoMetaVaultTest_test_enterExitQueue": "99356", - "GnoMetaVaultTest_test_updateState": "174143" + "GnoMetaVaultTest_test_updateState": "173981" } \ No newline at end of file diff --git a/snapshots/GnoOsTokenRedeemerTest.json b/snapshots/GnoOsTokenRedeemerTest.json index 0fda6080..82a9b953 100644 --- a/snapshots/GnoOsTokenRedeemerTest.json +++ b/snapshots/GnoOsTokenRedeemerTest.json @@ -1,6 +1,6 @@ { "GnoOsTokenRedeemerTest_test_claimExitedAssets_fullWithdrawal": "80517", "GnoOsTokenRedeemerTest_test_permitGnoToken_success": "91931", - "GnoOsTokenRedeemerTest_test_redeemOsTokenPositions_success_singlePosition": "252849", + "GnoOsTokenRedeemerTest_test_redeemOsTokenPositions_success_singlePosition": "252834", "GnoOsTokenRedeemerTest_test_swapAssetsToOsTokenShares_success": "138013" } \ No newline at end of file diff --git a/snapshots/GnoRewardSplitterTest.json b/snapshots/GnoRewardSplitterTest.json index 16c0deef..db292496 100644 --- a/snapshots/GnoRewardSplitterTest.json +++ b/snapshots/GnoRewardSplitterTest.json @@ -3,9 +3,9 @@ "GnoRewardSplitter_claimExitedAssetsOnBehalf": "824360", "GnoRewardSplitter_claimVaultTokens": "855592", "GnoRewardSplitter_decreaseShares": "101041", - "GnoRewardSplitter_enterExitQueue": "186729", - "GnoRewardSplitter_enterExitQueueMaxWithdrawal": "131400", - "GnoRewardSplitter_enterExitQueueOnBehalf": "1028984", + "GnoRewardSplitter_enterExitQueue": "186750", + "GnoRewardSplitter_enterExitQueueMaxWithdrawal": "131421", + "GnoRewardSplitter_enterExitQueueOnBehalf": "1029005", "GnoRewardSplitter_increaseShares": "73172", "GnoRewardSplitter_setClaimer": "66291", "GnoRewardSplitter_syncRewards": "77704", diff --git a/snapshots/VaultSubVaultsTest.json b/snapshots/VaultSubVaultsTest.json index 37fd3cdf..8dc106da 100644 --- a/snapshots/VaultSubVaultsTest.json +++ b/snapshots/VaultSubVaultsTest.json @@ -2,23 +2,23 @@ "VaultSubVaultsTest_test_acceptMetaSubVault_success": "138196", "VaultSubVaultsTest_test_addSubVault_metaVaultAsSubVault_proposesMetaVault": "105914", "VaultSubVaultsTest_test_addSubVault_success": "136924", - "VaultSubVaultsTest_test_claimSubVaultsExitedAssets_ejectionConsumesShares": "444069", - "VaultSubVaultsTest_test_claimSubVaultsExitedAssets_partiallyClaimsExitedAssets": "138052", - "VaultSubVaultsTest_test_depositToSubVaults_maxVaults": "3985493", - "VaultSubVaultsTest_test_depositToSubVaults_multipleSubVaults": "330979", - "VaultSubVaultsTest_test_depositToSubVaults_singleSubVault": "175387", - "VaultSubVaultsTest_test_depositToSubVaults_withMetaVaultSubVault": "416413", + "VaultSubVaultsTest_test_claimSubVaultsExitedAssets_ejectionConsumesShares": "443907", + "VaultSubVaultsTest_test_claimSubVaultsExitedAssets_partiallyClaimsExitedAssets": "138058", + "VaultSubVaultsTest_test_depositToSubVaults_maxVaults": "4153889", + "VaultSubVaultsTest_test_depositToSubVaults_multipleSubVaults": "341976", + "VaultSubVaultsTest_test_depositToSubVaults_singleSubVault": "178821", + "VaultSubVaultsTest_test_depositToSubVaults_withMetaVaultSubVault": "432145", "VaultSubVaultsTest_test_ejectSubVault_metaVaultAsSubVault_emptySubVault": "63232", "VaultSubVaultsTest_test_ejectSubVault_metaVaultAsSubVault_withShares": "226072", "VaultSubVaultsTest_test_rejectMetaSubVault_byAdmin_success": "54123", "VaultSubVaultsTest_test_rejectMetaSubVault_byOwner_success": "49587", "VaultSubVaultsTest_test_setSubVaultsCurator_success": "60428", - "VaultSubVaultsTest_test_updateState_enterExitQueueMaxVaults": "7235819", - "VaultSubVaultsTest_test_updateState_withMetaVaultSubVault_success": "192699", + "VaultSubVaultsTest_test_updateState_enterExitQueueMaxVaults": "7235657", + "VaultSubVaultsTest_test_updateState_withMetaVaultSubVault_success": "192537", "test_addSubVault_firstSubVault": "151713", - "test_depositToSubVaults_ejectingSubVault": "171209", + "test_depositToSubVaults_ejectingSubVault": "178010", "test_ejectSubVault_emptySubVault": "71608", "test_ejectSubVault_subVaultWithShares": "219558", - "test_updateState_newTotalAssets": "197680", - "test_updateState_unprocessedSubVaultExit": "218421" + "test_updateState_newTotalAssets": "197518", + "test_updateState_unprocessedSubVaultExit": "218259" } \ No newline at end of file From 1c61cdf9d54d5e7481ac4dc1d67d12f737226bf0 Mon Sep 17 00:00:00 2001 From: Dmitri Tsumak Date: Fri, 3 Apr 2026 13:39:31 +0300 Subject: [PATCH 23/26] Simpify BalancedCurator --- contracts/curators/BalancedCurator.sol | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/contracts/curators/BalancedCurator.sol b/contracts/curators/BalancedCurator.sol index 29f4136d..227fc913 100644 --- a/contracts/curators/BalancedCurator.sol +++ b/contracts/curators/BalancedCurator.sol @@ -30,6 +30,7 @@ contract BalancedCurator is ISubVaultsCurator { // fetch remaining capacities and validate vaults uint256[] memory capacities = new uint256[](subVaultsCount); + uint256 depositSubVaultsCount; for (uint256 i = 0; i < subVaultsCount;) { address subVault = subVaults[i]; if (subVault == address(0)) { @@ -44,7 +45,10 @@ contract BalancedCurator is ISubVaultsCurator { } else { uint256 capacity = IVaultState(subVault).capacity(); uint256 totalAssets = IVaultState(subVault).totalAssets(); - capacities[i] = capacity > totalAssets ? capacity - totalAssets : 0; + if (capacity > totalAssets) { + capacities[i] = capacity - totalAssets; + depositSubVaultsCount += 1; + } } unchecked { ++i; @@ -54,17 +58,6 @@ contract BalancedCurator is ISubVaultsCurator { revert Errors.EjectingVaultNotFound(); } - // count sub-vaults with available capacity - uint256 depositSubVaultsCount; - for (uint256 i = 0; i < subVaultsCount;) { - if (capacities[i] > 0) { - depositSubVaultsCount += 1; - } - unchecked { - ++i; - } - } - // distribute assets evenly across sub-vaults, respecting capacities while (assetsToDeposit > 0) { if (depositSubVaultsCount == 0) { From 2e0f3baa12c10787697ec091a6f383fea9d95841 Mon Sep 17 00:00:00 2001 From: Dmitri Tsumak Date: Fri, 3 Apr 2026 13:49:55 +0300 Subject: [PATCH 24/26] Fix test assertion --- test/EthErc20Vault.t.sol | 2 +- test/SubVaultsRegistry.t.sol | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/test/EthErc20Vault.t.sol b/test/EthErc20Vault.t.sol index bb3d7c43..317bb5d8 100644 --- a/test/EthErc20Vault.t.sol +++ b/test/EthErc20Vault.t.sol @@ -493,7 +493,7 @@ contract EthErc20VaultTest is Test, EthHelpers { vault.transferOsTokenPositionToEscrow(osTokenShares); } - function test_transferOsTokenPositionToEscrow_partialTransfer_emitsTransfer() public { + function test_transferOsTokenPositionToEscrow_partialTransfer() public { _collateralizeEthVault(address(vault)); uint256 depositAmount = 10 ether; diff --git a/test/SubVaultsRegistry.t.sol b/test/SubVaultsRegistry.t.sol index bf41c4b5..3dd4bef2 100644 --- a/test/SubVaultsRegistry.t.sol +++ b/test/SubVaultsRegistry.t.sol @@ -19,7 +19,7 @@ import {BalancedCurator} from "../contracts/curators/BalancedCurator.sol"; import {CuratorsRegistry} from "../contracts/curators/CuratorsRegistry.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {IOsTokenConfig} from "../contracts/interfaces/IOsTokenConfig.sol"; -import {IOsTokenVaultController} from "../contracts/interfaces/IOsTokenVaultController.sol"; + import {EthOsTokenRedeemer} from "../contracts/tokens/EthOsTokenRedeemer.sol"; import {EthHelpers} from "./helpers/EthHelpers.sol"; import {GnoHelpers} from "./helpers/GnoHelpers.sol"; @@ -819,9 +819,10 @@ contract SubVaultsRegistryTest is Test, EthHelpers { vm.prank(positionsManager); uint256 totalRedeemed = osTokenRedeemer.redeemSubVaultsAssets(address(metaVault), assetsToRedeem); - // Should redeem at most ltvPercent of the deposited assets + // Should redeem at most ltvPercent (50%) of the deposited assets assertGt(totalRedeemed, 0, "Should redeem some assets"); - assertLe(totalRedeemed, assetsToRedeem, "Should not redeem more than requested"); + assertLt(totalRedeemed, assetsToRedeem, "Should redeem less than requested due to LTV cap"); + assertApproxEqAbs(totalRedeemed, 5 ether, 0.001 ether, "Should redeem approximately LTV cap amount"); } } From a0de746b27c3e063461a3aaa37e37603a9d84201 Mon Sep 17 00:00:00 2001 From: Dmitri Tsumak Date: Fri, 3 Apr 2026 14:13:18 +0300 Subject: [PATCH 25/26] Fix fork test --- test/SubVaultsRegistry.t.sol | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/SubVaultsRegistry.t.sol b/test/SubVaultsRegistry.t.sol index 3dd4bef2..a287b30a 100644 --- a/test/SubVaultsRegistry.t.sol +++ b/test/SubVaultsRegistry.t.sol @@ -801,12 +801,13 @@ contract SubVaultsRegistryTest is Test, EthHelpers { registry.depositToSubVaults(); _harvestMetaVault(); - // Set low LTV (50%) on sub-vaults to trigger the LTV cap - for (uint256 i = 0; i < subVaults.length; i++) { + // Set low LTV (50%) on all sub-vaults to trigger the LTV cap + address[] memory allVaults = registry.getSubVaults(); + for (uint256 i = 0; i < allVaults.length; i++) { vm.prank(configOwner); contracts.osTokenConfig .updateConfig( - subVaults[i], + allVaults[i], IOsTokenConfig.Config({ltvPercent: 5e17, liqThresholdPercent: 6e17, liqBonusPercent: 1.1e18}) ); } @@ -819,10 +820,9 @@ contract SubVaultsRegistryTest is Test, EthHelpers { vm.prank(positionsManager); uint256 totalRedeemed = osTokenRedeemer.redeemSubVaultsAssets(address(metaVault), assetsToRedeem); - // Should redeem at most ltvPercent (50%) of the deposited assets + // Should redeem some assets but less than requested due to LTV cap on new sub-vaults assertGt(totalRedeemed, 0, "Should redeem some assets"); assertLt(totalRedeemed, assetsToRedeem, "Should redeem less than requested due to LTV cap"); - assertApproxEqAbs(totalRedeemed, 5 ether, 0.001 ether, "Should redeem approximately LTV cap amount"); } } From 1ad5e9c53a0e933c62724ea60b91641791671250 Mon Sep 17 00:00:00 2001 From: Dmitri Tsumak Date: Fri, 3 Apr 2026 14:16:24 +0300 Subject: [PATCH 26/26] fix snapshots --- snapshots/GnoMetaVaultTest.json | 2 +- snapshots/VaultSubVaultsTest.json | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/snapshots/GnoMetaVaultTest.json b/snapshots/GnoMetaVaultTest.json index c94e0bae..55a484ef 100644 --- a/snapshots/GnoMetaVaultTest.json +++ b/snapshots/GnoMetaVaultTest.json @@ -2,7 +2,7 @@ "GnoMetaVaultTest_test_addSubVault": "132818", "GnoMetaVaultTest_test_claimExitedAssets": "67817", "GnoMetaVaultTest_test_deposit": "111742", - "GnoMetaVaultTest_test_depositToSubVaults": "387318", + "GnoMetaVaultTest_test_depositToSubVaults": "386950", "GnoMetaVaultTest_test_ejectSubVault": "216850", "GnoMetaVaultTest_test_enterExitQueue": "99356", "GnoMetaVaultTest_test_updateState": "173981" diff --git a/snapshots/VaultSubVaultsTest.json b/snapshots/VaultSubVaultsTest.json index 8dc106da..16886445 100644 --- a/snapshots/VaultSubVaultsTest.json +++ b/snapshots/VaultSubVaultsTest.json @@ -4,10 +4,10 @@ "VaultSubVaultsTest_test_addSubVault_success": "136924", "VaultSubVaultsTest_test_claimSubVaultsExitedAssets_ejectionConsumesShares": "443907", "VaultSubVaultsTest_test_claimSubVaultsExitedAssets_partiallyClaimsExitedAssets": "138058", - "VaultSubVaultsTest_test_depositToSubVaults_maxVaults": "4153889", - "VaultSubVaultsTest_test_depositToSubVaults_multipleSubVaults": "341976", - "VaultSubVaultsTest_test_depositToSubVaults_singleSubVault": "178821", - "VaultSubVaultsTest_test_depositToSubVaults_withMetaVaultSubVault": "432145", + "VaultSubVaultsTest_test_depositToSubVaults_maxVaults": "4148257", + "VaultSubVaultsTest_test_depositToSubVaults_multipleSubVaults": "341608", + "VaultSubVaultsTest_test_depositToSubVaults_singleSubVault": "178677", + "VaultSubVaultsTest_test_depositToSubVaults_withMetaVaultSubVault": "431665", "VaultSubVaultsTest_test_ejectSubVault_metaVaultAsSubVault_emptySubVault": "63232", "VaultSubVaultsTest_test_ejectSubVault_metaVaultAsSubVault_withShares": "226072", "VaultSubVaultsTest_test_rejectMetaSubVault_byAdmin_success": "54123", @@ -16,7 +16,7 @@ "VaultSubVaultsTest_test_updateState_enterExitQueueMaxVaults": "7235657", "VaultSubVaultsTest_test_updateState_withMetaVaultSubVault_success": "192537", "test_addSubVault_firstSubVault": "151713", - "test_depositToSubVaults_ejectingSubVault": "178010", + "test_depositToSubVaults_ejectingSubVault": "177637", "test_ejectSubVault_emptySubVault": "71608", "test_ejectSubVault_subVaultWithShares": "219558", "test_updateState_newTotalAssets": "197518",