Skip to content

Commit

Permalink
Fix/native restaking withdraw in progress (#52)
Browse files Browse the repository at this point in the history
* initialize ExocoreCapsule

* add ValidatorContainer library

* implement capsule deposit

* initiate updateStakingBalance

* add index=>pubkey mapping

* add withdrawal container and integrate exocapsule into clientchaingateway

* add isValidWithdrawalContainerRoot implementation to verify withdrawal merkle proof

* add NativeRestakingController contract

* add createExoCapsule function

* add depositBeaconChainValidator implementation

* abstract CommonRestakingController

* add whenNotPaused modifier to functions

* rename CommonRestakingController as BaseRestakingController

* remove unused imports and fix errors

* fix operator address length

* optimize some code with DRY princple

* update log and reuse some code

* fix test

* move stake interface to NativeRestakingController

* update principleBalance upon receiving deposit response

* draft plantuml diagram

* fix depositBeaconChainValidator diagram

* fix container merklizatiion function

* forge install: eigenlayer-beacon-oracle

* integrate EigenLayerBeaconOracle into ExoCapsule

* add uint test for ExoCapsule.verifyDepositProof

* Optimize reuse code

* add other uint tests for ExoCapsule.verifyDepositProof

* adapt to use beacon proxies and create2 for vaults and capsules creation

* add nativedepositwithdraw integration test

* fix #30(remove TSSReceiver), deploy vaults with beacon proxy, fix incompatibilities

* reuse _getVault and rename exocoreAddressIsValid

* reuse modifiers

* fix some warnings and reuse some code

* fix: add prettier for audit, implement non beacon chain ETH withdraw workflow

* fix: move variable to ExoCapsuleStorage

* feat: consolidiate partial and full withdraw workflow in a single function

* fix ExocoreGateway.requestUndelegateFrom and add undeletion integration test

* optmize the _processRequest to make it simple and gas saving

* store BEACON_PROXY_BYTECODE in a separate contract to fix code size too big issue and deploy contracts on sepolia for testing

* remove unused file and comment on chainid

* fix name typo prerequisite

* deploy vault when adding whitelist token

* remove license infos

* remove unused codes in Merkle.sol

* clearBootstrapData => _clearBootstrapData

* fix ExoCapsule according Max's review

* optimize _isStaleProof and _hasFullyWithdrawn

* fix bootstrap unit test

* add comments for request lenght

* fix: merge conflict

* fix: update principle and withdraw balance after request

* fix: update withdraw modifiers

* fix: isValidWCRootAgainstExecutionPayloadRoot check logic updated with proper withdrawal tree height

* feat: update withdrawal test setup contract

* feat: refactor validator container set using internal setter functions

* fix: withdrwal proof generation test

* fix: historial summaries verification pass with beacon state root

* feat: update has restaking logic

* fix: remove prettier config and stick with forge fmt

* fix: forge fmt without prettier conflict

* feat: add vscode extension settings

* feat: full withdraw test improved

* feat: partial withdraw tests done

* fix: remove unused vars

* fix: remove complex callback lint

* fix: temporary increase for line length

* fix: remove console log

* fix: failing CI tests and get rid of hasRestake check

* fix: process request args

* chore(fmt): run `forge fmt`

* fix: use event emit rather than revert when withdraw from exocore is failed

* fix: remove hasRestaked flag and choose plan A

* fix: use beaconBlockRoot from oracle

* fix: proof validation issue with beacon block root

* fix: remove console log

* fix: remove unused struct

* feat: integration test for native withdrwal

* feat: deposit with 32 ether cap

* fix: typo for principal

* feat: update withdrawal verification logic

* feat: withdraw index instead of withdraw timestamp

* feat: reentrancy guard for sending ETH

* fix: typo

* fix: forge formatter

* fix: withdraw epoch calculation

* fix: lib version

* fix: update reentrancy guard import

* fix: add more constants for beaconchain proofs

* feat: beacon chain proof verification updated

* fix: integration test with 32 ether deposit cap

* fix: remove unused variable for slither check

* fix: ignore slither for withdraw eth to recipient address

---------

Co-authored-by: adu <[email protected]>
Co-authored-by: bwhour <[email protected]>
Co-authored-by: MaxMustermann2 <[email protected]>
  • Loading branch information
4 people authored Jul 17, 2024
1 parent a809580 commit a6dfa2d
Show file tree
Hide file tree
Showing 17 changed files with 1,073 additions and 193 deletions.
6 changes: 6 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"[solidity]": {
"editor.defaultFormatter": "JuanBlanco.solidity"
},
"solidity.formatter": "forge"
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@
"death": "^1.1.0",
"debug": "^4.3.4",
"decamelize": "^4.0.0",
"decimal.js":"10.4.3",
"decimal.js": "10.4.3",
"deep-eql": "^4.1.3",
"deep-extend": "^0.6.0",
"deep-is": "^0.1.4",
Expand Down
7 changes: 4 additions & 3 deletions src/.solhint.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"extends": "solhint:recommended",
"rules": {
"max-line-length": ["error", 121],
"max-line-length": ["error", 128],
"compiler-version": ["error", "^0.8.0"],
"func-visibility": ["warn", {"ignoreConstructors": true}],
"func-visibility": ["warn", { "ignoreConstructors": true }],
"no-inline-assembly": "off",
"no-empty-blocks": "off",
"no-unused-vars": "error",
Expand All @@ -13,6 +13,7 @@
"max-states-count": "off",
"reason-string": "off",
"gas-custom-errors": "off",
"state-visibility": "error"
"state-visibility": "error",
"no-complex-fallback": "off"
}
}
25 changes: 19 additions & 6 deletions src/core/ClientGatewayLzReceiver.sol
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ abstract contract ClientGatewayLzReceiver is PausableUpgradeable, OAppReceiverUp
error DepositShouldNotFailOnExocore(address token, address depositor);
error InvalidAddWhitelistTokensRequest(uint256 expectedLength, uint256 actualLength);

