diff --git a/contracts/contracts/proxies/Proxies.sol b/contracts/contracts/proxies/Proxies.sol index 68763d5c8e..7feeec94da 100644 --- a/contracts/contracts/proxies/Proxies.sol +++ b/contracts/contracts/proxies/Proxies.sol @@ -227,3 +227,10 @@ contract NativeStakingFeeAccumulatorProxy is { } + +/** + * @notice LidoWithdrawalStrategyProxy delegates calls to a LidoWithdrawalStrategy implementation + */ +contract LidoWithdrawalStrategyProxy is InitializeGovernedUpgradeabilityProxy { + +} diff --git a/contracts/contracts/strategies/LidoWithdrawalStrategy.sol b/contracts/contracts/strategies/LidoWithdrawalStrategy.sol new file mode 100644 index 0000000000..a36a8968ac --- /dev/null +++ b/contracts/contracts/strategies/LidoWithdrawalStrategy.sol @@ -0,0 +1,314 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import { IERC20, InitializableAbstractStrategy } from "../utils/InitializableAbstractStrategy.sol"; +import { IWETH9 } from "../interfaces/IWETH9.sol"; +import { IVault } from "../interfaces/IVault.sol"; + +interface IStETHWithdrawal { + event WithdrawalRequested( + uint256 indexed requestId, + address indexed requestor, + address indexed owner, + uint256 amountOfStETH, + uint256 amountOfShares + ); + event WithdrawalsFinalized( + uint256 indexed from, + uint256 indexed to, + uint256 amountOfETHLocked, + uint256 sharesToBurn, + uint256 timestamp + ); + event WithdrawalClaimed( + uint256 indexed requestId, + address indexed owner, + address indexed receiver, + uint256 amountOfETH + ); + + struct WithdrawalRequestStatus { + /// @notice stETH token amount that was locked on withdrawal queue for this request + uint256 amountOfStETH; + /// @notice amount of stETH shares locked on withdrawal queue for this request + uint256 amountOfShares; + /// @notice address that can claim or transfer this request + address owner; + /// @notice timestamp of when the request was created, in seconds + uint256 timestamp; + /// @notice true, if request is finalized + bool isFinalized; + /// @notice true, if request is claimed. Request is claimable if (isFinalized && !isClaimed) + bool isClaimed; + } + + function requestWithdrawals(uint256[] calldata _amounts, address _owner) + external + returns (uint256[] memory requestIds); + + function getLastCheckpointIndex() external view returns (uint256); + + function findCheckpointHints( + uint256[] calldata _requestIds, + uint256 _firstIndex, + uint256 _lastIndex + ) external view returns (uint256[] memory hintIds); + + function claimWithdrawals( + uint256[] calldata _requestIds, + uint256[] calldata _hints + ) external; + + function getWithdrawalStatus(uint256[] calldata _requestIds) + external + view + returns (WithdrawalRequestStatus[] memory statuses); + + function getWithdrawalRequests(address _owner) + external + view + returns (uint256[] memory requestsIds); + + function finalize( + uint256 _lastRequestIdToBeFinalized, + uint256 _maxShareRate + ) external payable; +} + +/** + * @title Lido Withdrawal Strategy + * @notice This strategy withdraws ETH from stETH via the Lido Withdrawal Queue contract + * @author Origin Protocol Inc + */ +contract LidoWithdrawalStrategy is InitializableAbstractStrategy { + /// @notice Address of the WETH token + IWETH9 private constant weth = + IWETH9(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); + /// @notice Address of the stETH token + IERC20 private constant stETH = + IERC20(0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84); + /// @notice Address of the Lido Withdrawal Queue contract + IStETHWithdrawal private constant withdrawalQueue = + IStETHWithdrawal(0x889edC2eDab5f40e902b864aD4d7AdE8E412F9B1); + /// @notice Maximum amount of stETH that can be withdrawn in a single request + uint256 public constant MaxWithdrawalAmount = 1000 ether; + /// @notice Total amount of stETH that has been requested to be withdrawn for ETH + uint256 public outstandingWithdrawals; + + event WithdrawalRequests(uint256[] requestIds, uint256[] amounts); + event WithdrawalClaims(uint256[] requestIds, uint256 amount); + + constructor(BaseStrategyConfig memory _stratConfig) + InitializableAbstractStrategy(_stratConfig) + { + require(MaxWithdrawalAmount < type(uint120).max); + } + + /** + * @notice initialize function, to set up initial internal state + * @param _rewardTokenAddresses Address of reward token for platform + * @param _assets Addresses of initial supported assets + * @param _pTokens Platform Token corresponding addresses + */ + function initialize( + address[] memory _rewardTokenAddresses, + address[] memory _assets, + address[] memory _pTokens + ) external onlyGovernor initializer { + InitializableAbstractStrategy._initialize( + _rewardTokenAddresses, + _assets, + _pTokens + ); + safeApproveAllTokens(); + } + + /** + * @notice deposit() function not used for this strategy. Use depositAll() instead. + */ + function deposit(address, uint256) public override onlyVault nonReentrant { + // This method no longer used by the VaultAdmin, and we don't want it + // to be used by VaultCore. + require(false, "use depositAll() instead"); + } + + /** + * @notice Takes all given stETH and creates Lido withdrawal request + */ + function depositAll() external override onlyVault nonReentrant { + uint256 stETHStart = stETH.balanceOf(address(this)); + require(stETHStart > 0, "No stETH to withdraw"); + + uint256 withdrawalLength = (stETHStart / MaxWithdrawalAmount) + 1; + uint256[] memory amounts = new uint256[](withdrawalLength); + + uint256 stETHRemaining = stETHStart; + uint256 i = 0; + while (stETHRemaining > MaxWithdrawalAmount) { + amounts[i++] = MaxWithdrawalAmount; + stETHRemaining -= MaxWithdrawalAmount; + } + amounts[i] = stETHRemaining; + + uint256[] memory requestIds = withdrawalQueue.requestWithdrawals( + amounts, + address(this) + ); + + emit WithdrawalRequests(requestIds, amounts); + + // Is there any stETH left except 1 wei from each request? + // This is because stETH does not transfer all the transfer amount. + uint256 stEthDust = stETH.balanceOf(address(this)); + require( + stEthDust <= withdrawalLength, + "Not all stEth in withdraw queue" + ); + outstandingWithdrawals += stETHStart - stEthDust; + + // This strategy claims to support WETH, so it is possible for + // the vault to transfer WETH to it. This returns any deposited WETH + // to the vault so that it is not lost for balance tracking purposes. + uint256 wethBalance = weth.balanceOf(address(this)); + if (wethBalance > 0) { + // slither-disable-next-line unchecked-transfer + weth.transfer(vaultAddress, wethBalance); + } + + emit Deposit(address(stETH), address(withdrawalQueue), stETHStart); + } + + /** + * @notice Withdraw an asset from the underlying platform + * @param _recipient Address to receive withdrawn assets + * @param _asset Address of the asset to withdraw + * @param _amount Amount of assets to withdraw + */ + function withdraw( + // solhint-disable-next-line no-unused-vars + address _recipient, + // solhint-disable-next-line no-unused-vars + address _asset, + // solhint-disable-next-line no-unused-vars + uint256 _amount + ) external override onlyVault nonReentrant { + // Does nothing - all withdrawals need to be called manually using the + // Strategist calling claimWithdrawals + revert("use claimWithdrawals()"); + } + + /** + * @notice Claim previously requested withdrawals that have now finalized. + * Called by the Strategist. + * @param _requestIds Array of withdrawal request identifiers + * @param expectedAmount Total amount of ETH expect to be withdrawn + */ + function claimWithdrawals( + uint256[] memory _requestIds, + uint256 expectedAmount + ) external nonReentrant { + require( + msg.sender == IVault(vaultAddress).strategistAddr(), + "Caller is not the Strategist" + ); + uint256 startingBalance = payable(address(this)).balance; + uint256 lastIndex = withdrawalQueue.getLastCheckpointIndex(); + uint256[] memory hintIds = withdrawalQueue.findCheckpointHints( + _requestIds, + 1, + lastIndex + ); + withdrawalQueue.claimWithdrawals(_requestIds, hintIds); + + uint256 currentBalance = payable(address(this)).balance; + uint256 withdrawalAmount = currentBalance - startingBalance; + // Withdrawal amount should be within 2 wei of expected amount + require( + withdrawalAmount + 2 >= expectedAmount && + withdrawalAmount <= expectedAmount, + "Withdrawal amount not expected" + ); + + emit WithdrawalClaims(_requestIds, withdrawalAmount); + + outstandingWithdrawals -= withdrawalAmount; + weth.deposit{ value: currentBalance }(); + // slither-disable-next-line unchecked-transfer + weth.transfer(vaultAddress, currentBalance); + emit Withdrawal( + address(weth), + address(withdrawalQueue), + currentBalance + ); + } + + /** + * @notice Withdraw all assets from this strategy, and transfer to the Vault. + * In correct operation, this strategy should never hold any assets. + */ + function withdrawAll() external override onlyVaultOrGovernor nonReentrant { + if (payable(address(this)).balance > 0) { + weth.deposit{ value: payable(address(this)).balance }(); + } + uint256 wethBalance = weth.balanceOf(address(this)); + if (wethBalance > 0) { + // slither-disable-next-line unchecked-transfer + weth.transfer(vaultAddress, wethBalance); + emit Withdrawal(address(weth), address(0), wethBalance); + } + uint256 stEthBalance = stETH.balanceOf(address(this)); + if (stEthBalance > 0) { + // slither-disable-next-line unchecked-transfer + stETH.transfer(vaultAddress, stEthBalance); + emit Withdrawal(address(stETH), address(0), stEthBalance); + } + } + + /** + * @notice Returns the amount of queued stETH that will be returned as WETH. + * We return this as a WETH asset, since that is what it will eventually be returned as. + * We only return the outstandingWithdrawals, because the contract itself should never hold any funds. + * @param _asset Address of the asset + * @return balance Total value of the asset in the platform + */ + function checkBalance(address _asset) + external + view + override + returns (uint256 balance) + { + if (_asset == address(weth)) { + return outstandingWithdrawals; + } else if (_asset == address(stETH)) { + return 0; + } else { + revert("Unexpected asset address"); + } + } + + /** + * @notice Approve the spending of all assets by their corresponding cToken, + * if for some reason is it necessary. + */ + function safeApproveAllTokens() public override { + // slither-disable-next-line unused-return + stETH.approve(address(withdrawalQueue), type(uint256).max); + } + + /** + * @notice Check if an asset is supported. + * @param _asset Address of the asset + * @return bool Whether asset is supported + */ + function supportsAsset(address _asset) public pure override returns (bool) { + // stETH can be deposited by the vault and balances are reported in WETH + return _asset == address(stETH) || _asset == address(weth); + } + + /// @notice Needed to receive ETH when withdrawal requests are claimed + receive() external payable {} + + function _abstractSetPToken(address, address) internal pure override { + revert("No pTokens are used"); + } +} diff --git a/contracts/deploy/mainnet/097_native_ssv_staking.js b/contracts/deploy/mainnet/097_native_ssv_staking.js index 7ca358e08f..06ae07edec 100644 --- a/contracts/deploy/mainnet/097_native_ssv_staking.js +++ b/contracts/deploy/mainnet/097_native_ssv_staking.js @@ -33,11 +33,11 @@ module.exports = deploymentWithGovernanceProposal( // ---------------- // 1. Fetch the strategy proxy deployed by relayer - const cStrategyProxy = await ethers.getContract( + const cNativeStakingStrategyProxy = await ethers.getContract( "NativeStakingSSVStrategyProxy" ); - // 2. Deploy the new fee accumulator proxy + // 2. Deploy the new FeeAccumulator proxy const dFeeAccumulatorProxy = await deployWithConfirmation( "NativeStakingFeeAccumulatorProxy" ); @@ -46,8 +46,28 @@ module.exports = deploymentWithGovernanceProposal( dFeeAccumulatorProxy.address ); - // 3. Deploy the new strategy implementation - const dStrategyImpl = await deployWithConfirmation( + // 3. Deploy the new FeeAccumulator implementation + const dFeeAccumulator = await deployWithConfirmation("FeeAccumulator", [ + cNativeStakingStrategyProxy.address, // _collector + ]); + const cFeeAccumulator = await ethers.getContractAt( + "FeeAccumulator", + dFeeAccumulator.address + ); + + // 4. Init the FeeAccumulator proxy to point at the implementation, set the governor + const proxyInitFunction = "initialize(address,address,bytes)"; + await withConfirmation( + cFeeAccumulatorProxy.connect(sDeployer)[proxyInitFunction]( + cFeeAccumulator.address, // implementation address + addresses.mainnet.Timelock, // governance + "0x", // do not call any initialize functions + await getTxOpts() + ) + ); + + // 5. Deploy the new Native Staking Strategy implementation + const dNativeStakingStrategyImpl = await deployWithConfirmation( "NativeStakingSSVStrategy", [ [addresses.zero, cVaultProxy.address], //_baseConfig @@ -59,18 +79,18 @@ module.exports = deploymentWithGovernanceProposal( addresses.mainnet.beaconChainDepositContract, // beacon chain deposit contract ] ); - const cStrategyImpl = await ethers.getContractAt( + const cNativeStakingStrategyImpl = await ethers.getContractAt( "NativeStakingSSVStrategy", - dStrategyImpl.address + dNativeStakingStrategyImpl.address ); - const cStrategy = await ethers.getContractAt( + const cNativeStakingStrategy = await ethers.getContractAt( "NativeStakingSSVStrategy", - cStrategyProxy.address + cNativeStakingStrategyProxy.address ); - // 3. Initialize Proxy with new implementation and strategy initialization - const initData = cStrategyImpl.interface.encodeFunctionData( + // 6. Initialize Native Staking Proxy with new implementation and strategy initialization + const initData = cNativeStakingStrategyImpl.interface.encodeFunctionData( "initialize(address[],address[],address[])", [ [addresses.mainnet.WETH], // reward token addresses @@ -88,7 +108,7 @@ module.exports = deploymentWithGovernanceProposal( "100" ); await withConfirmation( - cStrategyProxy + cNativeStakingStrategyProxy .connect(relayerSigner) .transferGovernance(deployerAddr, await getTxOpts()) ); @@ -100,47 +120,73 @@ module.exports = deploymentWithGovernanceProposal( * Run the following to make it happen, and comment this error block out: * yarn run hardhat transferGovernanceNativeStakingProxy --address 0xdeployerAddress --network mainnet */ - new Error("Transfer governance not yet ran"); + const proxyGovernor = await cNativeStakingStrategyProxy.governor(); + if (proxyGovernor != sDeployer.address) { + throw new Error( + `Native Staking Strategy proxy's governor: ${proxyGovernor} does not match current deployer ${sDeployer.address}` + ); + } } + // 7. Transfer governance of the Native Staking Strategy proxy to the deployer await withConfirmation( - cStrategyProxy.connect(sDeployer).claimGovernance(await getTxOpts()) + cNativeStakingStrategyProxy + .connect(sDeployer) + .claimGovernance(await getTxOpts()) ); - // 4. Init the proxy to point at the implementation, set the governor, and call initialize - const proxyInitFunction = "initialize(address,address,bytes)"; + // 9. Init the proxy to point at the implementation, set the governor, and call initialize await withConfirmation( - cStrategyProxy.connect(sDeployer)[proxyInitFunction]( - cStrategyImpl.address, // implementation address + cNativeStakingStrategyProxy.connect(sDeployer)[proxyInitFunction]( + cNativeStakingStrategyImpl.address, // implementation address addresses.mainnet.Timelock, // governance initData, // data for call to the initialize function on the strategy await getTxOpts() ) ); - // 5. Deploy the new fee accumulator implementation - const dFeeAccumulator = await deployWithConfirmation("FeeAccumulator", [ - cStrategyProxy.address, // _collector - ]); - const cFeeAccumulator = await ethers.getContractAt( - "FeeAccumulator", - dFeeAccumulator.address + // 10. Safe approve SSV token spending + await cNativeStakingStrategy.connect(sDeployer).safeApproveAllTokens(); + + // 7. Deploy the Lido Withdrawal Strategy + const dWithdrawalStrategyStrategyProxy = await deployWithConfirmation( + "LidoWithdrawalStrategyProxy" + ); + const cWithdrawalStrategyStrategyProxy = await ethers.getContractAt( + "LidoWithdrawalStrategyProxy", + dWithdrawalStrategyStrategyProxy.address + ); + const dWithdrawalStrategyImpl = await deployWithConfirmation( + "LidoWithdrawalStrategy", + [ + [addresses.zero, cVaultProxy.address], //_baseConfig + ] + ); + const cWithdrawalStrategyImpl = await ethers.getContractAt( + "LidoWithdrawalStrategy", + dWithdrawalStrategyImpl.address ); - // 6. Init the fee accumulator proxy to point at the implementation, set the governor + // 8. Init the Lido Withdrawal strategy proxy to point at the implementation, set the governor, and call initialize + const withdrawalInitData = + cWithdrawalStrategyImpl.interface.encodeFunctionData( + "initialize(address[],address[],address[])", + [ + [], // reward token addresses + [], // asset token addresses + [], // platform tokens addresses + ] + ); await withConfirmation( - cFeeAccumulatorProxy.connect(sDeployer)[proxyInitFunction]( - cFeeAccumulator.address, // implementation address + cWithdrawalStrategyStrategyProxy.connect(sDeployer)[proxyInitFunction]( + cWithdrawalStrategyImpl.address, addresses.mainnet.Timelock, // governance - "0x", // do not call any initialize functions + withdrawalInitData, // data for call to the initialize function on the strategy await getTxOpts() ) ); - // 7. Safe approve SSV token spending - await cStrategy.connect(sDeployer).safeApproveAllTokens(); - - // 8. Deploy Harvester + // 11. Deploy Harvester const cOETHHarvesterProxy = await ethers.getContract("OETHHarvesterProxy"); await deployWithConfirmation("OETHHarvester", [ cVaultProxy.address, @@ -148,13 +194,24 @@ module.exports = deploymentWithGovernanceProposal( ]); const dOETHHarvesterImpl = await ethers.getContract("OETHHarvester"); - console.log("Native Staking SSV Strategy proxy: ", cStrategyProxy.address); + console.log( + "Native Staking SSV Strategy proxy: ", + cNativeStakingStrategyProxy.address + ); console.log( "Native Staking SSV Strategy implementation: ", - dStrategyImpl.address + cNativeStakingStrategyImpl.address ); console.log("Fee accumulator proxy: ", cFeeAccumulatorProxy.address); console.log("Fee accumulator implementation: ", cFeeAccumulator.address); + console.log( + "Lido withdrawal strategy proxy: ", + cWithdrawalStrategyStrategyProxy.address + ); + console.log( + "Lido withdrawal strategy implementation: ", + cWithdrawalStrategyImpl.address + ); console.log( "New OETHHarvester implementation: ", dOETHHarvesterImpl.address @@ -163,29 +220,35 @@ module.exports = deploymentWithGovernanceProposal( // Governance Actions // ---------------- return { - name: "Deploy new OETH Native Staking Strategy\n\nThis is going to become the main strategy to power the reward accrual of OETH by staking ETH into SSV validators.", + name: `Deploy new OETH Native Staking Strategy. + +This is going to become the main strategy to power the reward accrual of OETH by staking ETH in SSV validators. + +Deployed a new strategy to convert stETH to WETH at 1:1 using the Lido withdrawal queue. + +Upgraded the Harvester so ETH rewards can be sent straight to the Dripper as WETH.`, actions: [ // 1. Add new strategy to vault { contract: cVaultAdmin, signature: "approveStrategy(address)", - args: [cStrategyProxy.address], + args: [cNativeStakingStrategyProxy.address], }, // 2. configure Harvester to support the strategy { contract: cHarvester, signature: "setSupportedStrategy(address,bool)", - args: [cStrategyProxy.address, true], + args: [cNativeStakingStrategyProxy.address, true], }, // 3. set harvester to the strategy { - contract: cStrategy, + contract: cNativeStakingStrategy, signature: "setHarvesterAddress(address)", args: [cHarvesterProxy.address], }, // 4. configure the fuse interval { - contract: cStrategy, + contract: cNativeStakingStrategy, signature: "setFuseInterval(uint256,uint256)", args: [ ethers.utils.parseEther("21.6"), @@ -194,21 +257,21 @@ module.exports = deploymentWithGovernanceProposal( }, // 5. set validator registrator to the Defender Relayer { - contract: cStrategy, + contract: cNativeStakingStrategy, signature: "setRegistrator(address)", // The Defender Relayer args: [addresses.mainnet.validatorRegistrator], }, // 6. set staking threshold { - contract: cStrategy, + contract: cNativeStakingStrategy, signature: "setStakeETHThreshold(uint256)", // 16 validators before the 5/8 multisig has to call resetStakeETHTally args: [ethers.utils.parseEther("512")], // 16 * 32ETH }, // 7. set staking monitor { - contract: cStrategy, + contract: cNativeStakingStrategy, signature: "setStakingMonitor(address)", // The 5/8 multisig args: [addresses.mainnet.Guardian], @@ -219,6 +282,12 @@ module.exports = deploymentWithGovernanceProposal( signature: "upgradeTo(address)", args: [dOETHHarvesterImpl.address], }, + // 9. Add new Lido Withdrawal Strategy to vault + { + contract: cVaultAdmin, + signature: "approveStrategy(address)", + args: [cWithdrawalStrategyStrategyProxy.address], + }, ], }; } diff --git a/contracts/test/_fixture.js b/contracts/test/_fixture.js index 416c7a6440..bfb127f981 100644 --- a/contracts/test/_fixture.js +++ b/contracts/test/_fixture.js @@ -353,6 +353,7 @@ const defaultFixture = deployments.createFixture(async () => { morphoCompoundStrategy, fraxEthStrategy, frxEthRedeemStrategy, + lidoWithdrawalStrategy, balancerREthStrategy, makerDsrStrategy, morphoAaveStrategy, @@ -481,6 +482,14 @@ const defaultFixture = deployments.createFixture(async () => { frxEthRedeemStrategyProxy.address ); + const lidoWithdrawalStrategyProxy = await ethers.getContract( + "LidoWithdrawalStrategyProxy" + ); + lidoWithdrawalStrategy = await ethers.getContractAt( + "LidoWithdrawalStrategy", + lidoWithdrawalStrategyProxy.address + ); + const balancerRethStrategyProxy = await ethers.getContract( "OETHBalancerMetaPoolrEthStrategyProxy" ); @@ -764,6 +773,7 @@ const defaultFixture = deployments.createFixture(async () => { nativeStakingSSVStrategy, nativeStakingFeeAccumulator, frxEthRedeemStrategy, + lidoWithdrawalStrategy, balancerREthStrategy, oethMorphoAaveStrategy, woeth, @@ -1966,7 +1976,7 @@ async function aaveVaultFixture() { } /** - * Configure a Vault hold frxEth to be redeeemed by the frxEthRedeemStrategy + * Configure a Vault hold frxEth to be redeemed by the frxEthRedeemStrategy */ async function frxEthRedeemStrategyFixture() { const fixture = await oethDefaultFixture(); @@ -1983,6 +1993,29 @@ async function frxEthRedeemStrategyFixture() { return fixture; } +/** + * Configure a Vault hold stEth to be withdrawn by the LidoWithdrawalStrategy + */ +async function lidoWithdrawalStrategyFixture() { + const fixture = await oethDefaultFixture(); + + // Give weth and stETH to the vault + + await fixture.stETH + .connect(fixture.daniel) + .transfer(fixture.oethVault.address, parseUnits("2002")); + await fixture.weth + .connect(fixture.daniel) + .transfer(fixture.oethVault.address, parseUnits("2003")); + + fixture.lidoWithdrawalQueue = await ethers.getContractAt( + "IStETHWithdrawal", + addresses.mainnet.LidoWithdrawalQueue + ); + + return fixture; +} + /** * Configure a compound fixture with a false vault for testing */ @@ -2450,6 +2483,7 @@ module.exports = { untiltBalancerMetaStableWETHPool, fraxETHStrategyFixture, frxEthRedeemStrategyFixture, + lidoWithdrawalStrategyFixture, nativeStakingSSVStrategyFixture, oethMorphoAaveFixture, oeth1InchSwapperFixture, diff --git a/contracts/test/strategies/lido_withdrawal_strategy.fork-test.js b/contracts/test/strategies/lido_withdrawal_strategy.fork-test.js new file mode 100644 index 0000000000..366e8dc76e --- /dev/null +++ b/contracts/test/strategies/lido_withdrawal_strategy.fork-test.js @@ -0,0 +1,227 @@ +const { expect } = require("chai"); +const { + createFixtureLoader, + lidoWithdrawalStrategyFixture, +} = require("./../_fixture"); +const { ousdUnits, isCI } = require("../helpers"); +const { impersonateAccount } = require("../../utils/signers"); +const { parseUnits } = require("ethers/lib/utils"); + +describe("ForkTest: Lido Withdrawal Strategy", function () { + this.timeout(360 * 1000); + + // Retry up to 3 times on CI + this.retries(isCI ? 3 : 0); + + let fixture; + const loadFixture = createFixtureLoader(lidoWithdrawalStrategyFixture); + beforeEach(async () => { + fixture = await loadFixture(); + }); + + describe("Post-deployment", function () { + it("Should support WETH and stETH", async () => { + const { lidoWithdrawalStrategy, weth, stETH } = fixture; + expect(await lidoWithdrawalStrategy.supportsAsset(weth.address)).to.be + .true; + expect(await lidoWithdrawalStrategy.supportsAsset(stETH.address)).to.be + .true; + }); + }); + + describe("Redeem Lifecyle", function () { + it("Should redeem stETH for WETH (multiple requests)", async function () { + await _testWithdrawalCycle(ousdUnits("17003.45"), 18); + }); + it("Should redeem stETH for WETH (1 request)", async function () { + await _testWithdrawalCycle(ousdUnits("250"), 1); + }); + it("Should redeem stETH for WETH (2 request)", async function () { + await _testWithdrawalCycle(ousdUnits("1999.99"), 2); + }); + it("Should redeem stETH for WETH (1 small request)", async function () { + await _testWithdrawalCycle(ousdUnits("0.03"), 1); + }); + it("Should revert on zero amount", async function () { + const { lidoWithdrawalStrategy, stETH, oethVault, strategist } = fixture; + const txPromise = oethVault + .connect(strategist) + .depositToStrategy( + lidoWithdrawalStrategy.address, + [stETH.address], + [0] + ); + await expect(txPromise).to.be.revertedWith("No stETH to withdraw"); + }); + }); + + describe("Strategist sent WETH", function () { + it("Should return WETH to the vault", async function () { + const { lidoWithdrawalStrategy, weth, stETH, oethVault, strategist } = + fixture; + const initialWeth = await weth.balanceOf(oethVault.address); + await oethVault + .connect(strategist) + .depositToStrategy( + lidoWithdrawalStrategy.address, + [weth.address, stETH.address], + [ousdUnits("1000"), ousdUnits("1000")] + ); + const afterWeth = await weth.balanceOf(oethVault.address); + expect(afterWeth).to.equal(initialWeth); + }); + }); + + describe("withdrawAll()", function () { + it("Should withdraw all WETH, stETH, and native ETH to the vault", async function () { + const { + lidoWithdrawalStrategy, + weth, + stETH, + oethVault, + strategist, + daniel, + } = fixture; + + // Deposit some WETH, stETH, and native ETH to the strategy contract + const wethAmount = ousdUnits("10.34"); + const stETHAmount = ousdUnits("20.123"); + const nativeEthAmount = ousdUnits("5.2345"); + await weth + .connect(daniel) + .transfer(lidoWithdrawalStrategy.address, wethAmount); + await stETH + .connect(daniel) + .transfer(lidoWithdrawalStrategy.address, stETHAmount); + await strategist.sendTransaction({ + to: lidoWithdrawalStrategy.address, + value: nativeEthAmount, + }); + + // Get initial balances + const initialWethBalanceVault = await weth.balanceOf(oethVault.address); + const initialStEthBalanceVault = await stETH.balanceOf(oethVault.address); + const initialNativeEthBalanceVault = await oethVault.provider.getBalance( + oethVault.address + ); + + // Call withdrawAll() + await oethVault + .connect(strategist) + .withdrawAllFromStrategy(lidoWithdrawalStrategy.address); + + // Check final balances + const finalWethBalanceVault = await weth.balanceOf(oethVault.address); + const finalstEthBalanceVault = await stETH.balanceOf(oethVault.address); + const finalNativeEthBalanceVault = await oethVault.provider.getBalance( + oethVault.address + ); + expect(finalWethBalanceVault.sub(initialWethBalanceVault)) + .to.gte(wethAmount.add(nativeEthAmount).sub(2)) + .lte(wethAmount.add(nativeEthAmount)); + expect(finalstEthBalanceVault.sub(initialStEthBalanceVault)) + .to.gte( + // stETH transfers can leave 1-2 wei in the contract + stETHAmount.sub(2) + ) + .lte(stETHAmount); + expect(finalNativeEthBalanceVault).to.equal(initialNativeEthBalanceVault); + }); + + it("Should handle the case when the strategy has no assets", async function () { + const { lidoWithdrawalStrategy, oethVault, strategist } = fixture; + await oethVault + .connect(strategist) + .withdrawAllFromStrategy(lidoWithdrawalStrategy.address); + }); + }); + + async function parseRequestIds(tx, lidoWithdrawalStrategy) { + const WithdrawalRequestedTopic = + "0x4a54e868001801e435d72d0f5a4ead23b6be3f49544fcfde1b83dd6d779a50f4"; + const receipt = await tx.wait(); + const log = receipt.events.find( + (x) => + x.address == lidoWithdrawalStrategy.address && + x.topics[0] == WithdrawalRequestedTopic + ); + const event = lidoWithdrawalStrategy.interface.parseLog(log); + return event.args.requestIds; + } + + async function finalizeRequests(requestIds, stETH, withdrawalQueue) { + const stETHSigner = await impersonateAccount(stETH.address); + const lastRequest = requestIds.slice(-1)[0]; + + const maxShareRate = parseUnits("1200000000", 18); + await withdrawalQueue + .connect(stETHSigner) + .finalize(lastRequest, maxShareRate); + + // check the first request is finalized + const requestStatuses = await withdrawalQueue.getWithdrawalStatus( + requestIds + ); + expect(requestStatuses[0].isFinalized).to.be.true; + } + + async function _testWithdrawalCycle(amount, expectedRequests) { + const { + lidoWithdrawalStrategy, + lidoWithdrawalQueue, + weth, + stETH, + oethVault, + strategist, + } = fixture; + const initialEth = await weth.balanceOf(oethVault.address); + const initialOutstanding = + await lidoWithdrawalStrategy.outstandingWithdrawals(); + expect(await lidoWithdrawalStrategy.checkBalance(weth.address)).to.equal( + await lidoWithdrawalStrategy.outstandingWithdrawals() + ); + expect(await lidoWithdrawalStrategy.checkBalance(stETH.address)).to.equal( + 0 + ); + + const tx = await oethVault + .connect(strategist) + .depositToStrategy( + lidoWithdrawalStrategy.address, + [stETH.address], + [amount] + ); + expect(await lidoWithdrawalStrategy.outstandingWithdrawals()) + .to.gte(initialOutstanding.add(amount).sub(2)) + .lte(initialOutstanding.add(amount)); + expect(await lidoWithdrawalStrategy.checkBalance(weth.address)).to.equal( + await lidoWithdrawalStrategy.outstandingWithdrawals() + ); + expect(await lidoWithdrawalStrategy.checkBalance(stETH.address)).to.equal( + 0 + ); + + const requestIds = await parseRequestIds(tx, lidoWithdrawalStrategy); + + // finalize the requests so they can be claimed + await finalizeRequests(requestIds, stETH, lidoWithdrawalQueue); + + // Claim finalized requests + await lidoWithdrawalStrategy + .connect(strategist) + .claimWithdrawals(requestIds, amount); + + const afterEth = await weth.balanceOf(oethVault.address); + expect(requestIds.length).to.equal(expectedRequests); + expect(afterEth.sub(initialEth)).to.gte(amount.sub(2)).lte(amount); + expect(await lidoWithdrawalStrategy.outstandingWithdrawals()).to.equal( + initialOutstanding + ); + expect(await lidoWithdrawalStrategy.checkBalance(weth.address)).to.equal( + await lidoWithdrawalStrategy.outstandingWithdrawals() + ); + expect(await lidoWithdrawalStrategy.checkBalance(stETH.address)).to.equal( + 0 + ); + } +}); diff --git a/contracts/utils/addresses.js b/contracts/utils/addresses.js index c1d5e42395..6367ebedd9 100644 --- a/contracts/utils/addresses.js +++ b/contracts/utils/addresses.js @@ -261,6 +261,10 @@ addresses.mainnet.NativeStakingSSVStrategyProxy = addresses.mainnet.validatorRegistrator = "0x4b91827516f79d6F6a1F292eD99671663b09169a"; +// Lido Withdrawal Queue +addresses.mainnet.LidoWithdrawalQueue = + "0x889edC2eDab5f40e902b864aD4d7AdE8E412F9B1"; + // Arbitrum One addresses.arbitrumOne = {}; addresses.arbitrumOne.WOETHProxy = "0xD8724322f44E5c58D7A815F542036fb17DbbF839";