diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index e4bc907a9..3efd46f0c 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -253,18 +253,17 @@ abstract contract VaultHub is PausableUntilWithRoles { if (vaultSharesAfterMint > shareLimit) revert ShareLimitExceeded(_vault, shareLimit); IStakingVault vault_ = IStakingVault(_vault); - uint256 mintingBasisPoints = TOTAL_BASIS_POINTS - socket.reserveRatioBP; - uint256 mintingCapacity = (vault_.valuation() * mintingBasisPoints) / TOTAL_BASIS_POINTS; - uint256 ethRequiredForMint = STETH.getPooledEthByShares(vaultSharesAfterMint); - - if (ethRequiredForMint > mintingCapacity) { + uint256 maxMintableRatioBP = TOTAL_BASIS_POINTS - socket.reserveRatioBP; + uint256 maxMintableEther = (vault_.valuation() * maxMintableRatioBP) / TOTAL_BASIS_POINTS; + uint256 etherToLock = STETH.getPooledEthByShares(vaultSharesAfterMint); + if (etherToLock > maxMintableEther) { revert InsufficientValuationToMint(_vault, vault_.valuation()); } socket.sharesMinted = uint96(vaultSharesAfterMint); // Calculate the total ETH that needs to be locked in the vault to maintain the reserve ratio - uint256 totalEtherLocked = (ethRequiredForMint * TOTAL_BASIS_POINTS) / mintingBasisPoints; + uint256 totalEtherLocked = (etherToLock * TOTAL_BASIS_POINTS) / maxMintableRatioBP; if (totalEtherLocked > vault_.locked()) { vault_.lock(totalEtherLocked); } diff --git a/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts b/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts index 740aa860b..1ea075cd3 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts @@ -17,7 +17,7 @@ import { import { BigIntMath, ether, findEvents, impersonate, randomAddress } from "lib"; import { deployLidoDao, updateLidoLocatorImplementation } from "test/deploy"; -import { Snapshot, Tracing, VAULTS_RELATIVE_SHARE_LIMIT_BP, ZERO_HASH } from "test/suite"; +import { Snapshot, VAULTS_RELATIVE_SHARE_LIMIT_BP, ZERO_HASH } from "test/suite"; const ZERO_BYTES32 = "0x" + Buffer.from(ZERO_HASH).toString("hex"); @@ -306,12 +306,12 @@ describe("VaultHub.sol:hub", () => { expect(actualHealthy).to.equal(expectedHealthy); } catch (error) { console.log(`Test failed with parameters: - Reserve Ratio Threshold: ${Number(reserveRatioThresholdBP) / 100}% - Reserve Ratio: ${Number(reserveRatioBP) / 100}% - Valuation: ${ethers.formatEther(valuation)} ETH - Minted: ${ethers.formatEther(mintable)} stETH - Slashed: ${ethers.formatEther(slashed)} ETH - Threshold: ${ethers.formatEther(treashold)} stETH + Reserve Ratio Threshold: ${reserveRatioThresholdBP} + Reserve Ratio: ${reserveRatioBP} + Valuation: ${valuation} ETH + Minted: ${mintable} stETH + Slashed: ${slashed} ETH + Threshold: ${treashold} stETH Expected Healthy: ${expectedHealthy} `); throw error; @@ -347,7 +347,6 @@ describe("VaultHub.sol:hub", () => { }); it("returns correct value for different share rates", async () => { - Tracing.enable(); const vault = await createAndConnectVault(vaultFactory, { shareLimit: ether("100"), // just to bypass the share limit check reserveRatioBP: 50_00n, // 50% @@ -361,6 +360,9 @@ describe("VaultHub.sol:hub", () => { const sharesToMint = await lido.getSharesByPooledEth(mintingEth); await vaultHub.connect(user).mintShares(vaultAddress, user, sharesToMint); + await vault.report(ether("1"), ether("1"), ether("1")); // normal report + expect(await vaultHub.isHealthy(vaultAddress)).to.equal(true); // valuation is enough + // Burn some shares to make share rate fractional const burner = await impersonate(await locator.burner(), ether("1")); await lido.connect(whale).transfer(burner, ether("100")); @@ -369,9 +371,9 @@ describe("VaultHub.sol:hub", () => { await vault.report(ether("1"), ether("1"), ether("1")); // normal report expect(await vaultHub.isHealthy(vaultAddress)).to.equal(false); // old valuation is not enough - const mintedEthChange = await lido.getPooledEthBySharesRoundUp(sharesToMint); - const diff = mintedEthChange - mintingEth; - const report = ether("1") + diff * 2n; // 2x because the 50% reserve ratio + const lockedEth = await lido.getPooledEthBySharesRoundUp(sharesToMint); + // For 50% reserve ratio, we need valuation to be 2x of locked ETH to be healthy + const report = lockedEth * 2n; await vault.report(report - 1n, ether("1"), ether("1")); // below the threshold expect(await vaultHub.isHealthy(vaultAddress)).to.equal(false); @@ -382,6 +384,70 @@ describe("VaultHub.sol:hub", () => { await vault.report(report + 1n, ether("1"), ether("1")); // above the threshold expect(await vaultHub.isHealthy(vaultAddress)).to.equal(true); }); + + it("returns correct value for smallest possible reserve ratio", async () => { + const vault = await createAndConnectVault(vaultFactory, { + shareLimit: ether("100"), // just to bypass the share limit check + reserveRatioBP: 1n, // 0.01% + reserveRatioThresholdBP: 1n, // 0.01% + }); + + const vaultAddress = await vault.getAddress(); + + await vault.fund({ value: ether("1") }); + + const mintingEth = ether("0.9999"); // 99.99% of the valuation + const sharesToMint = await lido.getSharesByPooledEth(mintingEth); + await vaultHub.connect(user).mintShares(vaultAddress, user, sharesToMint); + + await vault.report(ether("1"), ether("1"), ether("1")); // normal report + expect(await vaultHub.isHealthy(vaultAddress)).to.equal(true); // valuation is enough + + // Burn some shares to make share rate fractional + const burner = await impersonate(await locator.burner(), ether("1")); + await lido.connect(whale).transfer(burner, ether("100")); + await lido.connect(burner).burnShares(ether("100")); + + const lockedEth = await lido.getPooledEthBySharesRoundUp(sharesToMint); + // if lockedEth is 99.99% of the valuation we need to report 100.00% of the valuation to be healthy + const report = (lockedEth * 10000n) / 9999n; + + await vault.report(report - 1n, ether("1"), ether("1")); // below the threshold + expect(await vaultHub.isHealthy(vaultAddress)).to.equal(false); + + await vault.report(report, ether("1"), ether("1")); // at the threshold + expect(await vaultHub.isHealthy(vaultAddress)).to.equal(false); // XXX: rounding issue, should be true + + await vault.report(report + 1n, ether("1"), ether("1")); // above the threshold + expect(await vaultHub.isHealthy(vaultAddress)).to.equal(true); + }); + + it("returns correct value for minimal shares amounts", async () => { + const vault = await createAndConnectVault(vaultFactory, { + shareLimit: ether("100"), + reserveRatioBP: 50_00n, // 50% + reserveRatioThresholdBP: 50_00n, // 50% + }); + + const vaultAddress = await vault.getAddress(); + + await vault.fund({ value: ether("1") }); + await vaultHub.connect(user).mintShares(vaultAddress, user, 1n); + + await vault.report(ether("1"), ether("1"), ether("1")); + expect(await vaultHub.isHealthy(vaultAddress)).to.equal(true); + + await vault.report(2n, ether("1"), ether("1")); // Minimal valuation to be healthy with 1 share (50% reserve ratio) + expect(await vaultHub.isHealthy(vaultAddress)).to.equal(true); + + await vault.report(1n, ether("1"), ether("1")); // Below minimal required valuation + expect(await vaultHub.isHealthy(vaultAddress)).to.equal(false); + + await lido.connect(user).transferShares(await locator.accounting(), 1n); + await vaultHub.connect(user).burnShares(vaultAddress, 1n); + + expect(await vaultHub.isHealthy(vaultAddress)).to.equal(true); // Should be healthy with no shares + }); }); context("connectVault", () => {