Skip to content

Commit

Permalink
feat: validator's withdrawal proof
Browse files Browse the repository at this point in the history
  • Loading branch information
madlabman committed Jan 10, 2024
1 parent 3dbd0d0 commit cfa0da1
Show file tree
Hide file tree
Showing 8 changed files with 661 additions and 11 deletions.
46 changes: 35 additions & 11 deletions src/CSModule.sol
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,11 @@ contract CSModuleBase {
bool isTargetLimitActive,
uint256 targetValidatorsCount
);
event WithdrawalSubmitted(uint256 indexed validatorId, uint256 exitBalance);
event WithdrawalSubmitted(
uint256 indexed nodeOperatorId,
uint256 keyIndex,
uint256 amount
);

event BatchEnqueued(
uint256 indexed nodeOperatorId,
Expand Down Expand Up @@ -125,6 +129,8 @@ contract CSModuleBase {
error QueueBatchUnvettedKeys(bytes32 batch);

error SigningKeysInvalidOffset();

error WithdrawalAlreadySubmitted();
}

contract CSModule is ICSModule, CSModuleBase {
Expand All @@ -147,6 +153,7 @@ contract CSModule is ICSModule, CSModuleBase {
bytes32 private _moduleType;
uint256 private _nonce;
mapping(uint256 => NodeOperator) private _nodeOperators;
mapping(uint256 noIdWithKeyIndex => bool) private _isValidatorWithdrawn;

uint256 private _totalDepositedValidators;
uint256 private _totalExitedValidators;
Expand Down Expand Up @@ -1080,24 +1087,35 @@ contract CSModule is ICSModule, CSModuleBase {
_checkForOutOfBond(nodeOperatorId);
}

/// @notice Report node operator's key as withdrawn and settle withdrawn amount.
/// @param nodeOperatorId Operator ID in the module.
/// @param keyIndex Index of the withdrawn key in the node operator's keys.
/// @param amount Amount of withdrawn ETH in wei.
function submitWithdrawal(
bytes32 /*withdrawalProof*/,
uint256 nodeOperatorId,
uint256 validatorId,
uint256 exitBalance
) external onlyExistingNodeOperator(nodeOperatorId) {
// TODO: check for withdrawal proof
// TODO: consider asserting that withdrawn keys count is not higher than exited keys count
uint256 keyIndex,
uint256 amount
) external onlyExistingNodeOperator(nodeOperatorId) onlyWithdrawalReporter {
NodeOperator storage no = _nodeOperators[nodeOperatorId];
// NOTE: Exited keys are reported by the oracle, but withdrawn keys by a permissionless actor.
require(no.totalWithdrawnKeys < no.totalExitedKeys);
require(keyIndex < no.totalDepositedKeys);

// NOTE: both nodeOperatorId and keyIndex are limited to uint64 by the contract.
uint256 pointer = (nodeOperatorId << 128) | keyIndex;
if (_isValidatorWithdrawn[pointer]) {
revert WithdrawalAlreadySubmitted();
}

no.totalWithdrawnKeys += 1;
_isValidatorWithdrawn[pointer] = true;
no.totalWithdrawnKeys++;

if (exitBalance < DEPOSIT_SIZE) {
accounting.penalize(nodeOperatorId, DEPOSIT_SIZE - exitBalance);
if (amount < DEPOSIT_SIZE) {
accounting.penalize(nodeOperatorId, DEPOSIT_SIZE - amount);
_checkForOutOfBond(nodeOperatorId);
}

emit WithdrawalSubmitted(validatorId, exitBalance);
emit WithdrawalSubmitted(nodeOperatorId, keyIndex, amount);
}

/// @notice Called when withdrawal credentials changed by DAO
Expand Down Expand Up @@ -1420,4 +1438,10 @@ contract CSModule is ICSModule, CSModuleBase {
// TODO: check the role
_;
}

modifier onlyWithdrawalReporter() {
// Here should be a role granted to the CSVerifier contract and/or to the DAO/Oracle.
// TODO: check the role
_;
}
}
194 changes: 194 additions & 0 deletions src/CSVerifier.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
// SPDX-FileCopyrightText: 2023 Lido <[email protected]>
// SPDX-License-Identifier: GPL-3.0

pragma solidity 0.8.21;

import { ICSVerifier } from "./interfaces/ICSVerifier.sol";
import { IGIProvider } from "./interfaces/IGIProvider.sol";
import { ICSModule } from "./interfaces/ICSModule.sol";

import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol";

import { SSZ, Withdrawal, Validator } from "./lib/SSZ.sol";
import { GIndex } from "./lib/GIndexLib.sol";

contract CSVerifier is ICSVerifier {
using SafeCast for uint256;

using SSZ for Withdrawal;
using SSZ for Validator;

uint64 internal immutable SLOTS_PER_EPOCH;
uint64 public immutable SECONDS_PER_SLOT;
uint64 public immutable GENESIS_TIME;

IGIProvider public giProvider;
ICSModule public module;

error InvalidChainConfig();

error ProofTypeNotSupported();
error ValidatorNotWithdrawn();
error BLSPubkeyMismatch();

constructor(
uint256 slotsPerEpoch,
uint256 secondsPerSlot,
uint256 genesisTime,
address _giProvider,
address _module
) {
if (secondsPerSlot == 0) revert InvalidChainConfig();
if (slotsPerEpoch == 0) revert InvalidChainConfig();

SECONDS_PER_SLOT = secondsPerSlot.toUint64();
SLOTS_PER_EPOCH = slotsPerEpoch.toUint64();
GENESIS_TIME = genesisTime.toUint64();
// TODO: Move to initialize function.
giProvider = IGIProvider(_giProvider);
module = ICSModule(_module);
}

function processWithdrawalProof(
WithdrawalProofContext calldata ctx,
uint256 nodeOperatorId,
uint256 keyIndex
) external {
bytes memory pubkey = module.getNodeOperatorSigningKeys(
nodeOperatorId,
keyIndex,
1
);

Validator memory validator = Validator({
pubkey: pubkey,
withdrawalCredentials: _toWithdrawalCredentials(
ctx.withdrawalAddress
),
effectiveBalance: 0,
slashed: ctx.slashed,
activationEligibilityEpoch: ctx.activationEligibilityEpoch,
activationEpoch: ctx.activationEpoch,
exitEpoch: ctx.exitEpoch,
withdrawableEpoch: ctx.withdrawableEpoch
});

if (keccak256(validator.pubkey) != keccak256(pubkey)) {
revert BLSPubkeyMismatch();
}

// Compare validator struct's withdrawable epoch and balance, otherwise
// it's a skimmed reward withdrawn
if (
_getEpoch() < validator.withdrawableEpoch ||
validator.effectiveBalance != 0
) {
revert ValidatorNotWithdrawn();
}

Withdrawal memory withdrawal = Withdrawal({
index: ctx.withdrawalIndex,
validatorIndex: ctx.validatorIndex,
withdrawalAddress: ctx.withdrawalAddress,
amount: ctx.amount
});

bytes32 blockRoot = _getBlockRoot(ctx.blockTimestamp);
// solhint-disable-next-line func-named-parameters
_verifyStateProof(
ctx.stateRootProofType,
blockRoot,
ctx.stateRoot,
ctx.stateRootOffsets,
ctx.stateRootProof
);

SSZ.verifyProof(
ctx.validatorProof,
ctx.stateRoot,
validator.hashTreeRoot(),
giProvider.gIndex(validator, ctx.validatorIndex)
);

SSZ.verifyProof(
ctx.withdrawalProof,
ctx.stateRoot,
withdrawal.hashTreeRoot(),
giProvider.gIndex(withdrawal, ctx.withdrawalOffset)
);

module.submitWithdrawal(
nodeOperatorId,
keyIndex,
withdrawal.amount * 1 gwei
);
}

function _verifyStateProof(
StateRootProofType proofType,
bytes32 blockRoot,
bytes32 stateRoot,
bytes32 offsets,
bytes32[] calldata proof
) internal view {
// TODO: Add support for the additional types of proofs.
if (proofType != StateRootProofType.WithinBlock) {
revert ProofTypeNotSupported();
}

GIndex gI = _stateGIndex(proofType, offsets);

SSZ.verifyProof(proof, blockRoot, stateRoot, gI);
}

function _stateGIndex(
StateRootProofType proofType,
bytes32 /* offsets */
) internal pure returns (GIndex) {
if (proofType == StateRootProofType.WithinBlock) {
return GIndex.wrap(0xdeadbeef);
}

// TODO: Add support for the additional types of proofs.
revert ProofTypeNotSupported();
}

function _toWithdrawalCredentials(
address withdrawalAddress
) internal pure returns (bytes32) {
return bytes32((1 << 248) + uint160(withdrawalAddress));
}

function _getBlockRoot(uint64 /* ts */) internal pure returns (bytes32) {
return bytes32(hex"deadbeef");
}

function _getEpoch() internal view returns (uint256) {
return _computeEpochAtTimestamp(_getTime());
}

// ┌─────────────────────────────────────────────────────────┐
// │ Methods below were copied from HashConsensus contract. │
// └─────────────────────────────────────────────────────────┘

function _computeSlotAtTimestamp(
uint256 timestamp
) internal view returns (uint256) {
return (timestamp - GENESIS_TIME) / SECONDS_PER_SLOT;
}

function _computeEpochAtSlot(uint256 slot) internal view returns (uint256) {
// See: github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#compute_epoch_at_slot
return slot / SLOTS_PER_EPOCH;
}

function _computeEpochAtTimestamp(
uint256 timestamp
) internal view returns (uint256) {
return _computeEpochAtSlot(_computeSlotAtTimestamp(timestamp));
}

function _getTime() internal view virtual returns (uint256) {
return block.timestamp; // solhint-disable-line not-rely-on-time
}
}
47 changes: 47 additions & 0 deletions src/GIProvider.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// SPDX-FileCopyrightText: 2023 Lido <[email protected]>
// SPDX-License-Identifier: GPL-3.0

pragma solidity 0.8.21;

import { IGIProvider } from "./interfaces/IGIProvider.sol";

import { Withdrawal, Validator } from "./lib/SSZ.sol";
import { GIndex } from "./lib/GIndexLib.sol";

contract GIProvider is IGIProvider {
// FIXME: These constants are not constants from a hardfork to a hardfork. We can implement a dedicated provider for
// these values.
GIndex public constant GI_STATE_WITHDRAWAL_ROOT = GIndex.wrap(0xdeadbeef);
GIndex public constant GI_STATE_VALIDATORS_ROOT = GIndex.wrap(0xdeadbeef);
GIndex public constant GI_HISTORICAL_SUMMARIES_ROOT =
GIndex.wrap(0xdeadbeef);

uint64 public constant MAX_WITHDRAWALS = 2 ** 4; // See spec/capella/beacon-chain.md:87.
uint64 public constant MAX_VALIDATORS = 2 ** 40; // See spec/phase0/beacon-chain.md:258.
uint64 public constant MAX_STATE_SUMMARY_ROOTS = 2 ** 13; // See spec/phase0/beacon-chain.md:249.
uint64 public constant MAX_HISTORICAL_ROOTS = 2 ** 24; // See spec/phase0/beacon-chain.md:257.

function gIndex(
Withdrawal memory /* self */,
uint64 offset
) external pure returns (GIndex) {
// NOTE: Shifting MAX_WITHDRAWALS because of mix_in_length during merkleization.
return
// TODO: This call can be inlined, because MAX_VALIDATORS is a constant and we don't have to compute log2.
GI_STATE_WITHDRAWAL_ROOT.concat(
GIndex.wrap((MAX_WITHDRAWALS << 1) | offset)
);
}

function gIndex(
Validator memory /* self */,
uint64 offset
) external pure returns (GIndex) {
// NOTE: Shifting MAX_VALIDATORS because of mix_in_length during merkleization.
return
// TODO: This call can be inlined, because MAX_WITHDRAWALS is a constant and we don't have to compute log2.
GI_STATE_VALIDATORS_ROOT.concat(
GIndex.wrap((MAX_VALIDATORS << 1) | offset)
);
}
}
21 changes: 21 additions & 0 deletions src/interfaces/ICSModule.sol
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,25 @@ interface ICSModule is IStakingModule {
function getNodeOperator(
uint256 nodeOperatorId
) external view returns (NodeOperatorInfo memory);

/// @notice Gets node operator signing keys
/// @param nodeOperatorId ID of the node operator
/// @param startIndex Index of the first key
/// @param keysCount Count of keys to get
/// @return Signing keys
function getNodeOperatorSigningKeys(
uint256 nodeOperatorId,
uint256 startIndex,
uint256 keysCount
) external view returns (bytes memory);

/// @notice Report node operator's key as withdrawn and settle withdrawn amount.
/// @param nodeOperatorId Operator ID in the module.
/// @param keyIndex Index of the withdrawn key in the node operator's keys.
/// @param amount Amount of withdrawn ETH in wei.
function submitWithdrawal(
uint256 nodeOperatorId,
uint256 keyIndex,
uint256 amount
) external;
}
Loading

0 comments on commit cfa0da1

Please sign in to comment.