diff --git a/contracts/0.8.25/vaults/PredepositGuardian.sol b/contracts/0.8.25/vaults/PredepositGuardian.sol new file mode 100644 index 000000000..052005184 --- /dev/null +++ b/contracts/0.8.25/vaults/PredepositGuardian.sol @@ -0,0 +1,302 @@ +// SPDX-FileCopyrightText: 2025 Lido +// SPDX-License-Identifier: GPL-3.0 + +// See contracts/COMPILERS.md +pragma solidity 0.8.25; + +import {MerkleProof} from "@openzeppelin/contracts-v5.2/utils/cryptography/MerkleProof.sol"; + +import {StakingVault} from "./StakingVault.sol"; +import {IDepositContract} from "../interfaces/IDepositContract.sol"; + +contract PredepositGuardian { + uint256 public constant PREDEPOSIT_AMOUNT = 1 ether; + + enum ValidatorStatus { + NO_RECORD, + AWAITING_PROOF, + PROVED, + PROVED_INVALID, + WITHDRAWN + } + + // See `BEACON_ROOTS_ADDRESS` constant in the EIP-4788. + address public constant BEACON_ROOTS = 0x000F3df6D732807Ef1319fB7B8bB8522d0Beac02; + + mapping(address nodeOperator => uint256) public nodeOperatorCollateral; + mapping(address nodeOperator => uint256) public nodeOperatorCollateralLocked; + mapping(address nodeOperator => address delegate) public nodeOperatorDelegate; + + mapping(bytes32 validatorPubkeyHash => ValidatorStatus validatorStatus) public validatorStatuses; + mapping(bytes32 validatorPubkeyHash => StakingVault) public validatorStakingVault; + // node operator can be taken from vault,but this prevents malicious vault from changing node operator midflight + mapping(bytes32 validatorPubkeyHash => address nodeOperator) public validatorToNodeOperator; + + /// views + + function nodeOperatorBalance(address nodeOperator) external view returns (uint256, uint256) { + return (nodeOperatorCollateral[nodeOperator], nodeOperatorCollateralLocked[nodeOperator]); + } + + /// NO Balance operations + + function topUpNodeOperatorCollateral(address _nodeOperator) external payable { + if (msg.value == 0) revert ZeroArgument("msg.value"); + _topUpNodeOperatorCollateral(_nodeOperator); + } + + function withdrawNodeOperatorCollateral(address _nodeOperator, uint256 _amount, address _recipient) external { + if (_amount == 0) revert ZeroArgument("amount"); + if (_nodeOperator == address(0)) revert ZeroArgument("_nodeOperator"); + + _isValidNodeOperatorCaller(_nodeOperator); + + if (nodeOperatorCollateral[_nodeOperator] - nodeOperatorCollateralLocked[_nodeOperator] >= _amount) + revert NotEnoughUnlockedCollateralToWithdraw(); + + nodeOperatorCollateral[_nodeOperator] -= _amount; + (bool success, ) = _recipient.call{value: _amount}(""); + + if (!success) revert WithdrawalFailed(); + + // TODO: event + } + + // delegation + + function delegateNodeOperator(address _delegate) external { + nodeOperatorDelegate[msg.sender] = _delegate; + // TODO: event + } + + /// Deposit operations + + function predeposit(StakingVault _stakingVault, StakingVault.Deposit[] calldata _deposits) external payable { + if (_deposits.length == 0) revert PredepositNoDeposits(); + + address _nodeOperator = _stakingVault.nodeOperator(); + _isValidNodeOperatorCaller(_nodeOperator); + + // optional top up + if (msg.value != 0) { + _topUpNodeOperatorCollateral(_nodeOperator); + } + + uint256 unlockedCollateral = nodeOperatorCollateral[_nodeOperator] - + nodeOperatorCollateralLocked[_nodeOperator]; + + uint256 totalDepositAmount = PREDEPOSIT_AMOUNT * _deposits.length; + + if (unlockedCollateral < totalDepositAmount) revert NotEnoughUnlockedCollateralToPredeposit(); + + for (uint256 i = 0; i < _deposits.length; i++) { + StakingVault.Deposit calldata _deposit = _deposits[i]; + + bytes32 validatorId = keccak256(_deposit.pubkey); + + if (validatorStatuses[validatorId] != ValidatorStatus.NO_RECORD) { + revert MustBeNewValidatorPubkey(); + } + + // cannot predeposit a validator with a deposit amount that is not 1 ether + if (_deposit.amount != PREDEPOSIT_AMOUNT) revert PredepositDepositAmountInvalid(); + + validatorStatuses[validatorId] = ValidatorStatus.AWAITING_PROOF; + validatorStakingVault[validatorId] = _stakingVault; + validatorToNodeOperator[validatorId] = _nodeOperator; + } + + nodeOperatorCollateralLocked[_nodeOperator] += totalDepositAmount; + _stakingVault.depositToBeaconChain(_deposits); + // TODO: event + } + + function proveValidatorPreDeposit( + StakingVault.Deposit calldata _deposit, + bytes32[] calldata proof, + uint64 beaconBlockTimestamp + ) external { + bytes32 validatorId = keccak256(_deposit.pubkey); + // check that the validator is predeposited + if (validatorStatuses[validatorId] != ValidatorStatus.AWAITING_PROOF) { + revert ValidatorNotPreDeposited(); + } + + // NB! this is potential attack vector, what if the staking vault is malicious + // it can change WC to block node operator from bringing proof + // we could check if staking vault must always have wc to it's own address is invariant + _validateDepositDataRoot(_deposit, validatorStakingVault[validatorId].withdrawalCredentials()); + + // check that predeposit was made to the staking vault in proof + _validateProof(proof, _deposit.depositDataRoot, beaconBlockTimestamp); + + nodeOperatorCollateralLocked[validatorToNodeOperator[validatorId]] -= PREDEPOSIT_AMOUNT; + validatorStatuses[validatorId] = ValidatorStatus.PROVED; + + // TODO: event + } + + function proveInvalidValidatorPreDeposit( + StakingVault.Deposit calldata _deposit, + bytes32 _invalidWC, + bytes32[] calldata proof, + uint64 beaconBlockTimestamp + ) external { + bytes32 _validatorId = keccak256(_deposit.pubkey); + + // check that the validator is predeposited + if (validatorStatuses[_validatorId] != ValidatorStatus.AWAITING_PROOF) { + revert ValidatorNotPreDeposited(); + } + + _validateDepositDataRoot(_deposit, _invalidWC); + + // NB! this is potential attack vector, if the staking vault is malicious + // it can change WC to steal from the node operator + // alt check if staking vault must always have wc to it's own address is invariant + //if (address(validatorStakingVault[_validatorId]) == _wcToAddress(_invalidWC)) { + if (validatorStakingVault[_validatorId].withdrawalCredentials() == _invalidWC) { + revert WithdrawalCredentialsAreValid(); + } + + _validateProof(proof, _deposit.depositDataRoot, beaconBlockTimestamp); + + validatorStatuses[_validatorId] = ValidatorStatus.PROVED_INVALID; + + // TODO: event + } + + function depositToProvenValidators( + StakingVault _stakingVault, + StakingVault.Deposit[] calldata _deposits + ) external payable { + _isValidNodeOperatorCaller(_stakingVault.nodeOperator()); + + for (uint256 i = 0; i < _deposits.length; i++) { + StakingVault.Deposit calldata _deposit = _deposits[i]; + bytes32 _validatorId = keccak256(_deposit.pubkey); + + if (validatorStatuses[_validatorId] != ValidatorStatus.PROVED) { + revert DepositToUnprovenValidator(); + } + + if (validatorStakingVault[_validatorId] != _stakingVault) { + revert DepositToWrongVault(); + } + } + + _stakingVault.depositToBeaconChain(_deposits); + } + + // called by the staking vault owner if the predeposited validator has a different withdrawal credentials than the vault's withdrawal credentials, + // i.e. node operator was malicious + function withdrawDisprovenCollateral(bytes32 _validatorId, address _recipient) external { + if (_recipient == address(0)) revert ZeroArgument("_recipient"); + + address _nodeOperator = validatorToNodeOperator[_validatorId]; + if (validatorStatuses[_validatorId] != ValidatorStatus.PROVED_INVALID) revert ValidatorNotProvenInvalid(); + + if (msg.sender != validatorStakingVault[_validatorId].owner()) revert WithdrawSenderNotStakingVaultOwner(); + + nodeOperatorCollateralLocked[_nodeOperator] -= PREDEPOSIT_AMOUNT; + nodeOperatorCollateral[_nodeOperator] -= PREDEPOSIT_AMOUNT; + validatorStatuses[_validatorId] = ValidatorStatus.WITHDRAWN; + + (bool success, ) = _recipient.call{value: PREDEPOSIT_AMOUNT}(""); + if (!success) revert WithdrawalFailed(); + + //TODO: events + } + + /// Internal functions + + function _validateProof( + bytes32[] calldata _proof, + bytes32 _depositDataRoot, + uint64 beaconBlockTimestamp + ) internal view { + if (!MerkleProof.verifyCalldata(_proof, _getParentBlockRoot(beaconBlockTimestamp), _depositDataRoot)) + revert InvalidProof(); + } + + function _topUpNodeOperatorCollateral(address _nodeOperator) internal { + if (_nodeOperator == address(0)) revert ZeroArgument("_nodeOperator"); + nodeOperatorCollateral[_nodeOperator] += msg.value; + // TODO: event + } + + function _isValidNodeOperatorCaller(address _nodeOperator) internal view { + if (msg.sender != _nodeOperator && nodeOperatorDelegate[_nodeOperator] != msg.sender) + revert MustBeNodeOperatorOrDelegate(); + } + + function _getParentBlockRoot(uint64 blockTimestamp) internal view returns (bytes32) { + (bool success, bytes memory data) = BEACON_ROOTS.staticcall(abi.encode(blockTimestamp)); + + if (!success || data.length == 0) { + revert RootNotFound(); + } + + return abi.decode(data, (bytes32)); + } + + function _validateDepositDataRoot(StakingVault.Deposit calldata _deposit, bytes32 _invalidWC) internal pure { + bytes32 pubkey_root = sha256(abi.encodePacked(_deposit.pubkey, bytes16(0))); + bytes32 signature_root = sha256( + abi.encodePacked( + sha256(abi.encodePacked(_deposit.signature[:64])), + sha256(abi.encodePacked(_deposit.signature[64:], bytes32(0))) + ) + ); + bytes32 node = sha256( + abi.encodePacked( + sha256(abi.encodePacked(pubkey_root, _invalidWC)), + sha256(abi.encodePacked(_deposit.amount, bytes24(0), signature_root)) + ) + ); + + if (_deposit.depositDataRoot != node) { + revert InvalidDepositRoot(); + } + } + + function _wcToAddress(bytes32 _withdrawalCredentials) internal pure returns (address) { + return address(uint160(uint256(_withdrawalCredentials))); + } + + // predeposit errors + error PredepositNoDeposits(); + error PredepositValueNotMultipleOfPrediposit(); + error PredepositDepositAmountInvalid(); + error MustBeNewValidatorPubkey(); + error NotEnoughUnlockedCollateralToPredeposit(); + + // proving errors + error ValidatorNotPreDeposited(); + error RootNotFound(); + error InvalidProof(); + error InvalidDepositRoot(); + + // depositing errors + error DepositToUnprovenValidator(); + error DepositToWrongVault(); + + // withdrawal proven + error NotEnoughUnlockedCollateralToWithdraw(); + + // withdrawal disproven + error ValidatorNotProvenInvalid(); + error WithdrawSenderNotStakingVaultOwner(); + error WithdrawSenderNotNodeOperator(); + error WithdrawValidatorDoesNotBelongToNodeOperator(); + error WithdrawalCollateralOfWrongVault(); + error WithdrawalCredentialsAreValid(); + /// withdrawal genereic + error WithdrawalFailed(); + + // auth + error MustBeNodeOperatorOrDelegate(); + + // general + error ZeroArgument(string argument); +} diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index 936d55ee1..30bb0343d 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -5,7 +5,6 @@ pragma solidity 0.8.25; import {OwnableUpgradeable} from "contracts/openzeppelin/5.2/upgradeable/access/OwnableUpgradeable.sol"; -import {SignatureChecker} from "@openzeppelin/contracts-v5.2/utils/cryptography/SignatureChecker.sol"; import {VaultHub} from "./VaultHub.sol"; @@ -68,6 +67,7 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { uint128 locked; int128 inOutDelta; address nodeOperator; + // depositGuardian becomes the depositor, instead of just guardian, perhaps a renaming is needed 🌚 address depositGuardian; bool beaconChainDepositsPaused; } @@ -318,30 +318,23 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { * @param _deposits Array of deposit structs * @dev Includes a check to ensure StakingVault is balanced before making deposits */ - function depositToBeaconChain( - Deposit[] calldata _deposits, - bytes32 _expectedGlobalDepositRoot, - bytes calldata _signature - ) external { + function depositToBeaconChain(Deposit[] calldata _deposits) external { if (_deposits.length == 0) revert ZeroArgument("_deposits"); - - bytes32 currentGlobalDepositRoot = BEACON_CHAIN_DEPOSIT_CONTRACT.get_deposit_root(); - if (_expectedGlobalDepositRoot != currentGlobalDepositRoot) - revert GlobalDepositRootMismatch(_expectedGlobalDepositRoot, currentGlobalDepositRoot); + if (!isBalanced()) revert Unbalanced(); ERC7201Storage storage $ = _getStorage(); - if (msg.sender != $.nodeOperator) revert NotAuthorized("depositToBeaconChain", msg.sender); if ($.beaconChainDepositsPaused) revert BeaconChainDepositsArePaused(); - if (!isBalanced()) revert Unbalanced(); + if (msg.sender != $.depositGuardian) revert NotAuthorized("depositToBeaconChain", msg.sender); - uint256 totalAmount = 0; uint256 numberOfDeposits = _deposits.length; - // XOR is a commutative operation, so the aggregate root will be the same regardless of the order of deposits - bytes32 depositDataBatchXorRoot; + + uint256 totalAmount = 0; for (uint256 i = 0; i < numberOfDeposits; i++) { Deposit calldata deposit = _deposits[i]; + //TODO: check BLS signature + // check deposit data root BEACON_CHAIN_DEPOSIT_CONTRACT.deposit{value: deposit.amount}( deposit.pubkey, bytes.concat(withdrawalCredentials()), @@ -350,23 +343,8 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { ); totalAmount += deposit.amount; - depositDataBatchXorRoot ^= keccak256(abi.encodePacked(deposit.depositDataRoot)); } - if ( - !SignatureChecker.isValidSignatureNow( - $.depositGuardian, - keccak256( - abi.encodePacked( - DEPOSIT_GUARDIAN_MESSAGE_PREFIX, - _expectedGlobalDepositRoot, - depositDataBatchXorRoot - ) - ), - _signature - ) - ) revert DepositGuardianSignatureInvalid(); - emit DepositedToBeaconChain(msg.sender, numberOfDeposits, totalAmount); } diff --git a/contracts/0.8.25/vaults/interfaces/IStakingVault.sol b/contracts/0.8.25/vaults/interfaces/IStakingVault.sol index bcdfc4e7d..3fa1ae91f 100644 --- a/contracts/0.8.25/vaults/interfaces/IStakingVault.sol +++ b/contracts/0.8.25/vaults/interfaces/IStakingVault.sol @@ -57,11 +57,7 @@ interface IStakingVault { function withdraw(address _recipient, uint256 _ether) external; - function depositToBeaconChain( - Deposit[] calldata _deposits, - bytes32 _expectedGlobalDepositRoot, - bytes calldata _guardianSignature - ) external; + function depositToBeaconChain(Deposit[] calldata _deposits) external; function requestValidatorExit(bytes calldata _pubkeys) external; diff --git a/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol b/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol index 9d28a7dea..2ad17dd8a 100644 --- a/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol +++ b/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol @@ -84,11 +84,7 @@ contract StakingVault__HarnessForTestUpgrade is IStakingVault, OwnableUpgradeabl } } - function depositToBeaconChain( - Deposit[] calldata _deposits, - bytes32 _expectedGlobalDepositRoot, - bytes calldata _guardianSignature - ) external {} + function depositToBeaconChain(Deposit[] calldata _deposits) external {} function fund() external payable {}