Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build/deployments-1.json
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@
"name": "020_UpgradeEthenaARMScript",
"proposalId": 1,
"tsDeployment": 1771799051,
"tsGovernance": 0
"tsGovernance": 1
}
]
}
33 changes: 33 additions & 0 deletions script/deploy/mainnet/021_UpgradeLidoARMDepositScript.s.sol
Original file line number Diff line number Diff line change
@@ -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"))
);
}
}
37 changes: 37 additions & 0 deletions script/deploy/mainnet/022_UpgradeEtherFiARMDepositScript.s.sol
Original file line number Diff line number Diff line change
@@ -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();
}
}
29 changes: 29 additions & 0 deletions script/deploy/mainnet/023_UpgradeEthenaARMDepositScript.s.sol
Original file line number Diff line number Diff line change
@@ -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();
}
}
33 changes: 33 additions & 0 deletions script/deploy/mainnet/024_UpgradeOETHARMDepositScript.s.sol
Original file line number Diff line number Diff line change
@@ -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"))
);
}
}
8 changes: 8 additions & 0 deletions src/contracts/AbstractARM.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions test/fork/LidoARM/Deposit.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
//////////////////////////////////////////////////////
Expand Down
1 change: 1 addition & 0 deletions test/invariants/EthenaARM/TargetFunctions.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand Down
2 changes: 2 additions & 0 deletions test/invariants/OriginARM/TargetFunction.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
66 changes: 66 additions & 0 deletions test/unit/OriginARM/Deposit.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down