Skip to content

[SI][Vault] Dashboard recovery #908

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jan 14, 2025
Merged
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
51 changes: 51 additions & 0 deletions contracts/0.8.25/vaults/Dashboard.sol
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {Math256} from "contracts/common/lib/Math256.sol";
import {VaultHub} from "./VaultHub.sol";

import {IERC20} from "@openzeppelin/contracts-v5.0.2/token/ERC20/IERC20.sol";
import {IERC721} from "@openzeppelin/contracts-v5.0.2/token/ERC721/IERC721.sol";
import {IERC20Permit} from "@openzeppelin/contracts-v5.0.2/token/ERC20/extensions/IERC20Permit.sol";
import {ILido as IStETH} from "contracts/0.8.25/interfaces/ILido.sol";
import {ILidoLocator} from "contracts/common/interfaces/ILidoLocator.sol";
Expand Down Expand Up @@ -56,6 +57,9 @@ contract Dashboard is AccessControlEnumerable {
/// @notice The wrapped ether token contract
IWETH9 public immutable WETH;

/// @notice ETH address convention per EIP-7528
address public constant ETH = address(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE);

/// @notice The underlying `StakingVault` contract
IStakingVault public stakingVault;

Expand Down Expand Up @@ -403,6 +407,41 @@ contract Dashboard is AccessControlEnumerable {
_rebalanceVault(_ether);
}

/**
* @notice recovers ERC20 tokens or ether from the dashboard contract to sender
* @param _token Address of the token to recover or 0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee for ether
*/
function recoverERC20(address _token) external onlyRole(DEFAULT_ADMIN_ROLE) {
uint256 _amount;
if (_token == address(0)) revert ZeroArgument("_token");

if (_token == ETH) {
_amount = address(this).balance;
payable(msg.sender).transfer(_amount);
} else {
_amount = IERC20(_token).balanceOf(address(this));
bool success = IERC20(_token).transfer(msg.sender, _amount);
if (!success) revert("ERC20: Transfer failed");
}

emit ERC20Recovered(msg.sender, _token, _amount);
}

/**
* @notice Transfers a given token_id of an ERC721-compatible NFT (defined by the token contract address)
* from the dashboard contract to sender
*
* @param _token an ERC721-compatible token
* @param _tokenId token id to recover
*/
function recoverERC721(address _token, uint256 _tokenId) external onlyRole(DEFAULT_ADMIN_ROLE) {
if (_token == address(0)) revert ZeroArgument("_token");

emit ERC721Recovered(msg.sender, _token, _tokenId);

IERC721(_token).transferFrom(address(this), msg.sender, _tokenId);
}

// ==================== Internal Functions ====================

/**
Expand Down Expand Up @@ -501,6 +540,18 @@ contract Dashboard is AccessControlEnumerable {
/// @notice Emitted when the contract is initialized
event Initialized();

/// @notice Emitted when the ERC20 `token` or Ether is recovered (i.e. transferred)
/// @param to The address of the recovery recipient
/// @param token The address of the recovered ERC20 token (zero address for Ether)
/// @param amount The amount of the token recovered
event ERC20Recovered(address indexed to, address indexed token, uint256 amount);

/// @notice Emitted when the ERC721-compatible `token` (NFT) recovered (i.e. transferred)
/// @param to The address of the recovery recipient
/// @param token The address of the recovered ERC721 token
/// @param tokenId id of token recovered
event ERC721Recovered(address indexed to, address indexed token, uint256 tokenId);

// ==================== Errors ====================

/// @notice Error for zero address arguments
Expand Down
14 changes: 14 additions & 0 deletions test/0.8.25/vaults/dashboard/contracts/ERC721_MockForDashboard.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// SPDX-License-Identifier: UNLICENSED
// for testing purposes only

pragma solidity 0.8.25;

import {ERC721} from "@openzeppelin/contracts-v5.0.2/token/ERC721/ERC721.sol";

contract ERC721_MockForDashboard is ERC721 {
constructor() ERC721("MockERC721", "M721") {}

function mint(address _recipient, uint256 _tokenId) external {
_mint(_recipient, _tokenId);
}
}
75 changes: 74 additions & 1 deletion test/0.8.25/vaults/dashboard/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { setBalance, time } from "@nomicfoundation/hardhat-network-helpers";
import {
Dashboard,
DepositContract__MockForStakingVault,
ERC721_MockForDashboard,
LidoLocator,
StakingVault,
StETHPermit__HarnessForDashboard,
Expand All @@ -31,6 +32,7 @@ describe("Dashboard", () => {

let steth: StETHPermit__HarnessForDashboard;
let weth: WETH9__MockForVault;
let erc721: ERC721_MockForDashboard;
let wsteth: WstETH__HarnessForVault;
let hub: VaultHub__MockForDashboard;
let depositContract: DepositContract__MockForStakingVault;
Expand All @@ -57,6 +59,7 @@ describe("Dashboard", () => {
weth = await ethers.deployContract("WETH9__MockForVault");
wsteth = await ethers.deployContract("WstETH__HarnessForVault", [steth]);
hub = await ethers.deployContract("VaultHub__MockForDashboard", [steth]);
erc721 = await ethers.deployContract("ERC721_MockForDashboard");
lidoLocator = await deployLidoLocator({ lido: steth, wstETH: wsteth });
depositContract = await ethers.deployContract("DepositContract__MockForStakingVault");

Expand Down Expand Up @@ -1173,6 +1176,75 @@ describe("Dashboard", () => {
});
});

context("recover", async () => {
const amount = ether("1");

before(async () => {
const wethContract = weth.connect(vaultOwner);

await wethContract.deposit({ value: amount });

await vaultOwner.sendTransaction({ to: dashboardAddress, value: amount });
await wethContract.transfer(dashboardAddress, amount);
await erc721.mint(dashboardAddress, 0);

expect(await ethers.provider.getBalance(dashboardAddress)).to.equal(amount);
expect(await wethContract.balanceOf(dashboardAddress)).to.equal(amount);
expect(await erc721.ownerOf(0)).to.equal(dashboardAddress);
});

it("allows only admin to recover", async () => {
await expect(dashboard.connect(stranger).recoverERC20(ZeroAddress)).to.be.revertedWithCustomError(
dashboard,
"AccessControlUnauthorizedAccount",
);
await expect(dashboard.connect(stranger).recoverERC721(erc721.getAddress(), 0)).to.be.revertedWithCustomError(
dashboard,
"AccessControlUnauthorizedAccount",
);
});

it("does not allow zero token address for erc20 recovery", async () => {
await expect(dashboard.recoverERC20(ZeroAddress)).to.be.revertedWithCustomError(dashboard, "ZeroArgument");
});

it("recovers all ether", async () => {
const ethStub = await dashboard.ETH();
const preBalance = await ethers.provider.getBalance(vaultOwner);
const tx = await dashboard.recoverERC20(ethStub);
const { gasUsed, gasPrice } = (await ethers.provider.getTransactionReceipt(tx.hash))!;

await expect(tx).to.emit(dashboard, "ERC20Recovered").withArgs(tx.from, ethStub, amount);
expect(await ethers.provider.getBalance(dashboardAddress)).to.equal(0);
expect(await ethers.provider.getBalance(vaultOwner)).to.equal(preBalance + amount - gasUsed * gasPrice);
});

it("recovers all weth", async () => {
const preBalance = await weth.balanceOf(vaultOwner);
const tx = await dashboard.recoverERC20(weth.getAddress());

await expect(tx)
.to.emit(dashboard, "ERC20Recovered")
.withArgs(tx.from, await weth.getAddress(), amount);
expect(await weth.balanceOf(dashboardAddress)).to.equal(0);
expect(await weth.balanceOf(vaultOwner)).to.equal(preBalance + amount);
});

it("does not allow zero token address for erc721 recovery", async () => {
await expect(dashboard.recoverERC721(ZeroAddress, 0)).to.be.revertedWithCustomError(dashboard, "ZeroArgument");
});

it("recovers erc721", async () => {
const tx = await dashboard.recoverERC721(erc721.getAddress(), 0);

await expect(tx)
.to.emit(dashboard, "ERC721Recovered")
.withArgs(tx.from, await erc721.getAddress(), 0);

expect(await erc721.ownerOf(0)).to.equal(vaultOwner.address);
});
});

context("fallback behavior", () => {
const amount = ether("1");

Expand All @@ -1187,8 +1259,9 @@ describe("Dashboard", () => {
});

it("allows ether to be recieved", async () => {
const preBalance = await weth.balanceOf(dashboardAddress);
await vaultOwner.sendTransaction({ to: dashboardAddress, value: amount });
expect(await ethers.provider.getBalance(dashboardAddress)).to.equal(amount);
expect(await ethers.provider.getBalance(dashboardAddress)).to.equal(amount + preBalance);
});
});
});
Loading