Skip to content

Commit 7ba9655

Browse files
authored
feat: pause mechanism and operator-triggered claimRedeem (#252)
* feat: add pause mechanism and operator-triggered claimRedeem - Add `paused` state with `pause()` (operator or owner) and `unpause()` (owner) - Gate `deposit()` and `requestRedeem()` with `whenNotPaused` - Let the operator call `claimRedeem()` on behalf of a user; funds and the `RedeemClaimed` event still go to the original withdrawer - Declare the previously missing `Paused`/`Unpaused` events - Add unit tests for pause/unpause and operator-claim - Update fork tests for the new "Not requester or operator" message - Add script 028 upgrading LidoARM, EtherFiARM, EthenaARM and OETH ARM implementations (LidoARM and OETH ARM via governance proposal) * refactor: remove OriginARM from upgrade script and update governance proposal description * feat: update governance proposal to include EtherFiARM upgrade and refine comments
1 parent 90e441f commit 7ba9655

5 files changed

Lines changed: 321 additions & 12 deletions

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity 0.8.23;
3+
4+
// Contracts
5+
import {Proxy} from "contracts/Proxy.sol";
6+
import {Mainnet} from "contracts/utils/Addresses.sol";
7+
import {LidoARM} from "contracts/LidoARM.sol";
8+
import {EthenaARM} from "contracts/EthenaARM.sol";
9+
import {EtherFiARM} from "contracts/EtherFiARM.sol";
10+
11+
// Deployment
12+
import {AbstractDeployScript} from "script/deploy/helpers/AbstractDeployScript.s.sol";
13+
import {GovHelper, GovProposal} from "script/deploy/helpers/GovHelper.sol";
14+
15+
/// @notice Upgrades LidoARM, EtherFiARM, EthenaARM and OETH (Origin) ARM
16+
/// to the new AbstractARM implementation that adds the pause mechanism
17+
/// (pause/unpause + whenNotPaused on deposit/requestRedeem) and lets
18+
/// the operator claim withdrawal requests on behalf of users.
19+
contract $028_UpgradeARMsPauseScript is AbstractDeployScript("028_UpgradeARMsPauseScript") {
20+
using GovHelper for GovProposal;
21+
22+
LidoARM public lidoARMImpl;
23+
EtherFiARM public etherFiARMImpl;
24+
EthenaARM public ethenaARMImpl;
25+
26+
function _execute() internal override {
27+
uint256 claimDelay = 10 minutes;
28+
29+
// 1. LidoARM
30+
lidoARMImpl = new LidoARM(
31+
Mainnet.STETH,
32+
Mainnet.WETH,
33+
Mainnet.LIDO_WITHDRAWAL,
34+
claimDelay,
35+
1e7, // minSharesToRedeem
36+
1e18 // allocateThreshold
37+
);
38+
_recordDeployment("LIDO_ARM_IMPL", address(lidoARMImpl));
39+
40+
// 2. EtherFiARM
41+
etherFiARMImpl = new EtherFiARM(
42+
Mainnet.EETH,
43+
Mainnet.WETH,
44+
Mainnet.ETHERFI_WITHDRAWAL,
45+
claimDelay,
46+
1e7, // minSharesToRedeem
47+
1e18, // allocateThreshold
48+
Mainnet.ETHERFI_WITHDRAWAL_NFT
49+
);
50+
_recordDeployment("ETHERFI_ARM_IMPL", address(etherFiARMImpl));
51+
52+
// 3. EthenaARM
53+
ethenaARMImpl = new EthenaARM(
54+
Mainnet.USDE,
55+
Mainnet.SUSDE,
56+
claimDelay,
57+
1e18, // minSharesToRedeem
58+
100e18 // allocateThreshold
59+
);
60+
_recordDeployment("ETHENA_ARM_IMPL", address(ethenaARMImpl));
61+
}
62+
63+
function _buildGovernanceProposal() internal override {
64+
govProposal.setDescription("Upgrade LidoARM and EtherFiARM to add pause mechanism and operator-claim");
65+
66+
govProposal.action(
67+
resolver.resolve("LIDO_ARM"), "upgradeTo(address)", abi.encode(resolver.resolve("LIDO_ARM_IMPL"))
68+
);
69+
70+
govProposal.action(
71+
resolver.resolve("ETHER_FI_ARM"), "upgradeTo(address)", abi.encode(resolver.resolve("ETHERFI_ARM_IMPL"))
72+
);
73+
}
74+
75+
/// @notice EthenaARM is owned by the multisig directly, so we upgrade it
76+
/// via a prank in fork simulation. On real deployment, the multisig will execute the upgrade.
77+
function _fork() internal override {
78+
// EthenaARM
79+
Proxy ethenaProxy = Proxy(payable(resolver.resolve("ETHENA_ARM")));
80+
address ethenaImpl = resolver.resolve("ETHENA_ARM_IMPL");
81+
if (ethenaProxy.implementation() != ethenaImpl) {
82+
vm.startPrank(ethenaProxy.owner());
83+
ethenaProxy.upgradeTo(ethenaImpl);
84+
vm.stopPrank();
85+
}
86+
}
87+
}

src/contracts/AbstractARM.sol

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,16 @@ abstract contract AbstractARM is OwnableOperable, ERC20Upgradeable {
129129
/// @notice Percentage of available liquid assets to keep in the ARM. 100% = 1e18.
130130
uint256 public armBuffer;
131131

132-
uint256[38] private _gap;
132+
/// @notice True when user-facing ARM actions are paused.
133+
bool public paused;
134+
135+
uint256[37] private _gap;
136+
137+
////////////////////////////////////////////////////
138+
/// Errors
139+
////////////////////////////////////////////////////
140+
141+
error ContractPaused(); // 0xab35696f
133142

134143
////////////////////////////////////////////////////
135144
/// Events
@@ -151,6 +160,21 @@ abstract contract AbstractARM is OwnableOperable, ERC20Upgradeable {
151160
event MarketRemoved(address indexed market);
152161
event ARMBufferUpdated(uint256 armBuffer);
153162
event Allocated(address indexed market, int256 targetLiquidityDelta, int256 actualLiquidityDelta);
163+
event Paused(address indexed account);
164+
event Unpaused(address indexed account);
165+
166+
////////////////////////////////////////////////////
167+
/// Modifiers
168+
////////////////////////////////////////////////////
169+
170+
modifier whenNotPaused() {
171+
if (paused) revert ContractPaused();
172+
_;
173+
}
174+
175+
////////////////////////////////////////////////////
176+
/// Constructor
177+
////////////////////////////////////////////////////
154178

155179
constructor(
156180
address _token0,
@@ -535,7 +559,7 @@ abstract contract AbstractARM is OwnableOperable, ERC20Upgradeable {
535559
/// The caller needs to have approved the contract to transfer the assets.
536560
/// @param assets The amount of liquidity assets to deposit
537561
/// @return shares The amount of shares that were minted
538-
function deposit(uint256 assets) external returns (uint256 shares) {
562+
function deposit(uint256 assets) external whenNotPaused returns (uint256 shares) {
539563
shares = _deposit(assets, msg.sender);
540564
}
541565

@@ -544,7 +568,7 @@ abstract contract AbstractARM is OwnableOperable, ERC20Upgradeable {
544568
/// @param assets The amount of liquidity assets to deposit
545569
/// @param receiver The address that will receive shares.
546570
/// @return shares The amount of shares that were minted
547-
function deposit(uint256 assets, address receiver) external returns (uint256 shares) {
571+
function deposit(uint256 assets, address receiver) external whenNotPaused returns (uint256 shares) {
548572
shares = _deposit(assets, receiver);
549573
}
550574

@@ -587,7 +611,7 @@ abstract contract AbstractARM is OwnableOperable, ERC20Upgradeable {
587611
/// @return assets The max amount of liquidity assets that will be claimable by the redeemer.
588612
/// The amount can be less at claim time if ARM's assets per share has decreased. This can happen
589613
/// from a significant slashing event on the base asset, eg stETH.
590-
function requestRedeem(uint256 shares) external returns (uint256 requestId, uint256 assets) {
614+
function requestRedeem(uint256 shares) external whenNotPaused returns (uint256 requestId, uint256 assets) {
591615
// Calculate the amount of assets to transfer to the redeemer
592616
assets = convertToAssets(shares);
593617

@@ -634,7 +658,7 @@ abstract contract AbstractARM is OwnableOperable, ERC20Upgradeable {
634658
require(request.claimTimestamp <= block.timestamp, "Claim delay not met");
635659
// Is there enough liquidity in the ARM and lending market to claim this request?
636660
require(request.queued <= claimable(), "Queue pending liquidity");
637-
require(request.withdrawer == msg.sender, "Not requester");
661+
require(request.withdrawer == msg.sender || msg.sender == operator, "Not requester or operator");
638662
require(request.claimed == false, "Already claimed");
639663

640664
// In the scenario where the ARM has made a loss from after the redeem request, the asset value of
@@ -664,10 +688,10 @@ abstract contract AbstractARM is OwnableOperable, ERC20Upgradeable {
664688
}
665689
}
666690

667-
// transfer the liquidity asset to the withdrawer
668-
IERC20(liquidityAsset).transfer(msg.sender, assets);
691+
// transfer the liquidity asset to the original withdrawer
692+
IERC20(liquidityAsset).transfer(request.withdrawer, assets);
669693

670-
emit RedeemClaimed(msg.sender, requestId, assets);
694+
emit RedeemClaimed(request.withdrawer, requestId, assets);
671695
}
672696

673697
/// @notice Used to work out if an ARM's withdrawal request can be claimed.
@@ -1051,4 +1075,16 @@ abstract contract AbstractARM is OwnableOperable, ERC20Upgradeable {
10511075

10521076
emit ARMBufferUpdated(_armBuffer);
10531077
}
1078+
1079+
/// @notice Pause user-facing ARM actions.
1080+
function pause() external onlyOperatorOrOwner {
1081+
paused = true;
1082+
emit Paused(msg.sender);
1083+
}
1084+
1085+
/// @notice Unpause user-facing ARM actions.
1086+
function unpause() external onlyOwner {
1087+
paused = false;
1088+
emit Unpaused(msg.sender);
1089+
}
10541090
}

test/fork/LidoARM/ClaimRedeem.t.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ contract Fork_Concrete_LidoARM_ClaimRedeem_Test_ is Fork_Shared_Test_ {
8989

9090
// Expect revert
9191
vm.startPrank(vm.randomAddress());
92-
vm.expectRevert("Not requester");
92+
vm.expectRevert("Not requester or operator");
9393
lidoARM.claimRedeem(0);
9494
}
9595

test/unit/OriginARM/ClaimRedeem.sol

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,36 @@ contract Unit_Concrete_OriginARM_ClaimRedeem_Test_ is Unit_Shared_Test {
3333
originARM.claimRedeem(0);
3434
}
3535

36-
function test_RevertWhen_ClaimRedeem_Because_NotWithdrawer()
36+
function test_RevertWhen_ClaimRedeem_Because_NotWithdrawerNorOperator()
3737
public
3838
requestRedeemAll(alice)
3939
timejump(CLAIM_DELAY)
40-
asNot(alice)
4140
{
42-
vm.expectRevert("Not requester");
41+
// bob is neither the withdrawer (alice) nor the operator
42+
vm.prank(bob);
43+
vm.expectRevert("Not requester or operator");
4344
originARM.claimRedeem(0);
4445
}
4546

47+
function test_ClaimRedeem_AsOperator() public requestRedeemAll(alice) timejump(CLAIM_DELAY) {
48+
uint256 aliceBalanceBefore = weth.balanceOf(alice);
49+
uint256 operatorBalanceBefore = weth.balanceOf(operator);
50+
51+
// Operator (not the withdrawer) claims on Alice's behalf
52+
vm.prank(operator);
53+
vm.expectEmit(address(originARM));
54+
// Event reports the actual withdrawer (alice), not the caller
55+
emit AbstractARM.RedeemClaimed(alice, 0, DEFAULT_AMOUNT);
56+
originARM.claimRedeem(0);
57+
58+
(, bool claimed,,,,) = originARM.withdrawalRequests(0);
59+
assertEq(claimed, true, "Claimed should be true");
60+
assertEq(originARM.withdrawsClaimed(), DEFAULT_AMOUNT, "Claimed amount should be DEFAULT_AMOUNT");
61+
// Funds go to the original withdrawer (alice), even though the operator triggered the claim
62+
assertEq(weth.balanceOf(alice), aliceBalanceBefore + DEFAULT_AMOUNT, "Alice should receive the WETH");
63+
assertEq(weth.balanceOf(operator), operatorBalanceBefore, "Operator balance unchanged");
64+
}
65+
4666
function test_RevertWhen_ClaimRedeem_Because_AlreadyClaimed() public requestRedeemAll(alice) timejump(CLAIM_DELAY) {
4767
// Alice claims her redeem
4868
vm.prank(alice);

0 commit comments

Comments
 (0)