From 3629fe18363d0ccc254461d6611b786c680674e7 Mon Sep 17 00:00:00 2001 From: madlabman <10616301+madlabman@users.noreply.github.com> Date: Fri, 5 Jan 2024 08:59:44 +0100 Subject: [PATCH] feat: validator's withdrawal proof --- src/CSVerifier.sol | 194 ++++++++++++++++++++++++++ src/GIProvider.sol | 47 +++++++ src/interfaces/ICSVerifier.sol | 46 ++++++ src/interfaces/IGIProvider.sol | 19 +++ src/lib/GIndexLib.sol | 51 +++++++ src/lib/SSZ.sol | 248 +++++++++++++++++++++++++++++++++ 6 files changed, 605 insertions(+) create mode 100644 src/CSVerifier.sol create mode 100644 src/GIProvider.sol create mode 100644 src/interfaces/ICSVerifier.sol create mode 100644 src/interfaces/IGIProvider.sol create mode 100644 src/lib/GIndexLib.sol create mode 100644 src/lib/SSZ.sol diff --git a/src/CSVerifier.sol b/src/CSVerifier.sol new file mode 100644 index 00000000..4f5fb116 --- /dev/null +++ b/src/CSVerifier.sol @@ -0,0 +1,194 @@ +// SPDX-FileCopyrightText: 2023 Lido +// 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 + } +} diff --git a/src/GIProvider.sol b/src/GIProvider.sol new file mode 100644 index 00000000..28bf2325 --- /dev/null +++ b/src/GIProvider.sol @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: 2023 Lido +// 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) + ); + } +} diff --git a/src/interfaces/ICSVerifier.sol b/src/interfaces/ICSVerifier.sol new file mode 100644 index 00000000..3335a47c --- /dev/null +++ b/src/interfaces/ICSVerifier.sol @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2023 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.21; + +interface ICSVerifier { + // ┌────────────────────────────────────────────────────────────────────────────────┐ + // │ A proof for a `state_root` against a `block_root │ + // │ Should accept different types of proofs as described here │ + // │ https://research.lido.fi/t/block-roots-permanent-cache-eip-4788-plugin/5706/18 │ + // └────────────────────────────────────────────────────────────────────────────────┘ + enum StateRootProofType { + WithinBlock, + WithinHistoricalSummaries + } + + struct WithdrawalProofContext { + // ── Withdrawal fields ───────────────────────────────────────────────── + uint8 withdrawalOffset; // in the withdrawals list + uint64 withdrawalIndex; // network-wise + uint64 validatorIndex; + uint64 amount; + address withdrawalAddress; + // ── Validator fields ────────────────────────────────────────────────── + bool slashed; + uint64 activationEligibilityEpoch; + uint64 activationEpoch; + uint64 exitEpoch; + uint64 withdrawableEpoch; + // ────────────────────────────────────────────────────────────────────── + uint64 blockTimestamp; + bytes32 stateRoot; + StateRootProofType stateRootProofType; + bytes32 stateRootOffsets; // Includes offsets for the different types of proofs. + // ── Proofs ──────────────────────────────────────────────────────────── + bytes32[] withdrawalProof; + bytes32[] validatorProof; + bytes32[] stateRootProof; + } + + function processWithdrawalProof( + WithdrawalProofContext calldata ctx, + uint256 nodeOperatorId, + uint256 keyIndex + ) external; +} diff --git a/src/interfaces/IGIProvider.sol b/src/interfaces/IGIProvider.sol new file mode 100644 index 00000000..940943bb --- /dev/null +++ b/src/interfaces/IGIProvider.sol @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2023 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.21; + +import { Withdrawal, Validator } from "../lib/SSZ.sol"; +import { GIndex } from "../lib/GIndexLib.sol"; + +interface IGIProvider { + function gIndex( + Withdrawal memory /* self */, + uint64 offset + ) external pure returns (GIndex); + + function gIndex( + Validator memory /* self */, + uint64 offset + ) external pure returns (GIndex); +} diff --git a/src/lib/GIndexLib.sol b/src/lib/GIndexLib.sol new file mode 100644 index 00000000..dbbdd97d --- /dev/null +++ b/src/lib/GIndexLib.sol @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: 2023 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.21; + +type GIndex is uint256; + +library GIndexLib { + // See https://github.com/protolambda/remerkleable/blob/91ed092d08ef0ba5ab076f0a34b0b371623db728/remerkleable/tree.py#L46 + function concat(GIndex self, GIndex b) internal pure returns (GIndex) { + uint256 stepBitLen = log2(GIndex.unwrap(b)); + return + GIndex.wrap( + (GIndex.unwrap(self) << stepBitLen) | + (GIndex.unwrap(b) ^ (1 << stepBitLen)) + ); + } + + /// @dev From solady FixedPointMath. + /// @dev Returns the log2 of `x`. + /// Equivalent to computing the index of the most significant bit (MSB) of `x`. + function log2(uint256 x) internal pure returns (uint256 r) { + /// @solidity memory-safe-assembly + assembly { + if iszero(x) { + // revert Log2Undefined() + mstore(0x00, 0x5be3aa5c) + revert(0x1c, 0x04) + } + + r := shl(7, lt(0xffffffffffffffffffffffffffffffff, x)) + r := or(r, shl(6, lt(0xffffffffffffffff, shr(r, x)))) + r := or(r, shl(5, lt(0xffffffff, shr(r, x)))) + + // For the remaining 32 bits, use a De Bruijn lookup. + // See: https://graphics.stanford.edu/~seander/bithacks.html + x := shr(r, x) + x := or(x, shr(1, x)) + x := or(x, shr(2, x)) + x := or(x, shr(4, x)) + x := or(x, shr(8, x)) + x := or(x, shr(16, x)) + + // prettier-ignore + r := or(r, byte(shr(251, mul(x, shl(224, 0x07c4acdd))), + 0x0009010a0d15021d0b0e10121619031e080c141c0f111807131b17061a05041f)) + } + } +} + +using GIndexLib for GIndex global; diff --git a/src/lib/SSZ.sol b/src/lib/SSZ.sol new file mode 100644 index 00000000..b756afaa --- /dev/null +++ b/src/lib/SSZ.sol @@ -0,0 +1,248 @@ +// SPDX-FileCopyrightText: 2023 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.21; + +import { GIndex } from "./GIndexLib.sol"; + +// Withdrawal represents a validator withdrawal from the consensus layer. +// See EIP-4895: Beacon chain push withdrawals as operations. +struct Withdrawal { + uint64 index; + uint64 validatorIndex; + address withdrawalAddress; + uint64 amount; +} + +// As defined in phase0/beacon-chain.md:356 +struct Validator { + bytes pubkey; + bytes32 withdrawalCredentials; + uint64 effectiveBalance; + bool slashed; + uint64 activationEligibilityEpoch; + uint64 activationEpoch; + uint64 exitEpoch; + uint64 withdrawableEpoch; +} + +library SSZ { + error BranchHasMissingItem(); + error BranchHasExtraItem(); + + // Inspired by https://github.com/succinctlabs/telepathy-contracts/blob/main/src/libraries/SimpleSerialize.sol#L59 + function hashTreeRoot( + Withdrawal memory withdrawal + ) internal pure returns (bytes32) { + return + sha256( + bytes.concat( + sha256( + bytes.concat( + toLittleEndian(withdrawal.index), + toLittleEndian(withdrawal.validatorIndex) + ) + ), + sha256( + bytes.concat( + bytes20(withdrawal.withdrawalAddress), + bytes12(0), + toLittleEndian(withdrawal.amount) + ) + ) + ) + ); + } + + function hashTreeRoot( + Validator memory validator + ) internal view returns (bytes32 root) { + bytes32 pubkeyRoot; + + assembly { + // Dynamic data types such as bytes are stored at the specified offset. + let offset := mload(validator) + // Call sha256 precompile with the pubkey pointer + let result := staticcall( + gas(), + 0x02, + add(offset, 32), + 0x40, + 0x00, + 0x20 + ) + + if eq(result, 0) { + // Precompiles returns no data on OutOfGas error. + revert(0, 0) + } + + pubkeyRoot := mload(0x00) + } + + bytes32[8] memory nodes = [ + pubkeyRoot, + validator.withdrawalCredentials, + toLittleEndian(validator.effectiveBalance), + toLittleEndian(validator.slashed ? 1 : 0), + toLittleEndian(validator.activationEligibilityEpoch), + toLittleEndian(validator.activationEpoch), + toLittleEndian(validator.exitEpoch), + toLittleEndian(validator.withdrawableEpoch) + ]; + + /// @solidity memory-safe-assembly + assembly { + // Count of nodes to hash + let count := 8 + + // Loop over levels + // prettier-ignore + for { } 1 { } { + // Loop over nodes at the given depth + + // Initialize `offset` to the offset of `proof` elements in memory. + let target := nodes + let source := nodes + let end := add(source, shl(5, count)) + + // prettier-ignore + for { } 1 { } { + // Read next two hashes to hash + mstore(0x00, mload(source)) + mstore(0x20, mload(add(source, 0x20))) + + // Call sha256 precompile + let result := staticcall( + gas(), + 0x02, + 0x00, + 0x40, + 0x00, + 0x20 + ) + + if eq(result, 0) { + // Precompiles returns no data on OutOfGas error. + revert(0, 0) + } + + // Store the resulting hash at the target location + mstore(target, mload(0x00)) + + // Advance the pointers + target := add(target, 0x20) + source := add(source, 0x40) + + if iszero(lt(source, end)) { + break + } + } + + count := shr(1, count) + if eq(count, 1) { + root := mload(0x00) + break + } + } + } + } + + function toLittleEndian(uint256 v) internal pure returns (bytes32) { + v = + ((v & + 0xFF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00) >> + 8) | + ((v & + 0x00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF) << + 8); + v = + ((v & + 0xFFFF0000FFFF0000FFFF0000FFFF0000FFFF0000FFFF0000FFFF0000FFFF0000) >> + 16) | + ((v & + 0x0000FFFF0000FFFF0000FFFF0000FFFF0000FFFF0000FFFF0000FFFF0000FFFF) << + 16); + v = + ((v & + 0xFFFFFFFF00000000FFFFFFFF00000000FFFFFFFF00000000FFFFFFFF00000000) >> + 32) | + ((v & + 0x00000000FFFFFFFF00000000FFFFFFFF00000000FFFFFFFF00000000FFFFFFFF) << + 32); + v = + ((v & + 0xFFFFFFFFFFFFFFFF0000000000000000FFFFFFFFFFFFFFFF0000000000000000) >> + 64) | + ((v & + 0x0000000000000000FFFFFFFFFFFFFFFF0000000000000000FFFFFFFFFFFFFFFF) << + 64); + v = (v >> 128) | (v << 128); + return bytes32(v); + } + + /// @notice Modified version of `verify` from `MerkleProofLib` to support generalized indices and sha256 precompile. + /// @dev Returns whether `leaf` exists in the Merkle tree with `root`, given `proof`. + function verifyProof( + bytes32[] calldata proof, + bytes32 root, + bytes32 leaf, + GIndex index + ) internal view returns (bool isValid) { + /// @solidity memory-safe-assembly + assembly { + if proof.length { + // Left shift by 5 is equivalent to multiplying by 0x20. + let end := add(proof.offset, shl(5, proof.length)) + // Initialize `offset` to the offset of `proof` in the calldata. + let offset := proof.offset + // Iterate over proof elements to compute root hash. + // prettier-ignore + for { } 1 { } { + // Slot of `leaf` in scratch space. + // If the condition is true: 0x20, otherwise: 0x00. + let scratch := shl(5, and(index, 1)) + index := shr(1, index) + if iszero(index) { + // revert BranchHasExtraItem() + mstore(0x00, 0x5849603f) + // 0x1c = 28 => offset in 32-byte word of a slot 0x00 + revert(0x1c, 0x04) + } + // Store elements to hash contiguously in scratch space. + // Scratch space is 64 bytes (0x00 - 0x3f) and both elements are 32 bytes. + mstore(scratch, leaf) + mstore(xor(scratch, 0x20), calldataload(offset)) + // Call sha256 precompile. + let result := staticcall( + gas(), + 0x02, + 0x00, + 0x40, + 0x00, + 0x20 + ) + + if eq(result, 0) { + // Precompile returns no data on OutOfGas error. + revert(0, 0) + } + + // Reuse `leaf` to store the hash to reduce stack operations. + leaf := mload(0x00) + offset := add(offset, 0x20) + if iszero(lt(offset, end)) { + break + } + } + } + // index != 1 + if gt(sub(index, 1), 0) { + // revert BranchHasMissingItem() + mstore(0x00, 0x1b6661c3) + revert(0x1c, 0x04) + } + isValid := eq(leaf, root) + } + } +}