// Events
event WithdrawFailedOnExocore(address indexed token, address indexed withdrawer);

modifier onlyCalledFromThis() {
require(
msg.sender == address(this),
Expand Down Expand Up @@ -111,14 +114,24 @@ abstract contract ClientGatewayLzReceiver is PausableUpgradeable, OAppReceiverUp

bool success = (uint8(bytes1(responsePayload[0])) == 1);
uint256 lastlyUpdatedPrincipalBalance = uint256(bytes32(responsePayload[1:33]));
if (success) {
IVault vault = _getVault(token);

vault.updatePrincipalBalance(withdrawer, lastlyUpdatedPrincipalBalance);
vault.updateWithdrawableBalance(withdrawer, unlockPrincipalAmount, 0);
}
if (!success) {
emit WithdrawFailedOnExocore(token, withdrawer);
} else {
if (token == VIRTUAL_STAKED_ETH_ADDRESS) {
IExoCapsule capsule = _getCapsule(withdrawer);

capsule.updatePrincipalBalance(lastlyUpdatedPrincipalBalance);
capsule.updateWithdrawableBalance(unlockPrincipalAmount);
} else {
IVault vault = _getVault(token);

emit WithdrawPrincipalResult(success, token, withdrawer, unlockPrincipalAmount);
vault.updatePrincipalBalance(withdrawer, lastlyUpdatedPrincipalBalance);
vault.updateWithdrawableBalance(withdrawer, unlockPrincipalAmount, 0);
}

emit WithdrawPrincipalResult(success, token, withdrawer, unlockPrincipalAmount);
}
}

function afterReceiveWithdrawRewardResponse(bytes memory requestPayload, bytes calldata responsePayload)
Expand Down
156 changes: 96 additions & 60 deletions src/core/ExoCapsule.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,45 @@ import {IExoCapsule} from "../interfaces/IExoCapsule.sol";

import {INativeRestakingController} from "../interfaces/INativeRestakingController.sol";
import {BeaconChainProofs} from "../libraries/BeaconChainProofs.sol";
import {Endian} from "../libraries/Endian.sol";
import {ValidatorContainer} from "../libraries/ValidatorContainer.sol";
import {WithdrawalContainer} from "../libraries/WithdrawalContainer.sol";
import {ExoCapsuleStorage} from "../storage/ExoCapsuleStorage.sol";

import {IBeaconChainOracle} from "@beacon-oracle/contracts/src/IBeaconChainOracle.sol";
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol";

contract ExoCapsule is Initializable, ExoCapsuleStorage, IExoCapsule {
contract ExoCapsule is ReentrancyGuardUpgradeable, ExoCapsuleStorage, IExoCapsule {

using BeaconChainProofs for bytes32;
using Endian for bytes32;
using ValidatorContainer for bytes32[];
using WithdrawalContainer for bytes32[];

event PrincipalBalanceUpdated(address, uint256);
event WithdrawableBalanceUpdated(address, uint256);
event WithdrawalSuccess(address, address, uint256);
/// @notice Emitted when a partial withdrawal claim is successfully redeemed
event PartialWithdrawalRedeemed(
bytes32 pubkey, uint256 withdrawalEpoch, address indexed recipient, uint64 partialWithdrawalAmountGwei
);
/// @notice Emitted when an ETH validator is prove to have fully withdrawn from the beacon chain
event FullWithdrawalRedeemed(
bytes32 pubkey, uint64 withdrawalEpoch, address indexed recipient, uint64 withdrawalAmountGwei
);
/// @notice Emitted when capsuleOwner enables restaking
event RestakingActivated(address indexed capsuleOwner);
/// @notice Emitted when ETH is received via the `receive` fallback
event NonBeaconChainETHReceived(uint256 amountReceived);
/// @notice Emitted when ETH that was previously received via the `receive` fallback is withdrawn
event NonBeaconChainETHWithdrawn(address indexed recipient, uint256 amountWithdrawn);

error InvalidValidatorContainer(bytes32 pubkey);
error InvalidWithdrawalContainer(uint64 validatorIndex);
error InvalidHistoricalSummaries(uint64 validatorIndex);
error DoubleDepositedValidator(bytes32 pubkey);
error StaleValidatorContainer(bytes32 pubkey, uint256 timestamp);
error WithdrawalAlreadyProven(bytes32 pubkey, uint256 timestamp);
error UnregisteredValidator(bytes32 pubkey);
error UnregisteredOrWithdrawnValidatorContainer(bytes32 pubkey);
error FullyWithdrawnValidatorContainer(bytes32 pubkey);
Expand All @@ -47,6 +65,11 @@ contract ExoCapsule is Initializable, ExoCapsuleStorage, IExoCapsule {
_disableInitializers();
}

receive() external payable {
nonBeaconChainETHBalance += msg.value;
emit NonBeaconChainETHReceived(msg.value);
}

function initialize(address gateway_, address capsuleOwner_, address beaconOracle_) external initializer {
require(gateway_ != address(0), "ExoCapsule: gateway address can not be empty");
require(capsuleOwner_ != address(0), "ExoCapsule: capsule owner address can not be empty");
Expand All @@ -55,11 +78,14 @@ contract ExoCapsule is Initializable, ExoCapsuleStorage, IExoCapsule {
gateway = INativeRestakingController(gateway_);
beaconOracle = IBeaconChainOracle(beaconOracle_);
capsuleOwner = capsuleOwner_;

emit RestakingActivated(capsuleOwner);
}

function verifyDepositProof(bytes32[] calldata validatorContainer, ValidatorContainerProof calldata proof)
external
onlyGateway
returns (uint256 depositAmount)
{
bytes32 validatorPubkey = validatorContainer.getPubkey();
bytes32 withdrawalCredentials = validatorContainer.getWithdrawalCredentials();
Expand Down Expand Up @@ -90,81 +116,90 @@ contract ExoCapsule is Initializable, ExoCapsuleStorage, IExoCapsule {
validator.status = VALIDATOR_STATUS.REGISTERED;
validator.validatorIndex = proof.validatorIndex;
validator.mostRecentBalanceUpdateTimestamp = proof.beaconBlockTimestamp;
validator.restakedBalanceGwei = validatorContainer.getEffectiveBalance();

_capsuleValidatorsByIndex[proof.validatorIndex] = validatorPubkey;
}

function verifyPartialWithdrawalProof(
bytes32[] calldata validatorContainer,
ValidatorContainerProof calldata validatorProof,
bytes32[] calldata withdrawalContainer,
WithdrawalContainerProof calldata withdrawalProof
) external view onlyGateway {
bytes32 validatorPubkey = validatorContainer.getPubkey();
uint64 withdrawableEpoch = validatorContainer.getWithdrawableEpoch();

bool partialWithdrawal = _timestampToEpoch(validatorProof.beaconBlockTimestamp) < withdrawableEpoch;

if (!validatorContainer.verifyValidatorContainerBasic()) {
revert InvalidValidatorContainer(validatorPubkey);
uint64 depositAmountGwei = validatorContainer.getEffectiveBalance();
if (depositAmountGwei > MAX_RESTAKED_BALANCE_GWEI_PER_VALIDATOR) {
validator.restakedBalanceGwei = MAX_RESTAKED_BALANCE_GWEI_PER_VALIDATOR;
depositAmount = MAX_RESTAKED_BALANCE_GWEI_PER_VALIDATOR * GWEI_TO_WEI;
} else {
validator.restakedBalanceGwei = depositAmountGwei;
depositAmount = depositAmountGwei * GWEI_TO_WEI;
}

if (!partialWithdrawal) {
revert NotPartialWithdrawal(validatorPubkey);
}

if (validatorProof.beaconBlockTimestamp != withdrawalProof.beaconBlockTimestamp) {
revert UnmatchedValidatorAndWithdrawal(validatorPubkey);
}

_verifyValidatorContainer(validatorContainer, validatorProof);
_verifyWithdrawalContainer(withdrawalContainer, withdrawalProof);
_capsuleValidatorsByIndex[proof.validatorIndex] = validatorPubkey;
}

function verifyFullWithdrawalProof(
function verifyWithdrawalProof(
bytes32[] calldata validatorContainer,
ValidatorContainerProof calldata validatorProof,
bytes32[] calldata withdrawalContainer,
WithdrawalContainerProof calldata withdrawalProof
) external onlyGateway {
BeaconChainProofs.WithdrawalProof calldata withdrawalProof
) external onlyGateway returns (bool partialWithdrawal, uint256 withdrawalAmount) {
bytes32 validatorPubkey = validatorContainer.getPubkey();
uint64 withdrawableEpoch = validatorContainer.getWithdrawableEpoch();

Validator storage validator = _capsuleValidators[validatorPubkey];
bool fullyWithdrawal = _timestampToEpoch(validatorProof.beaconBlockTimestamp) > withdrawableEpoch;
uint64 withdrawalEpoch = withdrawalProof.slotRoot.getWithdrawalEpoch();
partialWithdrawal = withdrawalEpoch < validatorContainer.getWithdrawableEpoch();

if (!validatorContainer.verifyValidatorContainerBasic()) {
revert InvalidValidatorContainer(validatorPubkey);
}

if (!fullyWithdrawal) {
revert NotPartialWithdrawal(validatorPubkey);
if (validator.status == VALIDATOR_STATUS.UNREGISTERED) {
revert UnregisteredOrWithdrawnValidatorContainer(validatorPubkey);
}

if (validatorProof.beaconBlockTimestamp != withdrawalProof.beaconBlockTimestamp) {
revert UnmatchedValidatorAndWithdrawal(validatorPubkey);
if (provenWithdrawal[validatorPubkey][withdrawalProof.withdrawalIndex]) {
revert WithdrawalAlreadyProven(validatorPubkey, withdrawalProof.withdrawalIndex);
}

provenWithdrawal[validatorPubkey][withdrawalProof.withdrawalIndex] = true;

_verifyValidatorContainer(validatorContainer, validatorProof);
_verifyWithdrawalContainer(withdrawalContainer, withdrawalProof);

validator.status = VALIDATOR_STATUS.WITHDRAWN;
uint64 withdrawalAmountGwei = withdrawalContainer.getAmount();

if (partialWithdrawal) {
// Immediately send ETH without sending request to Exocore side
emit PartialWithdrawalRedeemed(validatorPubkey, withdrawalEpoch, capsuleOwner, withdrawalAmountGwei);
_sendETH(capsuleOwner, withdrawalAmountGwei * GWEI_TO_WEI);
} else {
// Full withdrawal
validator.status = VALIDATOR_STATUS.WITHDRAWN;
validator.restakedBalanceGwei = 0;
// If over MAX_RESTAKED_BALANCE_GWEI_PER_VALIDATOR = 32 * 1e9, then send remaining amount immediately
emit FullWithdrawalRedeemed(validatorPubkey, withdrawalEpoch, capsuleOwner, withdrawalAmountGwei);
if (withdrawalAmountGwei > MAX_RESTAKED_BALANCE_GWEI_PER_VALIDATOR) {
uint256 amountToSend = (withdrawalAmountGwei - MAX_RESTAKED_BALANCE_GWEI_PER_VALIDATOR) * GWEI_TO_WEI;
_sendETH(capsuleOwner, amountToSend);
withdrawalAmount = MAX_RESTAKED_BALANCE_GWEI_PER_VALIDATOR * GWEI_TO_WEI;
} else {
withdrawalAmount = withdrawalAmountGwei * GWEI_TO_WEI;
}
}
}

function withdraw(uint256 amount, address payable recipient) external onlyGateway {
require(recipient != address(0), "ExoCapsule: recipient address cannot be zero or empty");
require(amount > 0 && amount <= withdrawableBalance, "ExoCapsule: invalid withdrawal amount");

withdrawableBalance -= amount;
(bool sent,) = recipient.call{value: amount}("");
if (!sent) {
revert WithdrawalFailure(capsuleOwner, recipient, amount);
}
_sendETH(recipient, amount);

emit WithdrawalSuccess(capsuleOwner, recipient, amount);
}

/// @notice Called by the capsule owner to withdraw the nonBeaconChainETHBalance
function withdrawNonBeaconChainETHBalance(address recipient, uint256 amountToWithdraw) external onlyGateway {
require(
amountToWithdraw <= nonBeaconChainETHBalance,
"ExoCapsule.withdrawNonBeaconChainETHBalance: amountToWithdraw is greater than nonBeaconChainETHBalance"
);
require(recipient != address(0), "ExoCapsule: recipient address cannot be zero or empty");

nonBeaconChainETHBalance -= amountToWithdraw;
_sendETH(recipient, amountToWithdraw);
emit NonBeaconChainETHWithdrawn(recipient, amountToWithdraw);
}

function updatePrincipalBalance(uint256 lastlyUpdatedPrincipalBalance) external onlyGateway {
principalBalance = lastlyUpdatedPrincipalBalance;

Expand Down Expand Up @@ -214,6 +249,14 @@ contract ExoCapsule is Initializable, ExoCapsuleStorage, IExoCapsule {
return validator;
}

// slither-disable-next-line arbitrary-send-eth
function _sendETH(address recipient, uint256 amountWei) internal nonReentrant {
(bool sent,) = recipient.call{value: amountWei}("");
if (!sent) {
revert WithdrawalFailure(capsuleOwner, recipient, amountWei);
}
}

function _verifyValidatorContainer(bytes32[] calldata validatorContainer, ValidatorContainerProof calldata proof)
internal
view
Expand All @@ -232,19 +275,13 @@ contract ExoCapsule is Initializable, ExoCapsuleStorage, IExoCapsule {
}
}

function _verifyWithdrawalContainer(bytes32[] calldata withdrawalContainer, WithdrawalContainerProof calldata proof)
internal
view
{
bytes32 beaconBlockRoot = getBeaconBlockRoot(proof.beaconBlockTimestamp);
function _verifyWithdrawalContainer(
bytes32[] calldata withdrawalContainer,
BeaconChainProofs.WithdrawalProof calldata proof
) internal view {
// To-do check withdrawalContainer length is valid
bytes32 withdrawalContainerRoot = withdrawalContainer.merklelizeWithdrawalContainer();
bool valid = withdrawalContainerRoot.isValidWithdrawalContainerRoot(
proof.withdrawalContainerRootProof,
proof.withdrawalIndex,
beaconBlockRoot,
proof.executionPayloadRoot,
proof.executionPayloadRootProof
);
bool valid = withdrawalContainerRoot.isValidWithdrawalContainerRoot(proof);
if (!valid) {
revert InvalidWithdrawalContainer(withdrawalContainer.getValidatorIndex());
}
Expand All @@ -257,9 +294,8 @@ contract ExoCapsule is Initializable, ExoCapsuleStorage, IExoCapsule {
{
uint64 atEpoch = _timestampToEpoch(atTimestamp);
uint64 activationEpoch = validatorContainer.getActivationEpoch();
uint64 exitEpoch = validatorContainer.getExitEpoch();

return (atEpoch >= activationEpoch && atEpoch < exitEpoch);
return atEpoch >= activationEpoch;
}

function _isStaleProof(Validator storage validator, uint256 proofTimestamp) internal view returns (bool) {
Expand Down
Loading

0 comments on commit a6dfa2d

Please sign in to comment.