diff --git a/contracts/contracts/vault/VaultCore.sol b/contracts/contracts/vault/VaultCore.sol index 54a1aa6c79..babf9842e3 100644 --- a/contracts/contracts/vault/VaultCore.sol +++ b/contracts/contracts/vault/VaultCore.sol @@ -74,6 +74,12 @@ abstract contract VaultCore is VaultInitializer { function _mint(uint256 _amount) internal virtual { require(_amount > 0, "Amount must be greater than 0"); + // Block mints into an under-backed vault: new minters would otherwise + // buy OTokens above their real value and subsidise the withdrawal queue + // at par. Checked on the pre-mint state (the deposit is transferred in + // below). mintForStrategy is a separate path and stays ungated. + require(_totalValue() >= oToken.totalSupply(), "Vault under-backed"); + // Scale amount to 18 decimals uint256 scaledAmount = _amount.scaleBy(18, assetDecimals); diff --git a/contracts/scripts/deploy/base/001_VaultMintGate.s.sol b/contracts/scripts/deploy/base/001_VaultMintGate.s.sol new file mode 100644 index 0000000000..8aaa9e66dd --- /dev/null +++ b/contracts/scripts/deploy/base/001_VaultMintGate.s.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +// Deployment framework +import {AbstractDeployScript} from "scripts/deploy/helpers/AbstractDeployScript.s.sol"; +import {GovHelper} from "scripts/deploy/helpers/GovHelper.sol"; +import {GovProposal} from "scripts/deploy/helpers/DeploymentTypes.sol"; + +// Addresses (aliased — `Base` also names the deploy-framework base contract) +import {Base as BaseAddresses} from "tests/utils/Addresses.sol"; + +// Contracts +import {OETHBaseVault} from "contracts/vault/OETHBaseVault.sol"; +import {InitializeGovernedUpgradeabilityProxy} from "contracts/proxies/InitializeGovernedUpgradeabilityProxy.sol"; + +/// @title 001_VaultMintGate +/// @notice Upgrade the superOETHb vault implementation to add the under-backed +/// mint gate: `mint` reverts while the vault's total value is below OToken supply. +contract $001_VaultMintGate is AbstractDeployScript("001_VaultMintGate") { + using GovHelper for GovProposal; + + // ==================== Deployment Logic ==================== // + + function _execute() internal override { + OETHBaseVault vaultImpl = new OETHBaseVault(BaseAddresses.WETH); + _recordDeployment("OETHBASE_VAULT_IMPL", address(vaultImpl), type(OETHBaseVault).name); + } + + // ==================== Governance Proposal ==================== // + + function _buildGovernanceProposal() internal override { + govProposal.setDescription("Add the under-backed mint gate to the superOETHb vault"); + govProposal.action( + resolver.resolve("OETHBASE_VAULT_PROXY"), + "upgradeTo(address)", + abi.encode(resolver.resolve("OETHBASE_VAULT_IMPL")) + ); + } + + // ==================== Fork Verification ==================== // + + function _fork() internal override { + address proxy = resolver.resolve("OETHBASE_VAULT_PROXY"); + address expectedImpl = resolver.resolve("OETHBASE_VAULT_IMPL"); + address currentImpl = InitializeGovernedUpgradeabilityProxy(payable(proxy)).implementation(); + require(currentImpl == expectedImpl, "superOETHb vault proxy implementation not updated"); + } +} diff --git a/contracts/scripts/deploy/mainnet/004_VaultMintGate.s.sol b/contracts/scripts/deploy/mainnet/004_VaultMintGate.s.sol new file mode 100644 index 0000000000..15b02de4ef --- /dev/null +++ b/contracts/scripts/deploy/mainnet/004_VaultMintGate.s.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +// Deployment framework +import {AbstractDeployScript} from "scripts/deploy/helpers/AbstractDeployScript.s.sol"; +import {GovHelper} from "scripts/deploy/helpers/GovHelper.sol"; +import {GovProposal} from "scripts/deploy/helpers/DeploymentTypes.sol"; + +// Addresses +import {Mainnet} from "tests/utils/Addresses.sol"; + +// Contracts +import {OUSDVault} from "contracts/vault/OUSDVault.sol"; +import {OETHVault} from "contracts/vault/OETHVault.sol"; +import {InitializeGovernedUpgradeabilityProxy} from "contracts/proxies/InitializeGovernedUpgradeabilityProxy.sol"; + +/// @title 004_VaultMintGate +/// @notice Upgrade the OUSD and OETH vault implementations to add the under-backed +/// mint gate: `mint` reverts while the vault's total value is below OToken supply. +contract $004_VaultMintGate is AbstractDeployScript("004_VaultMintGate") { + using GovHelper for GovProposal; + + // ==================== Deployment Logic ==================== // + + function _execute() internal override { + OUSDVault ousdVaultImpl = new OUSDVault(Mainnet.USDC); + _recordDeployment("OUSD_VAULT_IMPL", address(ousdVaultImpl), type(OUSDVault).name); + + OETHVault oethVaultImpl = new OETHVault(Mainnet.WETH); + _recordDeployment("OETH_VAULT_IMPL", address(oethVaultImpl), type(OETHVault).name); + } + + // ==================== Governance Proposal ==================== // + + function _buildGovernanceProposal() internal override { + govProposal.setDescription("Add the under-backed mint gate to the OUSD and OETH vaults"); + govProposal.action( + resolver.resolve("OUSD_VAULT_PROXY"), "upgradeTo(address)", abi.encode(resolver.resolve("OUSD_VAULT_IMPL")) + ); + govProposal.action( + resolver.resolve("OETH_VAULT_PROXY"), "upgradeTo(address)", abi.encode(resolver.resolve("OETH_VAULT_IMPL")) + ); + } + + // ==================== Fork Verification ==================== // + + function _fork() internal override { + _assertUpgraded("OUSD_VAULT_PROXY", "OUSD_VAULT_IMPL"); + _assertUpgraded("OETH_VAULT_PROXY", "OETH_VAULT_IMPL"); + } + + function _assertUpgraded(string memory proxyName, string memory implName) internal view { + address proxy = resolver.resolve(proxyName); + address expectedImpl = resolver.resolve(implName); + address currentImpl = InitializeGovernedUpgradeabilityProxy(payable(proxy)).implementation(); + require(currentImpl == expectedImpl, "Vault proxy implementation not updated"); + } +} diff --git a/contracts/tests/unit/vault/OETHVault/concrete/Mint.t.sol b/contracts/tests/unit/vault/OETHVault/concrete/Mint.t.sol index 84ff473340..280cd04829 100644 --- a/contracts/tests/unit/vault/OETHVault/concrete/Mint.t.sol +++ b/contracts/tests/unit/vault/OETHVault/concrete/Mint.t.sol @@ -203,4 +203,63 @@ contract Unit_Concrete_OETHVault_Mint_Test is Unit_OETHVault_Shared_Test { // Strategy should have received funds via auto-allocate assertGt(weth.balanceOf(address(strategy)), 0, "Strategy should receive allocation"); } + + ////////////////////////////////////////////////////// + /// --- MINT GATING: UNDER-BACKED VAULT + ////////////////////////////////////////////////////// + + /// @dev Simulate a loss by moving WETH out of the vault so totalValue falls + /// below OToken supply. The vault starts fully backed (200 OETH / 200 WETH). + function _makeUnderBacked() internal { + vm.prank(address(oethVault)); + weth.transfer(governor, 10e18); + assertLt(oethVault.totalValue(), oeth.totalSupply(), "vault should be under-backed"); + } + + function test_mint_RevertWhen_underBacked() public { + _makeUnderBacked(); + + _dealWETH(alice, 1e18); + vm.startPrank(alice); + weth.approve(address(oethVault), 1e18); + vm.expectRevert("Vault under-backed"); + oethVault.mint(1e18); + vm.stopPrank(); + } + + function test_mint_worksAfterBackingRestored() public { + _makeUnderBacked(); + + _dealWETH(alice, 1e18); + vm.startPrank(alice); + weth.approve(address(oethVault), 1e18); + vm.expectRevert("Vault under-backed"); + oethVault.mint(1e18); + vm.stopPrank(); + + // Restore backing to exactly 1:1 + _dealWETH(address(this), 10e18); + weth.transfer(address(oethVault), 10e18); + assertEq(oethVault.totalValue(), oeth.totalSupply(), "vault should be fully backed again"); + + uint256 balanceBefore = oeth.balanceOf(alice); + vm.prank(alice); + oethVault.mint(1e18); + assertEq(oeth.balanceOf(alice), balanceBefore + 1e18, "mint should succeed once backed"); + } + + function test_mintForStrategy_worksWhenUnderBacked() public { + MockStrategy strategy = _deployAndApproveStrategy(); + vm.prank(governor); + oethVault.addStrategyToMintWhitelist(address(strategy)); + + _makeUnderBacked(); + + // mintForStrategy is a separate path and is intentionally not gated + uint256 mintAmount = 1000e18; + vm.prank(address(strategy)); + oethVault.mintForStrategy(mintAmount); + + assertEq(oeth.balanceOf(address(strategy)), mintAmount, "mintForStrategy should succeed under-backed"); + } } diff --git a/contracts/tests/unit/vault/OUSDVault/concrete/Mint.t.sol b/contracts/tests/unit/vault/OUSDVault/concrete/Mint.t.sol index 278a3258eb..1ed603cc01 100644 --- a/contracts/tests/unit/vault/OUSDVault/concrete/Mint.t.sol +++ b/contracts/tests/unit/vault/OUSDVault/concrete/Mint.t.sol @@ -188,4 +188,64 @@ contract Unit_Concrete_OUSDVault_Mint_Test is Unit_Shared_Test { // Strategy should have received funds via auto-allocate assertGt(usdc.balanceOf(address(strategy)), 0, "Strategy should receive allocation"); } + + ////////////////////////////////////////////////////// + /// --- MINT GATING: UNDER-BACKED VAULT + ////////////////////////////////////////////////////// + + /// @dev Simulate a loss by moving USDC out of the vault so totalValue falls + /// below OToken supply. The vault starts fully backed (200 OUSD / 200e6 USDC). + function _makeUnderBacked() internal { + vm.prank(address(ousdVault)); + usdc.transfer(governor, 10e6); + assertLt(ousdVault.totalValue(), ousd.totalSupply(), "vault should be under-backed"); + } + + function test_mint_RevertWhen_underBacked() public { + _makeUnderBacked(); + + _dealUSDC(alice, 1e6); + vm.startPrank(alice); + usdc.approve(address(ousdVault), 1e6); + vm.expectRevert("Vault under-backed"); + ousdVault.mint(1e6); + vm.stopPrank(); + } + + function test_mint_worksAfterBackingRestored() public { + _makeUnderBacked(); + + _dealUSDC(alice, 1e6); + vm.startPrank(alice); + usdc.approve(address(ousdVault), 1e6); + vm.expectRevert("Vault under-backed"); + ousdVault.mint(1e6); + vm.stopPrank(); + + // Restore backing to exactly 1:1 + _dealUSDC(address(this), 10e6); + usdc.transfer(address(ousdVault), 10e6); + assertEq(ousdVault.totalValue(), ousd.totalSupply(), "vault should be fully backed again"); + + // 1e6 USDC mints 1e18 OUSD + uint256 balanceBefore = ousd.balanceOf(alice); + vm.prank(alice); + ousdVault.mint(1e6); + assertEq(ousd.balanceOf(alice), balanceBefore + 1e18, "mint should succeed once backed"); + } + + function test_mintForStrategy_worksWhenUnderBacked() public { + MockStrategy strategy = _deployAndApproveStrategy(); + vm.prank(governor); + ousdVault.addStrategyToMintWhitelist(address(strategy)); + + _makeUnderBacked(); + + // mintForStrategy is a separate path and is intentionally not gated + uint256 mintAmount = 1000e18; + vm.prank(address(strategy)); + ousdVault.mintForStrategy(mintAmount); + + assertEq(ousd.balanceOf(address(strategy)), mintAmount, "mintForStrategy should succeed under-backed"); + } }