diff --git a/build/deployments-1.json b/build/deployments-1.json index f0f4ad94..2d82ace3 100644 --- a/build/deployments-1.json +++ b/build/deployments-1.json @@ -224,7 +224,7 @@ "name": "020_UpgradeEthenaARMScript", "proposalId": 1, "tsDeployment": 1771799051, - "tsGovernance": 0 + "tsGovernance": 1 } ] } diff --git a/script/deploy/mainnet/021_UpgradeLidoARMDepositScript.s.sol b/script/deploy/mainnet/021_UpgradeLidoARMDepositScript.s.sol new file mode 100644 index 00000000..bf4dd4cc --- /dev/null +++ b/script/deploy/mainnet/021_UpgradeLidoARMDepositScript.s.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Contract +import {LidoARM} from "contracts/LidoARM.sol"; +import {Mainnet} from "contracts/utils/Addresses.sol"; + +// Deployment +import {AbstractDeployScript} from "script/deploy/helpers/AbstractDeployScript.s.sol"; +import {GovHelper, GovProposal} from "script/deploy/helpers/GovHelper.sol"; + +contract $021_UpgradeLidoARMDepositScript is AbstractDeployScript("021_UpgradeLidoARMDepositScript") { + using GovHelper for GovProposal; + + function _execute() internal override { + // 1. Deploy new LidoARM implementation + uint256 claimDelay = 10 minutes; + uint256 minSharesToRedeem = 1e7; + int256 allocateThreshold = 1e18; + LidoARM lidoARMImpl = new LidoARM( + Mainnet.STETH, Mainnet.WETH, Mainnet.LIDO_WITHDRAWAL, claimDelay, minSharesToRedeem, allocateThreshold + ); + _recordDeployment("LIDO_ARM_IMPL", address(lidoARMImpl)); + } + + function _buildGovernanceProposal() internal override { + govProposal.setDescription("Upgrade Lido ARM to restrict deposits during insolvency"); + + govProposal.action( + resolver.resolve("LIDO_ARM"), "upgradeTo(address)", abi.encode(resolver.resolve("LIDO_ARM_IMPL")) + ); + } +} diff --git a/script/deploy/mainnet/022_UpgradeEtherFiARMDepositScript.s.sol b/script/deploy/mainnet/022_UpgradeEtherFiARMDepositScript.s.sol new file mode 100644 index 00000000..914e066e --- /dev/null +++ b/script/deploy/mainnet/022_UpgradeEtherFiARMDepositScript.s.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Contract +import {Proxy} from "contracts/Proxy.sol"; +import {EtherFiARM} from "contracts/EtherFiARM.sol"; +import {Mainnet} from "contracts/utils/Addresses.sol"; + +// Deployment +import {AbstractDeployScript} from "script/deploy/helpers/AbstractDeployScript.s.sol"; + +contract $022_UpgradeEtherFiARMDepositScript is AbstractDeployScript("022_UpgradeEtherFiARMDepositScript") { + EtherFiARM etherFiARMImpl; + + function _execute() internal override { + // 1. Deploy new EtherFiARM implementation + uint256 claimDelay = 10 minutes; + uint256 minSharesToRedeem = 1e7; + int256 allocateThreshold = 1e18; + etherFiARMImpl = new EtherFiARM( + Mainnet.EETH, + Mainnet.WETH, + Mainnet.ETHERFI_WITHDRAWAL, + claimDelay, + minSharesToRedeem, + allocateThreshold, + Mainnet.ETHERFI_WITHDRAWAL_NFT + ); + _recordDeployment("ETHERFI_ARM_IMPL", address(etherFiARMImpl)); + } + + function _fork() internal override { + vm.startPrank(Proxy(payable(resolver.resolve("ETHER_FI_ARM"))).owner()); + Proxy(payable(resolver.resolve("ETHER_FI_ARM"))).upgradeTo(address(etherFiARMImpl)); + vm.stopPrank(); + } +} diff --git a/script/deploy/mainnet/023_UpgradeEthenaARMDepositScript.s.sol b/script/deploy/mainnet/023_UpgradeEthenaARMDepositScript.s.sol new file mode 100644 index 00000000..d43bf338 --- /dev/null +++ b/script/deploy/mainnet/023_UpgradeEthenaARMDepositScript.s.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Contract +import {Proxy} from "contracts/Proxy.sol"; +import {EthenaARM} from "contracts/EthenaARM.sol"; +import {Mainnet} from "contracts/utils/Addresses.sol"; + +// Deployment +import {AbstractDeployScript} from "script/deploy/helpers/AbstractDeployScript.s.sol"; + +contract $023_UpgradeEthenaARMDepositScript is AbstractDeployScript("023_UpgradeEthenaARMDepositScript") { + EthenaARM armImpl; + + function _execute() internal override { + // 1. Deploy new EthenaARM implementation + uint256 claimDelay = 10 minutes; + uint256 minSharesToRedeem = 1e18; + int256 allocateThreshold = 100e18; + armImpl = new EthenaARM(Mainnet.USDE, Mainnet.SUSDE, claimDelay, minSharesToRedeem, allocateThreshold); + _recordDeployment("ETHENA_ARM_IMPL", address(armImpl)); + } + + function _fork() internal override { + vm.startPrank(Proxy(payable(resolver.resolve("ETHENA_ARM"))).owner()); + Proxy(payable(resolver.resolve("ETHENA_ARM"))).upgradeTo(address(armImpl)); + vm.stopPrank(); + } +} diff --git a/script/deploy/mainnet/024_UpgradeOETHARMDepositScript.s.sol b/script/deploy/mainnet/024_UpgradeOETHARMDepositScript.s.sol new file mode 100644 index 00000000..117cfe9e --- /dev/null +++ b/script/deploy/mainnet/024_UpgradeOETHARMDepositScript.s.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Contract +import {OriginARM} from "contracts/OriginARM.sol"; +import {Mainnet} from "contracts/utils/Addresses.sol"; + +// Deployment +import {AbstractDeployScript} from "script/deploy/helpers/AbstractDeployScript.s.sol"; +import {GovHelper, GovProposal} from "script/deploy/helpers/GovHelper.sol"; + +contract $024_UpgradeOETHARMDepositScript is AbstractDeployScript("024_UpgradeOETHARMDepositScript") { + using GovHelper for GovProposal; + + function _execute() internal override { + // 1. Deploy new OriginARM implementation + uint256 claimDelay = 10 minutes; + uint256 minSharesToRedeem = 1e7; + int256 allocateThreshold = 1e18; + OriginARM originARMImpl = new OriginARM( + Mainnet.OETH, Mainnet.WETH, Mainnet.OETH_VAULT, claimDelay, minSharesToRedeem, allocateThreshold + ); + _recordDeployment("OETH_ARM_IMPL", address(originARMImpl)); + } + + function _buildGovernanceProposal() internal override { + govProposal.setDescription("Upgrade OETH ARM to restrict deposits during insolvency"); + + govProposal.action( + resolver.resolve("OETH_ARM"), "upgradeTo(address)", abi.encode(resolver.resolve("OETH_ARM_IMPL")) + ); + } +} diff --git a/src/contracts/AbstractARM.sol b/src/contracts/AbstractARM.sol index 1e4151b2..cc2c0c5c 100644 --- a/src/contracts/AbstractARM.sol +++ b/src/contracts/AbstractARM.sol @@ -550,6 +550,9 @@ abstract contract AbstractARM is OwnableOperable, ERC20Upgradeable { /// @dev Internal logic for depositing liquidity assets in exchange for liquidity provider (LP) shares. function _deposit(uint256 assets, address receiver) internal returns (uint256 shares) { + // Do not allow deposits if the ARM can not meet all its withdrawal obligations. + require(totalAssets() > MIN_TOTAL_SUPPLY || withdrawsQueued == withdrawsClaimed, "ARM: insolvent"); + // Calculate the amount of shares to mint after the performance fees have been accrued // which reduces the available assets, and before new assets are deposited. shares = convertToShares(assets); @@ -717,6 +720,11 @@ abstract contract AbstractARM is OwnableOperable, ERC20Upgradeable { // total assets should only go up from the initial deposit amount that is burnt // but in case of something unforeseen, return at least MIN_TOTAL_SUPPLY. + // An example scenario that will return MIN_TOTAL_SUPPLY is: + // First LP deposits and then requests a redeem of all their ARM shares. + // While waiting to claim their request, the ARM suffer a loss of assets. eg lending market loss. + // When they claim their request, the newAvailableAssets will be zero as + // the ARM assets will be less than the outstanding withdrawal request that was calculated before the loss. if (fees + MIN_TOTAL_SUPPLY >= newAvailableAssets) return MIN_TOTAL_SUPPLY; // Remove the performance fee from the available assets diff --git a/test/fork/LidoARM/Deposit.t.sol b/test/fork/LidoARM/Deposit.t.sol index 6c5c7e8d..add65a8a 100644 --- a/test/fork/LidoARM/Deposit.t.sol +++ b/test/fork/LidoARM/Deposit.t.sol @@ -97,6 +97,22 @@ contract Fork_Concrete_LidoARM_Deposit_Test_ is Fork_Shared_Test_ { lidoARM.deposit((DEFAULT_AMOUNT / 2) - MIN_TOTAL_SUPPLY + 1); // This should revert! } + function test_RevertWhen_Deposit_Because_Insolvent() + public + setTotalAssetsCap(DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY) + setLiquidityProviderCap(address(this), DEFAULT_AMOUNT) + depositInLidoARM(address(this), DEFAULT_AMOUNT) + requestRedeemFromLidoARM(address(this), DEFAULT_AMOUNT) + { + // Drain all WETH → rawTotal (0) < outstanding (DEFAULT_AMOUNT) → insolvent + deal(address(weth), address(lidoARM), 0); + + assertEq(lidoARM.totalAssets(), MIN_TOTAL_SUPPLY, "totalAssets should be floored at MIN_TOTAL_SUPPLY"); + + vm.expectRevert("ARM: insolvent"); + lidoARM.deposit(DEFAULT_AMOUNT); + } + ////////////////////////////////////////////////////// /// --- PASSING TESTS ////////////////////////////////////////////////////// diff --git a/test/invariants/EthenaARM/TargetFunctions.sol b/test/invariants/EthenaARM/TargetFunctions.sol index bc2187b6..a293b4e8 100644 --- a/test/invariants/EthenaARM/TargetFunctions.sol +++ b/test/invariants/EthenaARM/TargetFunctions.sol @@ -67,6 +67,7 @@ abstract contract TargetFunctions is Setup, StdUtils { // ║ ✦✦✦ ETHENA ARM ✦✦✦ ║ // ╚══════════════════════════════════════════════════════════════════════════════╝ function targetARMDeposit(uint88 amount, uint256 randomAddressIndex) external ensureExchangeRateIncrease { + vm.assume(arm.totalAssets() > 1e12 || arm.withdrawsQueued() == arm.withdrawsClaimed()); // Select a random user from makers address user = makers[randomAddressIndex % MAKERS_COUNT]; diff --git a/test/invariants/OriginARM/TargetFunction.sol b/test/invariants/OriginARM/TargetFunction.sol index 36eb66a9..92ecd62a 100644 --- a/test/invariants/OriginARM/TargetFunction.sol +++ b/test/invariants/OriginARM/TargetFunction.sol @@ -58,6 +58,8 @@ abstract contract TargetFunction is Properties { using MathComparisons for uint256; function handler_deposit(uint8 seed, uint88 amount) public { + vm.assume(originARM.totalAssets() > 1e12 || originARM.withdrawsQueued() == originARM.withdrawsClaimed()); + // Get a random user from the list of lps address user = getRandomLPs(seed); diff --git a/test/unit/OriginARM/Deposit.sol b/test/unit/OriginARM/Deposit.sol index 1a461eef..54f28bc4 100644 --- a/test/unit/OriginARM/Deposit.sol +++ b/test/unit/OriginARM/Deposit.sol @@ -228,6 +228,72 @@ contract Unit_Concrete_OriginARM_Deposit_Test_ is Unit_Shared_Test { ); } + /// @notice Deposit reverts when the ARM is insolvent (totalAssets floored to MIN_TOTAL_SUPPLY) + /// and there are outstanding withdrawal requests (withdrawsQueued > withdrawsClaimed). + function test_RevertWhen_Deposit_Because_Insolvent() public deposit(alice, DEFAULT_AMOUNT) requestRedeemAll(alice) { + // Drain all WETH → rawTotal (0) < outstanding (DEFAULT_AMOUNT) → insolvent + deal(address(weth), address(originARM), 0); + + assertEq(originARM.totalAssets(), MIN_TOTAL_SUPPLY, "totalAssets should be floored at MIN_TOTAL_SUPPLY"); + assertGt(originARM.withdrawsQueued(), originARM.withdrawsClaimed(), "should have outstanding requests"); + + vm.expectRevert("ARM: insolvent"); + vm.prank(alice); + originARM.deposit(DEFAULT_AMOUNT); + } + + /// @notice Attacker deposit is blocked when the ARM is insolvent due to a partial WETH loss. + /// Scenario (Immunefi #67167): + /// 1. Alice deposits and immediately requests a full redeem. + /// 2. While Alice waits to claim, the ARM suffers a 10% loss (e.g., lending market slashing). + /// 3. An attacker tries to deposit to dilute Alice's claim — blocked by the insolvent guard. + /// Without the guard, the attacker would acquire nearly all shares at the floored price and + /// capture Alice's remaining WETH when Alice's claim pays min(request.assets, convertToAssets). + function test_RevertWhen_Deposit_Because_Insolvent_WithSmallLoss() + public + deposit(alice, DEFAULT_AMOUNT) + requestRedeemAll(alice) + { + // Simulate a 10% loss on Alice's deposit (e.g., lending market slashing). + // rawTotal = MIN_TOTAL_SUPPLY + 0.9 * DEFAULT_AMOUNT < outstanding = DEFAULT_AMOUNT → insolvent + uint256 wethAfterLoss = MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT * 9 / 10; + deal(address(weth), address(originARM), wethAfterLoss); + + assertEq(originARM.totalAssets(), MIN_TOTAL_SUPPLY, "totalAssets should be floored at MIN_TOTAL_SUPPLY"); + assertGt(originARM.withdrawsQueued(), originARM.withdrawsClaimed(), "should have outstanding requests"); + + // Attacker (bob) attempts to deposit to dilute Alice's claim — must be blocked + deal(address(weth), bob, DEFAULT_AMOUNT); + vm.startPrank(bob); + weth.approve(address(originARM), DEFAULT_AMOUNT); + vm.expectRevert("ARM: insolvent"); + originARM.deposit(DEFAULT_AMOUNT); + vm.stopPrank(); + } + + /// @notice Deposit is allowed when there are outstanding requests but the ARM remains solvent. + /// Documents the totalAssets() > MIN_TOTAL_SUPPLY branch of the insolvent guard. + /// Alice deposits 2x and redeems 50%, leaving LP equity = MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT. + function test_Deposit_When_SolventWithOutstandingRequests() + public + deposit(alice, DEFAULT_AMOUNT * 2) + requestRedeem(alice, 5e17) // 50% of alice's shares → DEFAULT_AMOUNT queued + + { + // rawTotal = MIN_TOTAL_SUPPLY + 2*DEFAULT_AMOUNT, outstanding = DEFAULT_AMOUNT + // totalAssets() = MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT > MIN_TOTAL_SUPPLY → solvent + assertGt(originARM.totalAssets(), MIN_TOTAL_SUPPLY, "should be solvent with LP equity"); + assertGt(originARM.withdrawsQueued(), originARM.withdrawsClaimed(), "should have outstanding requests"); + + deal(address(weth), bob, DEFAULT_AMOUNT); + vm.startPrank(bob); + weth.approve(address(originARM), DEFAULT_AMOUNT); + originARM.deposit(DEFAULT_AMOUNT); + vm.stopPrank(); + + assertGt(originARM.balanceOf(bob), 0, "bob should have received shares"); + } + function test_Deposit_ForSomeoneElse() public { // Expected values uint256 expectedShares = originARM.convertToShares(DEFAULT_AMOUNT);