From 640944a916c9836fed6146c54954133b2a6f6fe1 Mon Sep 17 00:00:00 2001 From: Shawn <44221603+shaspitz@users.noreply.github.com> Date: Fri, 13 Jun 2025 13:18:19 -0700 Subject: [PATCH 1/2] contract + tests --- .../interfaces/IVanillaRegistryV2.sol | 163 +++++++ .../VanillaRegistryStorageV2.sol | 34 ++ .../validator-registry/VanillaRegistryV2.sol | 437 ++++++++++++++++++ .../ValidatorOptInRouterTest.sol | 2 +- .../VanillaRegistryTest.sol | 133 +++++- 5 files changed, 762 insertions(+), 7 deletions(-) create mode 100644 contracts/contracts/interfaces/IVanillaRegistryV2.sol create mode 100644 contracts/contracts/validator-registry/VanillaRegistryStorageV2.sol create mode 100644 contracts/contracts/validator-registry/VanillaRegistryV2.sol diff --git a/contracts/contracts/interfaces/IVanillaRegistryV2.sol b/contracts/contracts/interfaces/IVanillaRegistryV2.sol new file mode 100644 index 000000000..6a51ee03d --- /dev/null +++ b/contracts/contracts/interfaces/IVanillaRegistryV2.sol @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: BSL 1.1 +pragma solidity 0.8.26; + +import { BlockHeightOccurrence } from "../utils/Occurrence.sol"; + +/// @title IVanillaRegistryV2 +/// @notice Interface for the VanillaRegistryV2 contract for validators. +interface IVanillaRegistryV2 { + + /// @dev Struct representing a validator staked with the registry. + struct StakedValidator { + bool exists; + address withdrawalAddress; + uint256 balance; + BlockHeightOccurrence.Occurrence unstakeOccurrence; + } + + /// @dev Event emitted when a validator is staked. + event Staked(address indexed msgSender, address indexed withdrawalAddress, bytes valBLSPubKey, uint256 amount); + + /// @dev Event emitted when ETH is added to the staked balance a validator. + event StakeAdded(address indexed msgSender, address indexed withdrawalAddress, bytes valBLSPubKey, uint256 amount, uint256 newBalance); + + /// @dev Event emitted when a validator is unstaked. + event Unstaked(address indexed msgSender, address indexed withdrawalAddress, bytes valBLSPubKey, uint256 amount); + + /// @dev Event emitted when a validator's stake is withdrawn. + event StakeWithdrawn(address indexed msgSender, address indexed withdrawalAddress, bytes valBLSPubKey, uint256 amount); + + /// @dev Event emitted when total stake is withdrawn. + event TotalStakeWithdrawn(address indexed msgSender, address indexed withdrawalAddress, uint256 totalAmount); + + /// @dev Event emitted when a validator is slashed. + event Slashed(address indexed msgSender, address indexed slashReceiver, address indexed withdrawalAddress, bytes valBLSPubKey, uint256 amount); + + /// @dev Event emitted when the min stake parameter is set. + event MinStakeSet(address indexed msgSender, uint256 newMinStake); + + /// @dev Event emitted when the slash oracle parameter is set. + event SlashOracleSet(address indexed msgSender, address newSlashOracle); + + /// @dev Event emitted when the slash receiver parameter is set. + event SlashReceiverSet(address indexed msgSender, address newSlashReceiver); + + /// @dev Event emitted when the unstake period blocks parameter is set. + event UnstakePeriodBlocksSet(address indexed msgSender, uint256 newUnstakePeriodBlocks); + + /// @dev Event emitted when the slashing payout period blocks parameter is set. + event SlashingPayoutPeriodBlocksSet(address indexed msgSender, uint256 newSlashingPayoutPeriodBlocks); + + /// @dev Event emitted when a staker is whitelisted. + event StakerWhitelisted(address indexed msgSender, address staker); + + /// @dev Event emitted when a staker is removed from the whitelist. + event StakerRemovedFromWhitelist(address indexed msgSender, address staker); + + error ValidatorRecordMustExist(bytes valBLSPubKey); + error ValidatorRecordMustNotExist(bytes valBLSPubKey); + error ValidatorCannotBeUnstaking(bytes valBLSPubKey); + error SenderIsNotWithdrawalAddress(address sender, address withdrawalAddress); + error InvalidBLSPubKeyLength(uint256 expected, uint256 actual); + error SenderIsNotSlashOracle(address sender, address slashOracle); + error WithdrawalAddressMustBeSet(); + error MustUnstakeToWithdraw(); + error AtLeastOneRecipientRequired(); + error StakeTooLowForNumberOfKeys(uint256 msgValue, uint256 required); + error WithdrawingTooSoon(); + error WithdrawalAddressMismatch(address actualWithdrawalAddress, address expectedWithdrawalAddress); + error WithdrawalFailed(); + error NoFundsToWithdraw(); + error SlashingTransferFailed(); + error MinStakeMustBePositive(); + error SlashAmountMustBePositive(); + error SlashAmountMustBeLessThanMinStake(); + error SlashOracleMustBeSet(); + error SlashReceiverMustBeSet(); + error UnstakePeriodMustBePositive(); + error SlashingPayoutPeriodMustBePositive(); + error SenderIsNotWhitelistedStaker(address sender); + error StakerAlreadyWhitelisted(address staker); + error StakerNotWhitelisted(address staker); + + /// @dev Initializes the contract with the provided parameters. + function initialize( + uint256 _minStake, + address _slashOracle, + address _slashReceiver, + uint256 _unstakePeriodBlocks, + uint256 _slashingPayoutPeriodBlocks, + address _owner + ) external; + + /* + * @dev Stakes ETH on behalf of one or multiple validators via their BLS pubkey. + * @param blsPubKeys The validator BLS public keys to stake. + */ + function stake(bytes[] calldata blsPubKeys) external payable; + + /* + * @dev Stakes ETH on behalf of one or multiple validators via their BLS pubkey, + * and specifies an address other than msg.sender to be the withdrawal address. + * @param blsPubKeys The validator BLS public keys to stake. + * @param withdrawalAddress The address to receive the staked ETH. + */ + function delegateStake(bytes[] calldata blsPubKeys, address withdrawalAddress) external payable; + + /* + * @dev Adds ETH to the staked balance of one or multiple validators via their BLS pubkey. + * @dev A staking entry must already exist for each provided BLS pubkey. + * @param blsPubKeys The BLS public keys to add stake to. + */ + function addStake(bytes[] calldata blsPubKeys) external payable; + + /* + * @dev Unstakes ETH on behalf of one or multiple validators via their BLS pubkey. + * @param blsPubKeys The BLS public keys to unstake. + */ + function unstake(bytes[] calldata blsPubKeys) external; + + /* + * @dev Withdraws ETH on behalf of one or multiple validators via their BLS pubkey. + * @param blsPubKeys The BLS public keys to withdraw. + */ + function withdraw(bytes[] calldata blsPubKeys) external; + + /// @dev Allows oracle to slash some portion of stake for one or multiple validators via their BLS pubkey. + /// @param blsPubKeys The BLS public keys to slash. + /// @param payoutIfDue Whether to payout slashed funds to receiver if the payout period is due. + function slash(bytes[] calldata blsPubKeys, bool payoutIfDue) external; + + /// @dev Enables the owner to pause the contract. + function pause() external; + + /// @dev Enables the owner to unpause the contract. + function unpause() external; + + /// @dev Enables the owner to set the minimum stake parameter. + function setMinStake(uint256 newMinStake) external; + + /// @dev Enables the owner to set the slash oracle parameter. + function setSlashOracle(address newSlashOracle) external; + + /// @dev Enables the owner to set the slash receiver parameter. + function setSlashReceiver(address newSlashReceiver) external; + + /// @dev Enables the owner to set the unstake period parameter. + function setUnstakePeriodBlocks(uint256 newUnstakePeriodBlocks) external; + + /// @dev Returns true if a validator is considered "opted-in" to mev-commit via this registry. + function isValidatorOptedIn(bytes calldata valBLSPubKey) external view returns (bool); + + /// @dev Returns stored staked validator struct for a given BLS pubkey. + function getStakedValidator(bytes calldata valBLSPubKey) external view returns (StakedValidator memory); + + /// @dev Returns the staked amount for a given BLS pubkey. + function getStakedAmount(bytes calldata valBLSPubKey) external view returns (uint256); + + /// @dev Returns true if a validator is currently unstaking. + function isUnstaking(bytes calldata valBLSPubKey) external view returns (bool); + + /// @dev Returns the number of blocks remaining until an unstaking validator can withdraw their staked ETH. + function getBlocksTillWithdrawAllowed(bytes calldata valBLSPubKey) external view returns (uint256); +} diff --git a/contracts/contracts/validator-registry/VanillaRegistryStorageV2.sol b/contracts/contracts/validator-registry/VanillaRegistryStorageV2.sol new file mode 100644 index 000000000..746141067 --- /dev/null +++ b/contracts/contracts/validator-registry/VanillaRegistryStorageV2.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: BSL 1.1 +pragma solidity 0.8.26; + +import {IVanillaRegistryV2} from "../interfaces/IVanillaRegistryV2.sol"; +import {FeePayout} from "../utils/FeePayout.sol"; + +/// @title VanillaRegistryStorageV2 +/// @notice Storage components of the VanillaRegistryV2 contract. +contract VanillaRegistryStorageV2 { + + /// @dev Minimum stake required for validators, also used as the slash amount. + uint256 public minStake; + + /// @dev Permissioned account that is able to invoke slashes. + address public slashOracle; + + /// @dev Number of blocks required between unstake initiation and withdrawal. + uint256 public unstakePeriodBlocks; + + /// @dev Struct enabling automatic slashing funds payouts + FeePayout.Tracker public slashingFundsTracker; + + /// @dev Mapping of BLS pubkeys to stored staked validator structs. + mapping(bytes => IVanillaRegistryV2.StakedValidator) public stakedValidators; + + /// @dev Mapping of withdrawal addresses to claimable ETH that was force withdrawn by the owner. + mapping(address withdrawalAddress => uint256 amountToClaim) public forceWithdrawnFunds; + + /// @dev Mapping of staker addresses to whether they are whitelisted. + mapping(address staker => bool whitelisted) public whitelistedStakers; + + /// @dev See https://docs.openzeppelin.com/upgrades-plugins/1.x/writing-upgradeable#storage-gaps + uint256[48] private __gap; +} diff --git a/contracts/contracts/validator-registry/VanillaRegistryV2.sol b/contracts/contracts/validator-registry/VanillaRegistryV2.sol new file mode 100644 index 000000000..4e55f05e8 --- /dev/null +++ b/contracts/contracts/validator-registry/VanillaRegistryV2.sol @@ -0,0 +1,437 @@ +// SPDX-License-Identifier: BSL 1.1 +pragma solidity 0.8.26; + +import {IVanillaRegistryV2} from "../interfaces/IVanillaRegistryV2.sol"; +import {VanillaRegistryStorageV2} from "./VanillaRegistryStorageV2.sol"; +import {BlockHeightOccurrence} from "../utils/Occurrence.sol"; +import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; +import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {Errors} from "../utils/Errors.sol"; +import {FeePayout} from "../utils/FeePayout.sol"; + +/// @title Vanilla Registry V2 +/// @notice Logic contract enabling L1 validators to opt-in to mev-commit +/// via simply staking ETH outside what's staked with the beacon chain. +contract VanillaRegistryV2 is IVanillaRegistryV2, VanillaRegistryStorageV2, + Ownable2StepUpgradeable, PausableUpgradeable, UUPSUpgradeable { + + /// @dev Modifier to confirm all provided BLS pubkeys are valid length. + modifier onlyValidBLSPubKeys(bytes[] calldata blsPubKeys) { + uint256 len = blsPubKeys.length; + for (uint256 i = 0; i < len; ++i) { + require(blsPubKeys[i].length == 48, IVanillaRegistryV2.InvalidBLSPubKeyLength(48, blsPubKeys[i].length)); + } + _; + } + + /// @dev Modifier to confirm the sender is the oracle account. + modifier onlySlashOracle() { + require(msg.sender == slashOracle, IVanillaRegistryV2.SenderIsNotSlashOracle(msg.sender, slashOracle)); + _; + } + + /// @dev Modifier to confirm the sender is whitelisted. + modifier onlyWhitelistedStaker() { + require(whitelistedStakers[msg.sender], IVanillaRegistryV2.SenderIsNotWhitelistedStaker(msg.sender)); + _; + } + + /// @dev See https://docs.openzeppelin.com/upgrades-plugins/1.x/writing-upgradeable#initializing_the_implementation_contract + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @dev Receive function is disabled for this contract to prevent unintended interactions. + receive() external payable { + revert Errors.InvalidReceive(); + } + + /// @dev Fallback function to revert all calls, ensuring no unintended interactions. + fallback() external payable { + revert Errors.InvalidFallback(); + } + + /// @dev Initializes the contract with the provided parameters. + function initialize( + uint256 _minStake, + address _slashOracle, + address _slashReceiver, + uint256 _unstakePeriodBlocks, + uint256 _slashingPayoutPeriodBlocks, + address _owner + ) external initializer { + __Pausable_init(); + _setMinStake(_minStake); + _setSlashOracle(_slashOracle); + _setUnstakePeriodBlocks(_unstakePeriodBlocks); + FeePayout.init(slashingFundsTracker, _slashReceiver, _slashingPayoutPeriodBlocks); + __Ownable_init(_owner); + } + + /* + * @dev Stakes ETH on behalf of one or multiple validators via their BLS pubkey. + * @param blsPubKeys The validator BLS public keys to stake. + */ + function stake(bytes[] calldata blsPubKeys) external payable + onlyValidBLSPubKeys(blsPubKeys) onlyWhitelistedStaker() whenNotPaused() { + _stake(blsPubKeys, msg.sender); + } + + /* + * @dev Stakes ETH on behalf of one or multiple validators via their BLS pubkey, + * and specifies an address other than msg.sender to be the withdrawal address. + * @param blsPubKeys The validator BLS public keys to stake. + * @param withdrawalAddress The address to receive the staked ETH. + */ + function delegateStake(bytes[] calldata blsPubKeys, address withdrawalAddress) external payable + onlyValidBLSPubKeys(blsPubKeys) onlyOwner { + require(withdrawalAddress != address(0), IVanillaRegistryV2.WithdrawalAddressMustBeSet()); + _stake(blsPubKeys, withdrawalAddress); + } + + /* + * @dev Adds ETH to the staked balance of one or multiple validators via their BLS pubkey. + * @dev A staking entry must already exist for each provided BLS pubkey. + * @param blsPubKeys The BLS public keys to add stake to. + */ + function addStake(bytes[] calldata blsPubKeys) external payable onlyWhitelistedStaker() whenNotPaused() { + _addStake(blsPubKeys); + } + + /* + * @dev Unstakes ETH on behalf of one or multiple validators via their BLS pubkey. + * @param blsPubKeys The BLS public keys to unstake. + */ + function unstake(bytes[] calldata blsPubKeys) external whenNotPaused() { + _unstake(blsPubKeys); + } + + /// @dev Allows owner to withdraw ETH on behalf of one or multiple validators via their BLS pubkey. + /// @param blsPubKeys The BLS public keys to withdraw. + /// @dev msg.sender must be the withdrawal address for every provided validator pubkey as enforced in _withdraw. + function withdraw(bytes[] calldata blsPubKeys) external whenNotPaused() { + uint256 totalAmount = _withdraw(blsPubKeys, msg.sender); + if (totalAmount != 0) { + (bool success, ) = msg.sender.call{value: totalAmount}(""); + require(success, IVanillaRegistryV2.WithdrawalFailed()); + } + } + + /// @dev Allows owner to withdraw ETH on behalf of one or multiple validators via their BLS pubkey. + /// @param blsPubKeys The BLS public keys to withdraw. + /// @param withdrawalAddress The address to receive the staked ETH. + /// @dev withdrawalAddress must be the withdrawal address for every provided validator pubkeyas enforced in _withdraw. + function forceWithdrawalAsOwner(bytes[] calldata blsPubKeys, address withdrawalAddress) external onlyOwner { + uint256 totalAmount = _withdraw(blsPubKeys, withdrawalAddress); + if (totalAmount != 0) { + forceWithdrawnFunds[withdrawalAddress] += totalAmount; + } + } + + /// @dev Allows a withdrawal address to claim any ETH that was force withdrawn by the owner. + function claimForceWithdrawnFunds() external { + uint256 amountToClaim = forceWithdrawnFunds[msg.sender]; + require(amountToClaim != 0, IVanillaRegistryV2.NoFundsToWithdraw()); + forceWithdrawnFunds[msg.sender] = 0; + (bool success, ) = msg.sender.call{value: amountToClaim}(""); + require(success, IVanillaRegistryV2.WithdrawalFailed()); + } + + /// @dev Allows oracle to slash some portion of stake for one or multiple validators via their BLS pubkey. + /// @param blsPubKeys The BLS public keys to slash. + /// @param payoutIfDue Whether to payout slashed funds to receiver if the payout period is due. + function slash(bytes[] calldata blsPubKeys, bool payoutIfDue) external onlySlashOracle whenNotPaused() { + _slash(blsPubKeys, payoutIfDue); + } + + /// @dev Enables the owner to pause the contract. + function pause() external onlyOwner { + _pause(); + } + + /// @dev Enables the owner to unpause the contract. + function unpause() external onlyOwner { + _unpause(); + } + + /// @dev Enables the owner to set the minimum stake parameter. + function setMinStake(uint256 newMinStake) external onlyOwner { + _setMinStake(newMinStake); + } + + /// @dev Enables the owner to set the slash oracle parameter. + function setSlashOracle(address newSlashOracle) external onlyOwner { + _setSlashOracle(newSlashOracle); + } + + /// @dev Enables the owner to set the slash receiver parameter. + function setSlashReceiver(address newSlashReceiver) external onlyOwner { + _setSlashReceiver(newSlashReceiver); + } + + /// @dev Enables the owner to set the unstake period parameter. + function setUnstakePeriodBlocks(uint256 newUnstakePeriodBlocks) external onlyOwner { + _setUnstakePeriodBlocks(newUnstakePeriodBlocks); + } + + /// @dev Enables the owner to set the slashing payout period parameter. + function setSlashingPayoutPeriodBlocks(uint256 newSlashingPayoutPeriodBlocks) external onlyOwner { + _setSlashingPayoutPeriodBlocks(newSlashingPayoutPeriodBlocks); + } + + /// @dev Enables the owner to manually transfer slashing funds. + function manuallyTransferSlashingFunds() external onlyOwner { + FeePayout.transferToRecipient(slashingFundsTracker); + } + + /// @dev Enables the owner to whitelist stakers. + function whitelistStakers(address[] calldata stakers) external onlyOwner { + uint256 len = stakers.length; + for (uint256 i = 0; i < len; ++i) { + require(!whitelistedStakers[stakers[i]], IVanillaRegistryV2.StakerAlreadyWhitelisted(stakers[i])); + whitelistedStakers[stakers[i]] = true; + emit StakerWhitelisted(msg.sender, stakers[i]); + } + } + + /// @dev Enables the owner to remove stakers from the whitelist. + function removeWhitelistedStakers(address[] calldata stakers) external onlyOwner { + uint256 len = stakers.length; + for (uint256 i = 0; i < len; ++i) { + require(whitelistedStakers[stakers[i]], IVanillaRegistryV2.StakerNotWhitelisted(stakers[i])); + whitelistedStakers[stakers[i]] = false; + emit StakerRemovedFromWhitelist(msg.sender, stakers[i]); + } + } + + /// @dev Returns true if a validator is considered "opted-in" to mev-commit via this registry. + function isValidatorOptedIn(bytes calldata valBLSPubKey) external view returns (bool) { + return _isValidatorOptedIn(valBLSPubKey); + } + + /// @dev Returns stored staked validator struct for a given BLS pubkey. + function getStakedValidator(bytes calldata valBLSPubKey) external view returns (StakedValidator memory) { + return stakedValidators[valBLSPubKey]; + } + + /// @dev Returns the staked amount for a given BLS pubkey. + function getStakedAmount(bytes calldata valBLSPubKey) external view returns (uint256) { + return stakedValidators[valBLSPubKey].balance; + } + + /// @dev Returns true if a validator is currently unstaking. + function isUnstaking(bytes calldata valBLSPubKey) external view returns (bool) { + return _isUnstaking(valBLSPubKey); + } + + /// @dev Returns the number of blocks remaining until an unstaking validator can withdraw their staked ETH. + function getBlocksTillWithdrawAllowed(bytes calldata valBLSPubKey) external view returns (uint256) { + require(_isUnstaking(valBLSPubKey), IVanillaRegistryV2.MustUnstakeToWithdraw()); + uint256 blocksSinceUnstakeInitiated = block.number - stakedValidators[valBLSPubKey].unstakeOccurrence.blockHeight; + return blocksSinceUnstakeInitiated > unstakePeriodBlocks ? 0 : unstakePeriodBlocks - blocksSinceUnstakeInitiated; + } + + /// @dev Returns true if the slashing payout period is due. + function isSlashingPayoutDue() external view returns (bool) { + return FeePayout.isPayoutDue(slashingFundsTracker); + } + + function getAccumulatedSlashingFunds() external view returns (uint256) { + return slashingFundsTracker.accumulatedAmount; + } + + /* + * @dev implements _authorizeUpgrade from UUPSUpgradeable to enable only + * the owner to upgrade the implementation contract. + */ + // solhint-disable-next-line no-empty-blocks + function _authorizeUpgrade(address) internal override onlyOwner {} + + /// @dev Internal function that splits msg.value stake to apply an action for each validator. + function _splitStakeAndApplyAction( + bytes[] calldata blsPubKeys, + address withdrawalAddress, + function(bytes calldata, uint256, address) internal action + ) internal { + require(blsPubKeys.length != 0, IVanillaRegistryV2.AtLeastOneRecipientRequired()); + uint256 baseStakeAmount = msg.value / blsPubKeys.length; + uint256 lastStakeAmount = msg.value - (baseStakeAmount * (blsPubKeys.length - 1)); + uint256 numKeys = blsPubKeys.length; + for (uint256 i = 0; i < numKeys; ++i) { + uint256 stakeAmount = (i == numKeys - 1) ? lastStakeAmount : baseStakeAmount; + action(blsPubKeys[i], stakeAmount, withdrawalAddress); + } + } + + /* + * @dev Internal function to stake ETH on behalf of one or multiple validators via their BLS pubkey. + * @param blsPubKeys The validator BLS public keys to stake. + * @param withdrawalAddress The address to receive the staked ETH. + */ + function _stake(bytes[] calldata blsPubKeys, address withdrawalAddress) internal { + // At least minStake must be staked for each pubkey. + require(msg.value >= minStake * blsPubKeys.length, IVanillaRegistryV2.StakeTooLowForNumberOfKeys(msg.value, minStake * blsPubKeys.length)); + _splitStakeAndApplyAction(blsPubKeys, withdrawalAddress, _stakeAction); + } + + /// @dev Internal function that creates a staked validator record and emits a Staked event. + function _stakeAction(bytes calldata pubKey, uint256 stakeAmount, address withdrawalAddress) internal { + require(!stakedValidators[pubKey].exists, IVanillaRegistryV2.ValidatorRecordMustNotExist(pubKey)); + stakedValidators[pubKey] = StakedValidator({ + exists: true, + balance: stakeAmount, + withdrawalAddress: withdrawalAddress, + unstakeOccurrence: BlockHeightOccurrence.Occurrence({ exists: false, blockHeight: 0 }) + }); + emit Staked(msg.sender, withdrawalAddress, pubKey, stakeAmount); + } + + /* + * @dev Internal function to add ETH to the staked balance of one or multiple validators via their BLS pubkey. + * @param blsPubKeys The BLS public keys to add stake to. + */ + function _addStake(bytes[] calldata blsPubKeys) internal { + // At least 1 wei must be added for each pubkey. + require(msg.value >= blsPubKeys.length, IVanillaRegistryV2.StakeTooLowForNumberOfKeys(msg.value, blsPubKeys.length)); + _splitStakeAndApplyAction(blsPubKeys, address(0), _addStakeAction); + } + + /// @dev Internal function that adds stake to an already existing validator record, emitting a StakeAdded event. + function _addStakeAction(bytes calldata pubKey, uint256 stakeAmount, address) internal { + IVanillaRegistryV2.StakedValidator storage validator = stakedValidators[pubKey]; + require(validator.exists, IVanillaRegistryV2.ValidatorRecordMustExist(pubKey)); + require(validator.withdrawalAddress == msg.sender, + IVanillaRegistryV2.SenderIsNotWithdrawalAddress(msg.sender, validator.withdrawalAddress)); + require(!_isUnstaking(pubKey), IVanillaRegistryV2.ValidatorCannotBeUnstaking(pubKey)); + validator.balance += stakeAmount; + emit StakeAdded(msg.sender, validator.withdrawalAddress, pubKey, stakeAmount, validator.balance); + } + + /* + * @dev Internal function to unstake ETH on behalf of one or multiple validators via their BLS pubkey. + * @param blsPubKeys The BLS public keys to unstake. + */ + function _unstake(bytes[] calldata blsPubKeys) internal { + uint256 len = blsPubKeys.length; + for (uint256 i = 0; i < len; ++i) { + IVanillaRegistryV2.StakedValidator storage validator = stakedValidators[blsPubKeys[i]]; + require(validator.exists, IVanillaRegistryV2.ValidatorRecordMustExist(blsPubKeys[i])); + require(!_isUnstaking(blsPubKeys[i]), IVanillaRegistryV2.ValidatorCannotBeUnstaking(blsPubKeys[i])); + require(validator.withdrawalAddress == msg.sender, + IVanillaRegistryV2.SenderIsNotWithdrawalAddress(msg.sender, validator.withdrawalAddress)); + _unstakeSingle(blsPubKeys[i]); + } + } + + /* + * @dev Internal function to unstake ETH on behalf of one validator via their BLS pubkey. + * This function is necessary for slashing. + * @param pubKey The single BLS public key to unstake. + */ + function _unstakeSingle(bytes calldata pubKey) internal { + IVanillaRegistryV2.StakedValidator storage validator = stakedValidators[pubKey]; + BlockHeightOccurrence.captureOccurrence(validator.unstakeOccurrence); + emit Unstaked(msg.sender, validator.withdrawalAddress, pubKey, validator.balance); + } + + + /// @dev Internal function to withdraw ETH on behalf of one or multiple validators via their BLS pubkey. + /// @dev This function also deletes the validator record, and therefore serves a purpose even if no withdawable funds exist. + /// @param blsPubKeys The BLS public keys to withdraw. + /// @param expectedWithdrawalAddress The expected withdrawal address for every provided validator. + /// @return totalAmount The total amount of ETH withdrawn, to be handled by calling function. + /// @dev msg.sender must be contract owner, or the withdrawal address for every provided validator. + function _withdraw(bytes[] calldata blsPubKeys, address expectedWithdrawalAddress) internal returns (uint256) { + uint256 len = blsPubKeys.length; + uint256 totalAmount = 0; + for (uint256 i = 0; i < len; ++i) { + bytes calldata pubKey = blsPubKeys[i]; + IVanillaRegistryV2.StakedValidator storage validator = stakedValidators[pubKey]; + require(validator.exists, IVanillaRegistryV2.ValidatorRecordMustExist(pubKey)); + require(_isUnstaking(pubKey), IVanillaRegistryV2.MustUnstakeToWithdraw()); + require(block.number > validator.unstakeOccurrence.blockHeight + unstakePeriodBlocks, + IVanillaRegistryV2.WithdrawingTooSoon()); + require(validator.withdrawalAddress == expectedWithdrawalAddress, + IVanillaRegistryV2.WithdrawalAddressMismatch(validator.withdrawalAddress, expectedWithdrawalAddress)); + uint256 balance = validator.balance; + totalAmount += balance; + delete stakedValidators[pubKey]; + emit StakeWithdrawn(msg.sender, expectedWithdrawalAddress, pubKey, balance); + } + emit TotalStakeWithdrawn(msg.sender, expectedWithdrawalAddress, totalAmount); + return totalAmount; + } + + /// @dev Internal function to slash minStake worth of ETH on behalf of one or multiple validators via their BLS pubkey. + /// @param blsPubKeys The BLS public keys to slash. + /// @param payoutIfDue Whether to payout slashed funds to receiver if the payout period is due. + function _slash(bytes[] calldata blsPubKeys, bool payoutIfDue) internal { + uint256 len = blsPubKeys.length; + for (uint256 i = 0; i < len; ++i) { + bytes calldata pubKey = blsPubKeys[i]; + IVanillaRegistryV2.StakedValidator storage validator = stakedValidators[pubKey]; + require(validator.exists, IVanillaRegistryV2.ValidatorRecordMustExist(pubKey)); + if (!_isUnstaking(pubKey)) { + _unstakeSingle(pubKey); + } + uint256 toSlash = minStake; + if (validator.balance < minStake) { + toSlash = validator.balance; + } + validator.balance -= toSlash; + slashingFundsTracker.accumulatedAmount += toSlash; + bool isLastEntry = i == len - 1; + if (payoutIfDue && FeePayout.isPayoutDue(slashingFundsTracker) && isLastEntry) { + FeePayout.transferToRecipient(slashingFundsTracker); + } + emit Slashed(msg.sender, slashingFundsTracker.recipient, validator.withdrawalAddress, pubKey, toSlash); + } + } + + /// @dev Internal function to set the minimum stake parameter. + function _setMinStake(uint256 newMinStake) internal { + require(newMinStake != 0, IVanillaRegistryV2.MinStakeMustBePositive()); + minStake = newMinStake; + emit MinStakeSet(msg.sender, newMinStake); + } + + /// @dev Internal function to set the slash oracle parameter. + function _setSlashOracle(address newSlashOracle) internal { + require(newSlashOracle != address(0), IVanillaRegistryV2.SlashOracleMustBeSet()); + slashOracle = newSlashOracle; + emit SlashOracleSet(msg.sender, newSlashOracle); + } + + /// @dev Internal function to set the slash receiver parameter. + function _setSlashReceiver(address newSlashReceiver) internal { + require(newSlashReceiver != address(0), IVanillaRegistryV2.SlashReceiverMustBeSet()); + slashingFundsTracker.recipient = newSlashReceiver; + emit SlashReceiverSet(msg.sender, newSlashReceiver); + } + + /// @dev Internal function to set the unstake period parameter. + function _setUnstakePeriodBlocks(uint256 newUnstakePeriodBlocks) internal { + require(newUnstakePeriodBlocks != 0, IVanillaRegistryV2.UnstakePeriodMustBePositive()); + unstakePeriodBlocks = newUnstakePeriodBlocks; + emit UnstakePeriodBlocksSet(msg.sender, newUnstakePeriodBlocks); + } + + /// @dev Internal function to set the slashing payout period parameter in blocks. + function _setSlashingPayoutPeriodBlocks(uint256 newSlashingPayoutPeriodBlocks) internal { + require(newSlashingPayoutPeriodBlocks != 0, IVanillaRegistryV2.SlashingPayoutPeriodMustBePositive()); + slashingFundsTracker.payoutPeriodBlocks = newSlashingPayoutPeriodBlocks; + emit SlashingPayoutPeriodBlocksSet(msg.sender, newSlashingPayoutPeriodBlocks); + } + + /// @dev Internal function to check if a validator is considered "opted-in" to mev-commit via this registry. + function _isValidatorOptedIn(bytes calldata valBLSPubKey) internal view returns (bool) { + return !_isUnstaking(valBLSPubKey) && stakedValidators[valBLSPubKey].balance >= minStake; + } + + /// @dev Internal function to check if a validator is currently unstaking. + function _isUnstaking(bytes calldata valBLSPubKey) internal view returns (bool) { + return stakedValidators[valBLSPubKey].unstakeOccurrence.exists; + } +} diff --git a/contracts/test/validator-registry/ValidatorOptInRouterTest.sol b/contracts/test/validator-registry/ValidatorOptInRouterTest.sol index 540b38777..7630bc579 100644 --- a/contracts/test/validator-registry/ValidatorOptInRouterTest.sol +++ b/contracts/test/validator-registry/ValidatorOptInRouterTest.sol @@ -40,7 +40,7 @@ contract ValidatorOptInRouterTest is Test { vanillaRegistryTest = new VanillaRegistryTest(); vanillaRegistryTest.setUp(); - vanillaRegistry = vanillaRegistryTest.validatorRegistry(); + vanillaRegistry = VanillaRegistry(payable(address(vanillaRegistryTest.validatorRegistry()))); mevCommitAVSTest = new MevCommitAVSTest(); mevCommitAVSTest.setUp(); diff --git a/contracts/test/validator-registry/VanillaRegistryTest.sol b/contracts/test/validator-registry/VanillaRegistryTest.sol index af7405cea..ef7bd998a 100644 --- a/contracts/test/validator-registry/VanillaRegistryTest.sol +++ b/contracts/test/validator-registry/VanillaRegistryTest.sol @@ -3,12 +3,14 @@ pragma solidity 0.8.26; import {Test} from"forge-std/Test.sol"; import {VanillaRegistry} from"../../contracts/validator-registry/VanillaRegistry.sol"; +import {VanillaRegistryV2} from "../../contracts/validator-registry/VanillaRegistryV2.sol"; import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; import {IVanillaRegistry} from "../../contracts/interfaces/IVanillaRegistry.sol"; +import {IVanillaRegistryV2} from "../../contracts/interfaces/IVanillaRegistryV2.sol"; import {OwnableUpgradeable} from "openzeppelin-contracts-upgradeable/contracts/access/OwnableUpgradeable.sol"; contract VanillaRegistryTest is Test { - VanillaRegistry public validatorRegistry; + VanillaRegistryV2 public validatorRegistry; address public owner; address public user1; address public user2; @@ -49,7 +51,15 @@ contract VanillaRegistryTest is Test { "VanillaRegistry.sol", abi.encodeCall(VanillaRegistry.initialize, (MIN_STAKE, SLASH_ORACLE, SLASH_RECEIVER, UNSTAKE_PERIOD, PAYOUT_PERIOD, owner)) ); - validatorRegistry = VanillaRegistry(payable(proxy)); + + vm.prank(vm.addr(0x111119)); // V2 impl can be deployed by anyone + VanillaRegistryV2 newImpl = new VanillaRegistryV2(); + + bytes memory data = ""; + vm.prank(owner); + VanillaRegistry(payable(proxy)).upgradeToAndCall(address(newImpl), data); + + validatorRegistry = VanillaRegistryV2(payable(proxy)); } function testSecondInitialize() public { @@ -66,8 +76,16 @@ contract VanillaRegistryTest is Test { bytes[] memory validators = new bytes[](1); validators[0] = user1BLSKey; - vm.startPrank(user1); + vm.expectRevert(abi.encodeWithSelector(IVanillaRegistryV2.SenderIsNotWhitelistedStaker.selector, user1)); + vm.prank(user1); + validatorRegistry.stake{value: MIN_STAKE}(validators); + + vm.prank(owner); + address[] memory stakers = new address[](1); + stakers[0] = user1; + validatorRegistry.whitelistStakers(stakers); + vm.startPrank(user1); vm.expectEmit(true, true, true, true); emit Staked(user1, user1, user1BLSKey, MIN_STAKE); validatorRegistry.stake{value: MIN_STAKE}(validators); @@ -80,7 +98,44 @@ contract VanillaRegistryTest is Test { assertTrue(validatorRegistry.isValidatorOptedIn(user1BLSKey)); } + function testStakeAfterRemovedFromWhitelist() public { + testSelfStake(); + + vm.prank(owner); + address[] memory stakers = new address[](1); + stakers[0] = user1; + validatorRegistry.removeWhitelistedStakers(stakers); + + bytes[] memory validators = new bytes[](1); + validators[0] = user2BLSKey; + + vm.startPrank(user1); + vm.expectRevert(abi.encodeWithSelector(IVanillaRegistryV2.SenderIsNotWhitelistedStaker.selector, user1)); + validatorRegistry.stake{value: MIN_STAKE}(validators); + vm.stopPrank(); + + // works once again after whitelisting + vm.prank(owner); + validatorRegistry.whitelistStakers(stakers); + + vm.startPrank(user1); + vm.expectEmit(true, true, true, true); + emit Staked(user1, user1, user2BLSKey, MIN_STAKE); + validatorRegistry.stake{value: MIN_STAKE}(validators); + vm.stopPrank(); + + assertEq(address(user1).balance, 7 ether); + assertEq(validatorRegistry.getStakedAmount(user1BLSKey), MIN_STAKE); + assertTrue(validatorRegistry.isValidatorOptedIn(user1BLSKey)); + } + function testMultiStake() public { + vm.prank(owner); + address[] memory stakers = new address[](2); + stakers[0] = user1; + stakers[1] = user2; + validatorRegistry.whitelistStakers(stakers); + bytes[] memory validators = new bytes[](2); validators[0] = user1BLSKey; validators[1] = user2BLSKey; @@ -133,6 +188,12 @@ contract VanillaRegistryTest is Test { } function testAddStake() public { + vm.prank(owner); + address[] memory stakers = new address[](2); + stakers[0] = user1; + stakers[1] = user2; + validatorRegistry.whitelistStakers(stakers); + vm.deal(user1, 10 ether); assertEq(user1.balance, 10 ether); @@ -195,6 +256,12 @@ contract VanillaRegistryTest is Test { function testUnathorizedMultiUnstake() public { testSelfStake(); + + vm.prank(owner); + address[] memory stakers = new address[](1); + stakers[0] = user2; + validatorRegistry.whitelistStakers(stakers); + bytes[] memory validators = new bytes[](1); validators[0] = user2BLSKey; vm.deal(user2, MIN_STAKE); @@ -358,6 +425,11 @@ contract VanillaRegistryTest is Test { validatorRegistry.stake{value: MIN_STAKE}(validators); vm.stopPrank(); + vm.prank(owner); + address[] memory stakers = new address[](1); + stakers[0] = user2; + validatorRegistry.whitelistStakers(stakers); + vm.deal(user2, 10 ether); vm.startPrank(user2); vm.expectRevert(abi.encodeWithSelector(IVanillaRegistry.ValidatorRecordMustNotExist.selector, user1BLSKey)); @@ -424,6 +496,11 @@ contract VanillaRegistryTest is Test { vm.prank(SLASH_ORACLE); validatorRegistry.slash(validators, true); + vm.prank(owner); + address[] memory stakers = new address[](1); + stakers[0] = user1; + validatorRegistry.whitelistStakers(stakers); + vm.deal(user1, 2 ether); vm.startPrank(user1); uint256 stakeAmount = MIN_STAKE+1; @@ -590,11 +667,16 @@ contract VanillaRegistryTest is Test { function testManualPayout() public { testBatchedSlashing(); + vm.prank(owner); + address[] memory stakers = new address[](1); + address user3 = vm.addr(0x23333); + stakers[0] = user3; + validatorRegistry.whitelistStakers(stakers); + vm.roll(10000); bytes[] memory validators = new bytes[](1); validators[0] = user3BLSKey; - address user3 = vm.addr(0x23333); vm.deal(user3, 10 ether); vm.startPrank(user3); vm.expectEmit(true, true, true, true); @@ -737,6 +819,11 @@ contract VanillaRegistryTest is Test { function testStakingCycle() public { testUnstakeWaitThenWithdraw(); + vm.prank(owner); + address[] memory stakers = new address[](1); + stakers[0] = user1; + validatorRegistry.removeWhitelistedStakers(stakers); + // Reset user1 balance for next cycle vm.prank(user1); (bool sent, ) = user2.call{value: 9 ether}(""); @@ -807,6 +894,11 @@ contract VanillaRegistryTest is Test { validatorRegistry.pause(); vm.stopPrank(); + vm.prank(owner); + address[] memory stakers = new address[](1); + stakers[0] = user1; + validatorRegistry.whitelistStakers(stakers); + bytes[] memory validators = new bytes[](1); validators[0] = user1BLSKey; vm.startPrank(user1); @@ -857,6 +949,11 @@ contract VanillaRegistryTest is Test { } function testPrecisionLossPrevention() public { + vm.prank(owner); + address[] memory stakers = new address[](1); + stakers[0] = user1; + validatorRegistry.whitelistStakers(stakers); + vm.prank(owner); validatorRegistry.setMinStake(1 wei); @@ -975,7 +1072,7 @@ contract VanillaRegistryTest is Test { vm.roll(500); - IVanillaRegistry.StakedValidator memory stakedValidator = validatorRegistry.getStakedValidator(user1BLSKey); + IVanillaRegistryV2.StakedValidator memory stakedValidator = validatorRegistry.getStakedValidator(user1BLSKey); assertTrue(stakedValidator.exists); assertEq(stakedValidator.balance, 0); assertEq(stakedValidator.withdrawalAddress, user1); @@ -1011,7 +1108,7 @@ contract VanillaRegistryTest is Test { vm.roll(200); - IVanillaRegistry.StakedValidator memory stakedValidator = validatorRegistry.getStakedValidator(user1BLSKey); + IVanillaRegistryV2.StakedValidator memory stakedValidator = validatorRegistry.getStakedValidator(user1BLSKey); assertTrue(stakedValidator.exists); assertEq(stakedValidator.balance, 2 ether); assertEq(stakedValidator.withdrawalAddress, user1); @@ -1042,6 +1139,11 @@ contract VanillaRegistryTest is Test { } function testStakeWithDuplicateBlsPubkeys() public { + vm.prank(owner); + address[] memory stakers = new address[](2); + stakers[0] = user1; + validatorRegistry.whitelistStakers(stakers); + bytes[] memory validators = new bytes[](3); validators[0] = user1BLSKey; validators[1] = user2BLSKey; @@ -1072,4 +1174,23 @@ contract VanillaRegistryTest is Test { emit Staked(user1, user1, user3BLSKey, MIN_STAKE); validatorRegistry.stake{value: 3 * MIN_STAKE}(validators); } + + function testCannotWhitelistStakerTwice() public { + vm.prank(owner); + address[] memory stakers = new address[](1); + stakers[0] = user1; + validatorRegistry.whitelistStakers(stakers); + + vm.prank(owner); + vm.expectRevert(abi.encodeWithSelector(IVanillaRegistryV2.StakerAlreadyWhitelisted.selector, user1)); + validatorRegistry.whitelistStakers(stakers); + } + + function testCannotRemoveNonWhitelistedStakerfromWhitelist() public { + vm.prank(owner); + address[] memory stakers = new address[](1); + stakers[0] = user1; + vm.expectRevert(abi.encodeWithSelector(IVanillaRegistryV2.StakerNotWhitelisted.selector, user1)); + validatorRegistry.removeWhitelistedStakers(stakers); + } } From 66f2df7c1b279b9fbebb3016e85e6689a2757c7d Mon Sep 17 00:00:00 2001 From: Shawn <44221603+shaspitz@users.noreply.github.com> Date: Fri, 13 Jun 2025 13:28:20 -0700 Subject: [PATCH 2/2] properly shrink __gap from openzeppelin's cli --- .../contracts/validator-registry/VanillaRegistryStorageV2.sol | 4 +++- contracts/contracts/validator-registry/VanillaRegistryV2.sol | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/contracts/contracts/validator-registry/VanillaRegistryStorageV2.sol b/contracts/contracts/validator-registry/VanillaRegistryStorageV2.sol index 746141067..d727a1480 100644 --- a/contracts/contracts/validator-registry/VanillaRegistryStorageV2.sol +++ b/contracts/contracts/validator-registry/VanillaRegistryStorageV2.sol @@ -27,8 +27,10 @@ contract VanillaRegistryStorageV2 { mapping(address withdrawalAddress => uint256 amountToClaim) public forceWithdrawnFunds; /// @dev Mapping of staker addresses to whether they are whitelisted. + /// @dev This mapping was added in V2 and caused __gap to be decremented by 1. mapping(address staker => bool whitelisted) public whitelistedStakers; /// @dev See https://docs.openzeppelin.com/upgrades-plugins/1.x/writing-upgradeable#storage-gaps - uint256[48] private __gap; + /// @dev This gap was decremented by 1 in V2. + uint256[47] private __gap; } diff --git a/contracts/contracts/validator-registry/VanillaRegistryV2.sol b/contracts/contracts/validator-registry/VanillaRegistryV2.sol index 4e55f05e8..84c9a4df6 100644 --- a/contracts/contracts/validator-registry/VanillaRegistryV2.sol +++ b/contracts/contracts/validator-registry/VanillaRegistryV2.sol @@ -13,6 +13,7 @@ import {FeePayout} from "../utils/FeePayout.sol"; /// @title Vanilla Registry V2 /// @notice Logic contract enabling L1 validators to opt-in to mev-commit /// via simply staking ETH outside what's staked with the beacon chain. +/// @custom:oz-upgrades-from VanillaRegistry contract VanillaRegistryV2 is IVanillaRegistryV2, VanillaRegistryStorageV2, Ownable2StepUpgradeable, PausableUpgradeable, UUPSUpgradeable {