Skip to content
6 changes: 6 additions & 0 deletions contracts/contracts/vault/VaultCore.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
48 changes: 48 additions & 0 deletions contracts/scripts/deploy/base/001_VaultMintGate.s.sol
Original file line number Diff line number Diff line change
@@ -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");
}
}
58 changes: 58 additions & 0 deletions contracts/scripts/deploy/mainnet/004_VaultMintGate.s.sol
Original file line number Diff line number Diff line change
@@ -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");
}
}
30 changes: 24 additions & 6 deletions contracts/test/strategies/base/curve-amo.base.fork-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -568,14 +568,19 @@ describe("Base Fork Test: Curve AMO strategy", function () {
await balancePool();
await mintAndDepositToStrategy();

// Mint the WETH to be deposited while the vault is still fully backed. The
// vault's own under-backed mint gate would otherwise block this user mint
// before the strategy's insolvency check could be reached.
await mintToVault({});

// Make protocol insolvent by minting a lot of OETH
// This is a cheat.
// prettier-ignore
await oethbVault
.connect(impersonatedCurveStrategy)["mintForStrategy(uint256)"](oethUnits("1000000"));

await expect(
mintAndDepositToStrategy({ returnTransaction: true })
mintAndDepositToStrategy({ returnTransaction: true, skipMint: true })
).to.be.revertedWith("Protocol insolvent");
});
it("Withdraw: Must withdraw something", async () => {
Expand Down Expand Up @@ -782,20 +787,33 @@ describe("Base Fork Test: Curve AMO strategy", function () {
josh: nick,
}));

const mintToVault = async ({ user, amount } = {}) => {
user = user || defaultDepositor;
amount = amount || defaultDeposit;

const balance = await weth.balanceOf(user.address);
if (balance.lt(amount)) {
await setERC20TokenBalance(user.address, weth, amount.add(balance), hre);
}
await weth.connect(user).approve(oethbVault.address, amount);
await oethbVault.connect(user).mint(amount);
};

// `skipMint` deposits WETH the vault already holds, without a fresh user mint.
// Needed to reach the strategy's own solvency check on an under-backed vault,
// since the vault's mint gate would otherwise block the user mint first.
const mintAndDepositToStrategy = async ({
userOverride,
amount,
returnTransaction,
skipMint,
} = {}) => {
const user = userOverride || defaultDepositor;
amount = amount || defaultDeposit;

const balance = await weth.balanceOf(user.address);
if (balance.lt(amount)) {
await setERC20TokenBalance(user.address, weth, amount.add(balance), hre);
if (!skipMint) {
await mintToVault({ user, amount });
}
await weth.connect(user).approve(oethbVault.address, amount);
await oethbVault.connect(user).mint(amount);

const gov = await oethbVault.governor();
log(`Depositing ${formatUnits(amount)} WETH to AMO strategy`);
Expand Down
32 changes: 25 additions & 7 deletions contracts/test/strategies/curve-amo-oeth.mainnet.fork-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -763,14 +763,19 @@ describe("Fork Test: Curve AMO OETH strategy", function () {
await balancePool();
await mintAndDepositToStrategy();

// Mint the WETH to be deposited while the vault is still fully backed. The
// vault's own under-backed mint gate would otherwise block this user mint
// before the strategy's insolvency check could be reached.
await mintToVault({});

// Make protocol insolvent by minting a lot of OETH
// This is a cheat.
// prettier-ignore
await oethVault
.connect(impersonatedCurveStrategy)["mintForStrategy(uint256)"](ousdUnits("1000000"));

await expect(
mintAndDepositToStrategy({ returnTransaction: true })
mintAndDepositToStrategy({ returnTransaction: true, skipMint: true })
).to.be.revertedWith("Protocol insolvent");
});
it("Withdraw: Must withdraw something", async () => {
Expand Down Expand Up @@ -976,12 +981,8 @@ describe("Fork Test: Curve AMO OETH strategy", function () {
newBehavior: true,
}));

const mintAndDepositToStrategy = async ({
userOverride,
amount,
returnTransaction,
} = {}) => {
const user = userOverride || defaultDepositor;
const mintToVault = async ({ user, amount } = {}) => {
user = user || defaultDepositor;
amount = amount || defaultDeposit;

const balance = await weth.balanceOf(user.address);
Expand All @@ -992,6 +993,23 @@ describe("Fork Test: Curve AMO OETH strategy", function () {
await weth.connect(user).approve(oethVault.address, 0);
await weth.connect(user).approve(oethVault.address, amount);
await oethVault.connect(user).mint(amount);
};

// `skipMint` deposits WETH the vault already holds, without a fresh user mint.
// Needed to reach the strategy's own solvency check on an under-backed vault,
// since the vault's mint gate would otherwise block the user mint first.
const mintAndDepositToStrategy = async ({
userOverride,
amount,
returnTransaction,
skipMint,
} = {}) => {
const user = userOverride || defaultDepositor;
amount = amount || defaultDeposit;

if (!skipMint) {
await mintToVault({ user, amount });
}

const gov = await oethVault.governor();
const tx = await oethVault
Expand Down
34 changes: 26 additions & 8 deletions contracts/test/strategies/curve-amo-ousd.mainnet.fork-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -775,14 +775,19 @@ describe("Fork Test: Curve AMO OUSD strategy", function () {
await balancePool();
await mintAndDepositToStrategy();

// Make protocol insolvent by minting a lot of OETH
// Mint the USDC to be deposited while the vault is still fully backed. The
// vault's own under-backed mint gate would otherwise block this user mint
// before the strategy's insolvency check could be reached.
await mintToVault({});

// Make protocol insolvent by minting a lot of OUSD
// This is a cheat.
// prettier-ignore
await ousdVault
.connect(impersonatedCurveStrategy)["mintForStrategy(uint256)"](ousdUnits("1000000"));

await expect(
mintAndDepositToStrategy({ returnTransaction: true })
mintAndDepositToStrategy({ returnTransaction: true, skipMint: true })
).to.be.revertedWith("Protocol insolvent");
});
it("Withdraw: Must withdraw something", async () => {
Expand Down Expand Up @@ -988,12 +993,8 @@ describe("Fork Test: Curve AMO OUSD strategy", function () {
newBehavior: true,
}));

const mintAndDepositToStrategy = async ({
userOverride,
amount,
returnTransaction,
} = {}) => {
const user = userOverride || defaultDepositor;
const mintToVault = async ({ user, amount } = {}) => {
user = user || defaultDepositor;
amount = amount || defaultDeposit.div(1e12);

const balance = await usdc.balanceOf(user.address);
Expand All @@ -1004,6 +1005,23 @@ describe("Fork Test: Curve AMO OUSD strategy", function () {
await usdc.connect(user).approve(ousdVault.address, 0);
await usdc.connect(user).approve(ousdVault.address, amount);
await ousdVault.connect(user).mint(amount);
};

// `skipMint` deposits USDC the vault already holds, without a fresh user mint.
// Needed to reach the strategy's own solvency check on an under-backed vault,
// since the vault's mint gate would otherwise block the user mint first.
const mintAndDepositToStrategy = async ({
userOverride,
amount,
returnTransaction,
skipMint,
} = {}) => {
const user = userOverride || defaultDepositor;
amount = amount || defaultDeposit.div(1e12);

if (!skipMint) {
await mintToVault({ user, amount });
}

const gov = await ousdVault.governor();
const tx = await ousdVault
Expand Down
59 changes: 59 additions & 0 deletions contracts/tests/unit/vault/OETHVault/concrete/Mint.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
Loading
Loading