diff --git a/foundry.toml b/foundry.toml index f92eb61..4cc8df3 100644 --- a/foundry.toml +++ b/foundry.toml @@ -15,6 +15,7 @@ fs_permissions = [{ access = "read-write", path = "./"}] [rpc_endpoints] goerli = "${GOERLI_RPC_URL}" mainnet = "${MAINNET_RPC_URL}" + # See more config options https://github.com/gakonst/foundry/tree/master/config [fmt] diff --git a/src/interfaces/IETH2DepositContract.sol b/src/interfaces/IETH2DepositContract.sol new file mode 100644 index 0000000..3dad0db --- /dev/null +++ b/src/interfaces/IETH2DepositContract.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +interface IETH2DepositContract { + /// @notice A processed deposit event. + event DepositEvent( + bytes pubkey, + bytes withdrawal_credentials, + bytes amount, + bytes signature, + bytes index + ); + + /// @notice Submit a Phase 0 DepositData object. + /// @param pubkey A BLS12-381 public key. + /// @param withdrawal_credentials Commitment to a public key for withdrawals. + /// @param signature A BLS12-381 signature. + /// @param deposit_data_root The SHA-256 hash of the SSZ-encoded DepositData object. + /// Used as a protection against malformed input. + function deposit( + bytes calldata pubkey, + bytes calldata withdrawal_credentials, + bytes calldata signature, + bytes32 deposit_data_root + ) external payable; + + /// @notice Query the current deposit root hash. + /// @return The deposit root hash. + function get_deposit_root() external view returns (bytes32); + + /// @notice Query the current deposit count. + /// @return The deposit count encoded as a little endian 64-bit number. + function get_deposit_count() external view returns (bytes memory); +} \ No newline at end of file diff --git a/src/safe-modules/SimpleETHContributionVault.sol b/src/safe-modules/SimpleETHContributionVault.sol new file mode 100644 index 0000000..b76d928 --- /dev/null +++ b/src/safe-modules/SimpleETHContributionVault.sol @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.19; + +import {ERC20} from "solady/tokens/ERC20.sol"; +import {SafeTransferLib} from "solady/utils/SafeTransferLib.sol"; +import {IETH2DepositContract} from "../interfaces/IETH2DepositContract.sol"; + +contract SimpleETHContributionVault { + using SafeTransferLib for address; + + /// @notice unathorised user + /// @param user address of unauthorized user + error Unauthorized(address user); + + /// @notice cannot rage quit + error CannotRageQuit(); + + /// @notice incomplete contribution + error IncompleteContribution(uint256 actual, uint256 expected); + + /// @notice invalid deposit data + error Invalid__DepositData(); + + /// @notice Invalid Address + error Invalid__Address(); + + /// @notice Emitted on deposit ETH + /// @param to address the credited ETH + /// @param amount Amount of ETH deposit + event Deposit(address to, uint256 amount); + + /// @notice Emitted on user rage quit + /// @param to address that received amount + /// @param amount amount rage quitted + event RageQuit(address to, uint256 amount); + + /// @notice Emitted on rescue funds + /// @param amount amount of funds rescued + event RescueFunds(uint256 amount); + + /// @notice Amount of ETH validator stake + uint256 internal constant ETH_STAKE = 32 ether; + + /// @notice ETH2 deposit contract + IETH2DepositContract public immutable depositContract; + + /// @notice Address of gnosis safe + address public immutable safe; + + /// @notice adress => amount deposited + mapping(address => uint256) public userBalances; + + /// @notice tracks if validator have been activated + bool public activated; + + modifier onlySafe() { + if (msg.sender != safe) revert Unauthorized(msg.sender); + _; + } + + constructor(address _safe, address eth2DepositContract) { + safe = _safe; + depositContract = IETH2DepositContract(eth2DepositContract); + } + + receive() external payable { + _deposit(msg.sender, msg.value); + } + + /// @notice deposit ether into the contract + /// @param to address to credit + function deposit( + address to + ) external payable { + if (to == address(0)) revert Invalid__Address(); + _deposit(to, msg.value); + } + + /// @notice Deposit ETH into ETH2 deposit contract + /// @param pubkeys Array of validator pub keys + /// @param withdrawal_credentials Array of validator withdrawal credentials + /// @param signatures Array of validator signatures + /// @param deposit_data_roots Array of validator deposit data roots + function depositValidator( + bytes[] calldata pubkeys, + bytes[] calldata withdrawal_credentials, + bytes[] calldata signatures, + bytes32[] calldata deposit_data_roots + ) external onlySafe { + uint256 size = pubkeys.length; + + if ( + (withdrawal_credentials.length != size) || + (signatures.length != size) || + (deposit_data_roots.length != size) + ) { + revert Invalid__DepositData(); + } + + + if (address(this).balance < size * ETH_STAKE) { + revert IncompleteContribution(address(this).balance, ETH_STAKE * size); + } + + activated = true; + + for (uint256 i = 0; i < size;) { + depositContract.deposit{value: ETH_STAKE}( + pubkeys[i], withdrawal_credentials[i], signatures[i], deposit_data_roots[i] + ); + + unchecked { + i++; + } + } + } + + /// @notice Exit contribution vault prior to deposit starts + /// It allows a contributor to exit the vault before any validator is + /// activated + /// @param to Address to send funds to + /// @param amount balance to withdraw + function rageQuit(address to, uint256 amount) external { + if (to == address(0)) revert Invalid__Address(); + if (activated == true) revert CannotRageQuit(); + + userBalances[msg.sender] -= amount; + to.safeTransferETH(amount); + + emit RageQuit(to, amount); + } + + /// @notice Rescue non-ETH tokens stuck to the safe + /// @param token Token address + /// @param amount amount of balance to transfer to Safe + function rescueFunds(address token, uint256 amount) external { + token.safeTransfer(safe, amount); + emit RescueFunds(amount); + } + + /// @notice a user deposit + /// @param to address to receive the deposit + /// @param amount amount of deposit + function _deposit(address to, uint256 amount) internal { + userBalances[to] += amount; + emit Deposit(to, amount); + } +} diff --git a/src/test/safe/SimpleETHContributionVault.t.sol b/src/test/safe/SimpleETHContributionVault.t.sol new file mode 100644 index 0000000..315a786 --- /dev/null +++ b/src/test/safe/SimpleETHContributionVault.t.sol @@ -0,0 +1,269 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import {MockERC20} from "src/test/utils/mocks/MockERC20.sol"; +import {SimpleETHContributionVault} from "src/safe-modules/SimpleETHContributionVault.sol"; + +contract SimpleETHContributionVaultTest is Test { + error CannotRageQuit(); + error Invalid__Address(); + error Invalid__DepositData(); + error Unauthorized(address user); + + event Deposit(address to, uint256 amount); + event DepositValidator( + bytes[] pubkeys, bytes[] withdrawal_credentials, bytes[] signatures, bytes32[] deposit_data_roots + ); + event RageQuit(address to, uint256 amount); + event RescueFunds(uint256 amount); + + address constant ETH_DEPOSIT_CONTRACT = 0x00000000219ab540356cBB839Cbe05303d7705Fa; + uint256 internal constant ETH_STAKE = 32 ether; + + SimpleETHContributionVault contributionVault; + + address safe; + address user1; + address user2; + address user3; + MockERC20 mERC20; + + function setUp() public { + safe = makeAddr("safe"); + user1 = makeAddr("user1"); + user2 = makeAddr("user2"); + user3 = makeAddr("user3"); + + uint256 mainnetBlock = 17_421_005; + vm.createSelectFork(getChain("mainnet").rpcUrl, mainnetBlock); + + + contributionVault = new SimpleETHContributionVault( + safe, + ETH_DEPOSIT_CONTRACT + ); + + mERC20 = new MockERC20("Test Token", "TOK", 18); + mERC20.mint(type(uint256).max); + } + + function test_receive() external { + vm.deal(user1, ETH_STAKE); + + vm.expectEmit(false, false, false, true); + emit Deposit(user1, ETH_STAKE); + + vm.prank(user1); + (bool _success,) = payable(contributionVault).call{value: ETH_STAKE}(""); + assertTrue(_success, "call failed"); + + assertEq(contributionVault.userBalances(user1), ETH_STAKE, "failed to credit user balance"); + } + + function testFuzz_receive(address user, uint256 amount) external { + vm.assume(amount > 0); + vm.assume(user != address(0)); + amount = bound(amount, 0, address(this).balance); + + vm.deal(user, amount); + + vm.expectEmit(false, false, false, true); + emit Deposit(user, amount); + + vm.prank(user); + (bool _success,) = payable(contributionVault).call{value: amount}(""); + assertTrue(_success, "call failed"); + + assertEq(contributionVault.userBalances(user), amount); + } + + function test_deposit() external { + vm.deal(user1, ETH_STAKE); + + vm.expectEmit(false, false, false, true); + emit Deposit(user1, ETH_STAKE); + + vm.prank(user1); + contributionVault.deposit{value: ETH_STAKE}(user1); + + assertEq(contributionVault.userBalances(user1), ETH_STAKE, "failed to credit user balance"); + } + + function testFuzz_deposit(address user, uint256 amount) external { + vm.assume(amount > 0); + vm.assume(user != address(0)); + amount = bound(amount, 0, address(this).balance); + + vm.deal(user, amount); + + vm.expectEmit(false, false, false, true); + emit Deposit(user, amount); + + vm.prank(user); + contributionVault.deposit{value: amount}(user); + + assertEq(contributionVault.userBalances(user), amount); + } + + function test_cannotDepositInvalidZero() external { + vm.expectRevert(Invalid__Address.selector); + contributionVault.deposit{value: ETH_STAKE}(address(0)); + } + + function test_rageQuit() external { + vm.deal(user1, ETH_STAKE); + + vm.prank(user1); + (bool _success,) = payable(contributionVault).call{value: ETH_STAKE}(""); + assertTrue(_success, "call failed"); + + vm.expectEmit(false, false, false, true); + emit RageQuit(user1, ETH_STAKE); + + vm.prank(user1); + contributionVault.rageQuit(user1, ETH_STAKE); + } + + function testFuzz_rageQuit(address user, uint256 amount) external { + vm.assume(amount > 0); + vm.assume(user != address(0)); + amount = bound(amount, 0, address(this).balance); + + vm.deal(user, amount); + + vm.prank(user); + (bool _success,) = payable(contributionVault).call{value: amount}(""); + assertTrue(_success, "call failed"); + + vm.expectEmit(false, false, false, true); + emit RageQuit(user, amount); + + vm.prank(user); + contributionVault.rageQuit(user, amount); + } + + function test_cannotRageQuitAfterDeposit() external { + vm.deal(user1, ETH_STAKE); + + vm.prank(user1); + (bool _success,) = payable(contributionVault).call{value: ETH_STAKE}(""); + assertTrue(_success, "call failed"); + + ( + bytes[] memory pubkeys, + bytes[] memory withdrawal_credentials, + bytes[] memory signatures, + bytes32[] memory deposit_data_roots + ) = getETHValidatorData(); + + vm.prank(safe); + contributionVault.depositValidator(pubkeys, withdrawal_credentials, signatures, deposit_data_roots); + + vm.expectRevert(CannotRageQuit.selector); + + vm.prank(user1); + contributionVault.rageQuit(user1, ETH_STAKE); + } + + function test_invalidDepositData() external { + ( + bytes[] memory pubkeys, + bytes[] memory withdrawal_credentials,, + ) = getETHValidatorData(); + + vm.prank(safe); + vm.expectRevert(Invalid__DepositData.selector); + contributionVault.depositValidator(pubkeys, withdrawal_credentials, new bytes[](0), new bytes32[](0)); + } + + function test_cannotRageQuitIfZeroAddress() external { + vm.expectRevert(Invalid__Address.selector); + contributionVault.rageQuit( + address(0), + 1 + ); + } + + + function test_depositValidator() external { + vm.deal(user1, ETH_STAKE); + + vm.prank(user1); + (bool _success,) = payable(contributionVault).call{value: ETH_STAKE}(""); + assertTrue(_success, "call failed"); + + ( + bytes[] memory pubkeys, + bytes[] memory withdrawal_credentials, + bytes[] memory signatures, + bytes32[] memory deposit_data_roots + ) = getETHValidatorData(); + + vm.prank(safe); + contributionVault.depositValidator(pubkeys, withdrawal_credentials, signatures, deposit_data_roots); + } + + function test_OnlySafeDepositValidator() external { + ( + bytes[] memory pubkeys, + bytes[] memory withdrawal_credentials, + bytes[] memory signatures, + bytes32[] memory deposit_data_roots + ) = getETHValidatorData(); + + vm.expectRevert(abi.encodeWithSelector(Unauthorized.selector, address(this))); + contributionVault.depositValidator(pubkeys, withdrawal_credentials, signatures, deposit_data_roots); + } + + function test_rescueFunds() external { + uint256 amount = 1 ether; + mERC20.transfer(address(contributionVault), amount); + + vm.expectEmit(false, false, false, true); + emit RescueFunds(amount); + + contributionVault.rescueFunds(address(mERC20), amount); + } + + function testFuzz_rescueFunds(uint256 amount) external { + vm.assume(amount > 0); + + mERC20.transfer(address(contributionVault), amount); + vm.expectEmit(false, false, false, true); + emit RescueFunds(amount); + + contributionVault.rescueFunds(address(mERC20), amount); + } +} + +function getETHValidatorData() + pure + returns ( + bytes[] memory pubkeys, + bytes[] memory withdrawal_credentials, + bytes[] memory signatures, + bytes32[] memory deposit_data_roots + ) +{ + pubkeys = new bytes[](1); + pubkeys[0] = bytes( + abi.encodePacked( + hex"83fa9495bb0944a74fc6a66e699039b66134b22a52a710f8d0f7cde318a2db3da40081a5867667389d206e21b5e37e52" + ) + ); + + withdrawal_credentials = new bytes[](1); + withdrawal_credentials[0] = + bytes(abi.encodePacked(hex"010000000000000000000000e839a3e9efb32c6a56ab7128e51056585275506c")); + + signatures = new bytes[](1); + signatures[0] = bytes( + abi.encodePacked( + hex"95f00435e80e59a8fed41581e2050a3fe56272d6be845686ef014a57909c6621d7847fa550b77cb8e541b955f3c2ea031983d9e4336f215e75c8ba75d94e05f1e23460de6611a980ef629d3e32ca09cffaf2a63372496079b1ee22310d336ded" + ) + ); + + deposit_data_roots = new bytes32[](1); + deposit_data_roots[0] = bytes32(0x2d33b096d02f7a53dbbdaf840755dfc6b9269be39cff6b0d2701d15b4c1b639c); +} diff --git a/src/test/safe/invariant/SECVInvariant.t.sol b/src/test/safe/invariant/SECVInvariant.t.sol new file mode 100644 index 0000000..7545bc8 --- /dev/null +++ b/src/test/safe/invariant/SECVInvariant.t.sol @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.19; + +import {Test} from "forge-std/Test.sol"; +import "forge-std/console.sol"; +import {StdInvariant} from "forge-std/StdInvariant.sol"; +import {SimpleETHContributionVault} from "src/safe-modules/SimpleETHContributionVault.sol"; +import {CommonBase} from "forge-std/Base.sol"; +import {StdCheats} from "forge-std/StdCheats.sol"; +import {StdUtils} from "forge-std/StdUtils.sol"; +import {getETHValidatorData} from "../SimpleETHContributionVault.t.sol"; + +contract MockDepositContract { + uint256 public ghost_depositSum; + + receive() external payable {} + + function deposit(bytes calldata, bytes calldata, bytes calldata, bytes32) external payable { + ghost_depositSum += msg.value; + } +} + +contract SECVMock is SimpleETHContributionVault { + constructor(address _safe, address eth2DepositContract) SimpleETHContributionVault(_safe, eth2DepositContract) {} + + /// @notice Mock depositValidator function + function depositValidatorMock( + bytes[] calldata pubkeys, + bytes[] calldata withdrawal_credentials, + bytes[] calldata signatures, + bytes32[] calldata deposit_data_roots + ) external { + for (uint256 i = 0; i < 1;) { + depositContract.deposit{value: address(this).balance}( + pubkeys[i], withdrawal_credentials[i], signatures[i], deposit_data_roots[i] + ); + unchecked { + i++; + } + } + } +} + +contract SECVBoundedHandler is CommonBase, StdCheats, StdUtils { + SECVMock public contributionVault; + + uint256 public constant ETH_SUPPLY = 1_000_000 ether; + + uint256 public ghost_depositSum; + uint256 public ghost_rageQuitSum; + + receive() external payable {} + + constructor(SECVMock vault) { + contributionVault = vault; + deal(address(this), ETH_SUPPLY); + } + + function deposit(uint256 amount) external payable { + amount = bound(amount, 0, address(this).balance); + (bool _success,) = payable(contributionVault).call{value: amount}(""); + assert(_success); + + ghost_depositSum += amount; + } + + function rageQuit(uint256 amount) external payable { + amount = bound(amount, 0, contributionVault.userBalances(address(this))); + contributionVault.rageQuit(address(this), amount); + + ghost_rageQuitSum += amount; + } + + function depositValidator() external payable { + ( + bytes[] memory pubkeys, + bytes[] memory withdrawal_credentials, + bytes[] memory signatures, + bytes32[] memory deposit_data_roots + ) = getETHValidatorData(); + + // use entire vault balance + contributionVault.depositValidatorMock(pubkeys, withdrawal_credentials, signatures, deposit_data_roots); + } +} + +contract SECVInvariant is Test { + MockDepositContract public mockDepositContract; + SECVMock public contributionVault; + SECVBoundedHandler public handler; + + address public safe; + + function setUp() public { + safe = makeAddr("safe"); + + mockDepositContract = new MockDepositContract(); + contributionVault = new SECVMock( + safe, + address(mockDepositContract) + ); + handler = new SECVBoundedHandler(contributionVault); + + targetContract(address(handler)); + } + + /// @notice This invariant checks that the sum of balances in the handler, + /// vault and mock deposit contract is equal + function invariant_balanceEqual() public { + assertEq( + handler.ETH_SUPPLY(), + address(handler).balance + address(contributionVault).balance + address(mockDepositContract).balance + ); + } + + /// @notice This invariant checks that the vault is + /// always solvent + function invariant_vaultIsSolvent() public { + assertEq( + address(contributionVault).balance, + handler.ghost_depositSum() - mockDepositContract.ghost_depositSum() - handler.ghost_rageQuitSum() + ); + } +}