-
Notifications
You must be signed in to change notification settings - Fork 453
feat: ecdsa cert verifier #1470
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
eigenmikem
merged 6 commits into
release-dev/multichain
from
feat/mike-ecdsa-cert-verifier
Jun 17, 2025
Merged
Changes from 5 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
e11d077
feat: ecdsa cert verifier
eigenmikem 3895795
chore: cleanup
eigenmikem cc90653
refactor: pr comments
eigenmikem 3f2fd1d
refactor: change loop ordering for clarity
eigenmikem 5f0dfb9
Merge branch 'release-dev/multichain' into feat/mike-ecdsa-cert-verifier
eigenmikem 9e21b26
chore: bindings
eigenmikem File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,310 @@ | ||
| // SPDX-License-Identifier: BUSL-1.1 | ||
| pragma solidity ^0.8.27; | ||
|
|
||
| import "@openzeppelin-upgrades/contracts/proxy/utils/Initializable.sol"; | ||
| import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; | ||
|
|
||
| import {OperatorSet} from "../libraries/OperatorSetLib.sol"; | ||
| import "../mixins/SignatureUtilsMixin.sol"; | ||
| import "./ECDSACertificateVerifierStorage.sol"; | ||
|
|
||
| /** | ||
| * @title ECDSACertificateVerifier | ||
| * @notice Verifies ECDSA certificates across multiple operator sets | ||
| * @dev Implements ECDSA signature verification with operator information caching | ||
| */ | ||
| contract ECDSACertificateVerifier is Initializable, ECDSACertificateVerifierStorage, SignatureUtilsMixin { | ||
| using ECDSA for bytes32; | ||
|
|
||
| /** | ||
| * @notice Restricts access to the operator table updater | ||
| */ | ||
| modifier onlyTableUpdater() { | ||
| require(msg.sender == address(operatorTableUpdater), OnlyTableUpdater()); | ||
| _; | ||
| } | ||
|
|
||
| /** | ||
| * @notice Constructor for the certificate verifier | ||
| * @dev Disables initializers to prevent implementation initialization | ||
| * @param _operatorTableUpdater Address authorized to update operator tables | ||
| * @param _version The version string for the SignatureUtilsMixin | ||
| */ | ||
| constructor( | ||
| IOperatorTableUpdater _operatorTableUpdater, | ||
| string memory _version | ||
| ) ECDSACertificateVerifierStorage(_operatorTableUpdater) SignatureUtilsMixin(_version) { | ||
| _disableInitializers(); | ||
| } | ||
|
|
||
| /** | ||
| * @notice Override domainSeparator to not include chainId | ||
| * @return The domain separator hash without chainId | ||
| */ | ||
| function domainSeparator() public view override returns (bytes32) { | ||
| return keccak256( | ||
| abi.encode( | ||
| EIP712_DOMAIN_TYPEHASH_NO_CHAINID, | ||
| keccak256(bytes("EigenLayer")), | ||
| keccak256(bytes(_majorVersion())), | ||
| address(this) | ||
| ) | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * @notice Calculate the EIP-712 digest for a certificate | ||
| * @param referenceTimestamp The reference timestamp | ||
| * @param messageHash The message hash | ||
| * @return The EIP-712 digest | ||
| * @dev This function is public to allow offchain tools to calculate the same digest | ||
| * @dev Note: This does not support smart contract based signatures for multichain | ||
| */ | ||
| function calculateCertificateDigest(uint32 referenceTimestamp, bytes32 messageHash) public view returns (bytes32) { | ||
| bytes32 structHash = keccak256(abi.encode(ECDSA_CERTIFICATE_TYPEHASH, referenceTimestamp, messageHash)); | ||
| return _calculateSignableDigest(structHash); | ||
| } | ||
|
|
||
| ///@inheritdoc IBaseCertificateVerifier | ||
| function getOperatorSetOwner( | ||
| OperatorSet memory operatorSet | ||
| ) external view returns (address) { | ||
| bytes32 operatorSetKey = operatorSet.key(); | ||
| return _operatorSetOwners[operatorSetKey]; | ||
| } | ||
|
|
||
| ///@inheritdoc IBaseCertificateVerifier | ||
| function maxOperatorTableStaleness( | ||
| OperatorSet memory operatorSet | ||
| ) external view returns (uint32) { | ||
| bytes32 operatorSetKey = operatorSet.key(); | ||
| return _maxStalenessPeriods[operatorSetKey]; | ||
| } | ||
|
|
||
| ///@inheritdoc IBaseCertificateVerifier | ||
| function latestReferenceTimestamp( | ||
| OperatorSet memory operatorSet | ||
| ) external view returns (uint32) { | ||
| bytes32 operatorSetKey = operatorSet.key(); | ||
| return _latestReferenceTimestamps[operatorSetKey]; | ||
| } | ||
|
|
||
| ///@inheritdoc IECDSACertificateVerifier | ||
| function updateOperatorTable( | ||
| OperatorSet calldata operatorSet, | ||
| uint32 referenceTimestamp, | ||
| ECDSAOperatorInfo[] calldata operatorInfos, | ||
| OperatorSetConfig calldata operatorSetConfig | ||
| ) external onlyTableUpdater { | ||
| bytes32 operatorSetKey = operatorSet.key(); | ||
|
|
||
| // Validate that the new timestamp is greater than the latest reference timestamp | ||
| require(referenceTimestamp > _latestReferenceTimestamps[operatorSetKey], TableUpdateStale()); | ||
|
|
||
| // Store the number of operators | ||
| _numOperators[operatorSetKey][referenceTimestamp] = operatorInfos.length; | ||
|
|
||
| // Store each operator info in the indexed mapping | ||
| for (uint256 i = 0; i < operatorInfos.length; i++) { | ||
| _operatorInfos[operatorSetKey][referenceTimestamp][uint32(i)] = operatorInfos[i]; | ||
| } | ||
|
|
||
| _latestReferenceTimestamps[operatorSetKey] = referenceTimestamp; | ||
| _operatorSetOwners[operatorSetKey] = operatorSetConfig.owner; | ||
| _maxStalenessPeriods[operatorSetKey] = operatorSetConfig.maxStalenessPeriod; | ||
|
|
||
| emit TableUpdated(operatorSet, referenceTimestamp, operatorInfos); | ||
| } | ||
|
|
||
| /** | ||
| * @notice Internal function to verify a certificate | ||
| * @param cert The certificate to verify | ||
| * @return signedStakes The amount of stake that signed the certificate for each stake type | ||
| */ | ||
| function _verifyECDSACertificate( | ||
| OperatorSet calldata operatorSet, | ||
| ECDSACertificate calldata cert | ||
| ) internal view returns (uint256[] memory) { | ||
| bytes32 operatorSetKey = operatorSet.key(); | ||
|
|
||
| // Assert that reference timestamp is not stale | ||
| require(block.timestamp <= cert.referenceTimestamp + _maxStalenessPeriods[operatorSetKey], CertificateStale()); | ||
|
|
||
| // Assert that the reference timestamp exists | ||
| require(_latestReferenceTimestamps[operatorSetKey] == cert.referenceTimestamp, ReferenceTimestampDoesNotExist()); | ||
|
|
||
| // Get the total stakes | ||
| uint256[] memory totalStakes = _getTotalStakes(operatorSet, cert.referenceTimestamp); | ||
| uint256[] memory signedStakes = new uint256[](totalStakes.length); | ||
|
|
||
| // Compute the EIP-712 digest for signature recovery | ||
| bytes32 signableDigest = calculateCertificateDigest(cert.referenceTimestamp, cert.messageHash); | ||
|
|
||
| // Parse the signatures | ||
| (address[] memory signers, bool validSignatures) = _parseSignatures(signableDigest, cert.sig); | ||
| require(validSignatures, VerificationFailed()); | ||
|
|
||
| // Process each recovered signer | ||
| for (uint256 i = 0; i < signers.length; i++) { | ||
| address signer = signers[i]; | ||
|
|
||
| // Check if this signer is an operator | ||
| bool isOperator = false; | ||
| ECDSAOperatorInfo memory operatorInfo; | ||
|
|
||
| for (uint256 j = 0; j < _numOperators[operatorSetKey][cert.referenceTimestamp]; j++) { | ||
| operatorInfo = _operatorInfos[operatorSetKey][cert.referenceTimestamp][uint32(j)]; | ||
| if (operatorInfo.pubkey == signer) { | ||
| isOperator = true; | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| // If not an operator, the certificate is invalid | ||
| if (!isOperator) { | ||
| revert VerificationFailed(); | ||
| } | ||
|
|
||
| // Add this operator's weights to the signed stakes | ||
| uint256[] memory weights = operatorInfo.weights; | ||
| for (uint256 j = 0; j < weights.length && j < signedStakes.length; j++) { | ||
| signedStakes[j] += weights[j]; | ||
| } | ||
| } | ||
|
|
||
| return signedStakes; | ||
| } | ||
|
|
||
| ///@inheritdoc IECDSACertificateVerifier | ||
| function verifyCertificate( | ||
| OperatorSet calldata operatorSet, | ||
| ECDSACertificate calldata cert | ||
| ) external view returns (uint256[] memory) { | ||
| return _verifyECDSACertificate(operatorSet, cert); | ||
| } | ||
|
|
||
| ///@inheritdoc IECDSACertificateVerifier | ||
| function verifyCertificateProportion( | ||
| OperatorSet calldata operatorSet, | ||
| ECDSACertificate calldata cert, | ||
| uint16[] calldata totalStakeProportionThresholds | ||
| ) external view returns (bool) { | ||
| uint256[] memory signedStakes = _verifyECDSACertificate(operatorSet, cert); | ||
| uint256[] memory totalStakes = _getTotalStakes(operatorSet, cert.referenceTimestamp); | ||
| require(signedStakes.length == totalStakeProportionThresholds.length, ArrayLengthMismatch()); | ||
| for (uint256 i = 0; i < signedStakes.length; i++) { | ||
| uint256 threshold = (totalStakes[i] * totalStakeProportionThresholds[i]) / 10_000; | ||
| if (signedStakes[i] < threshold) { | ||
| return false; | ||
| } | ||
| } | ||
| return true; | ||
| } | ||
|
|
||
| ///@inheritdoc IECDSACertificateVerifier | ||
| function verifyCertificateNominal( | ||
| OperatorSet calldata operatorSet, | ||
| ECDSACertificate calldata cert, | ||
| uint256[] memory totalStakeNominalThresholds | ||
| ) external view returns (bool) { | ||
| uint256[] memory signedStakes = _verifyECDSACertificate(operatorSet, cert); | ||
| if (signedStakes.length != totalStakeNominalThresholds.length) revert ArrayLengthMismatch(); | ||
| for (uint256 i = 0; i < signedStakes.length; i++) { | ||
| if (signedStakes[i] < totalStakeNominalThresholds[i]) { | ||
| return false; | ||
| } | ||
| } | ||
| return true; | ||
| } | ||
|
|
||
| /** | ||
| * @notice Parse signatures from the concatenated signature bytes | ||
| * @param messageHash The message hash that was signed | ||
| * @param signatures The concatenated signatures | ||
| * @return signers Array of addresses that signed the message | ||
| * @return valid Whether all signatures are valid | ||
| * @dev Signatures must be ordered by signer address (ascending) | ||
| * @dev This does not support smart contract based signatures for multichain | ||
| */ | ||
| function _parseSignatures( | ||
| bytes32 messageHash, | ||
| bytes memory signatures | ||
| ) internal view returns (address[] memory signers, bool valid) { | ||
| // Each ECDSA signature is 65 bytes: r (32 bytes) + s (32 bytes) + v (1 byte) | ||
| if (signatures.length % 65 != 0) revert InvalidSignatureLength(); | ||
|
|
||
| uint256 signatureCount = signatures.length / 65; | ||
| signers = new address[](signatureCount); | ||
|
|
||
| for (uint256 i = 0; i < signatureCount; i++) { | ||
| bytes memory signature = new bytes(65); | ||
| for (uint256 j = 0; j < 65; j++) { | ||
| signature[j] = signatures[i * 65 + j]; | ||
| } | ||
|
|
||
| // Recover the signer | ||
| (address recovered, ECDSA.RecoverError error) = ECDSA.tryRecover(messageHash, signature); | ||
| if (error != ECDSA.RecoverError.NoError || recovered == address(0)) { | ||
| return (signers, false); | ||
| } | ||
|
|
||
| // Check that signatures are ordered by signer address | ||
| if (i > 0 && recovered <= signers[i - 1]) { | ||
| return (signers, false); | ||
| } | ||
|
|
||
| // Verify that the recovered address actually signed the message | ||
| _checkIsValidSignatureNow(recovered, messageHash, signature, type(uint256).max); | ||
|
|
||
| signers[i] = recovered; | ||
| } | ||
|
|
||
| return (signers, true); | ||
| } | ||
|
|
||
| /** | ||
| * @notice Get operator infos for a timestamp | ||
| * @param operatorSet The operator set | ||
| * @param referenceTimestamp The reference timestamp | ||
| * @return The operator infos | ||
| */ | ||
| function getOperatorInfos( | ||
| OperatorSet memory operatorSet, | ||
| uint32 referenceTimestamp | ||
| ) external view returns (ECDSAOperatorInfo[] memory) { | ||
| bytes32 operatorSetKey = operatorSet.key(); | ||
| uint32 numOperators = uint32(_numOperators[operatorSetKey][referenceTimestamp]); | ||
| ECDSAOperatorInfo[] memory operatorInfos = new ECDSAOperatorInfo[](numOperators); | ||
|
|
||
| for (uint32 i = 0; i < numOperators; i++) { | ||
| operatorInfos[i] = _operatorInfos[operatorSetKey][referenceTimestamp][i]; | ||
| } | ||
|
|
||
| return operatorInfos; | ||
| } | ||
|
|
||
| /** | ||
| * @notice Calculate the total stakes for all operators at a given reference timestamp | ||
| * @param operatorSet The operator set to calculate stakes for | ||
| * @param referenceTimestamp The reference timestamp | ||
| * @return totalStakes The total stakes for all operators | ||
| */ | ||
| function _getTotalStakes( | ||
| OperatorSet calldata operatorSet, | ||
| uint32 referenceTimestamp | ||
| ) internal view returns (uint256[] memory totalStakes) { | ||
| bytes32 operatorSetKey = operatorSet.key(); | ||
| require(_latestReferenceTimestamps[operatorSetKey] == referenceTimestamp, ReferenceTimestampDoesNotExist()); | ||
| uint256 operatorCount = _numOperators[operatorSetKey][referenceTimestamp]; | ||
| require(operatorCount > 0, ReferenceTimestampDoesNotExist()); | ||
| uint256 stakeTypesCount = _operatorInfos[operatorSetKey][referenceTimestamp][0].weights.length; | ||
| totalStakes = new uint256[](stakeTypesCount); | ||
| for (uint256 i = 0; i < operatorCount; i++) { | ||
| uint256[] memory weights = _operatorInfos[operatorSetKey][referenceTimestamp][uint32(i)].weights; | ||
| for (uint256 j = 0; j < weights.length && j < stakeTypesCount; j++) { | ||
| totalStakes[j] += weights[j]; | ||
| } | ||
| } | ||
| return totalStakes; | ||
| } | ||
| } | ||
54 changes: 54 additions & 0 deletions
54
src/contracts/multichain/ECDSACertificateVerifierStorage.sol
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| // SPDX-License-Identifier: BUSL-1.1 | ||
| pragma solidity ^0.8.27; | ||
|
|
||
| import {OperatorSet} from "../libraries/OperatorSetLib.sol"; | ||
| import "../interfaces/IOperatorTableUpdater.sol"; | ||
| import "../interfaces/IECDSATableCalculator.sol"; | ||
| import "../interfaces/IECDSACertificateVerifier.sol"; | ||
| import "../interfaces/IBaseCertificateVerifier.sol"; | ||
|
|
||
| abstract contract ECDSACertificateVerifierStorage is IECDSACertificateVerifier { | ||
| // Constants | ||
|
|
||
| /// @dev Basis point unit denominator for division | ||
| uint256 internal constant BPS_DENOMINATOR = 10_000; | ||
|
|
||
| /// @dev EIP-712 type hash for certificate verification | ||
| bytes32 internal constant ECDSA_CERTIFICATE_TYPEHASH = | ||
| keccak256("ECDSACertificate(uint32 referenceTimestamp,bytes32 messageHash)"); | ||
|
|
||
| /// @dev The EIP-712 domain type hash used for computing the domain separator without chainId | ||
| bytes32 internal constant EIP712_DOMAIN_TYPEHASH_NO_CHAINID = | ||
| keccak256("EIP712Domain(string name,string version,address verifyingContract)"); | ||
|
|
||
| // Immutables | ||
|
|
||
| /// @dev The address that can update operator tables | ||
| IOperatorTableUpdater public immutable operatorTableUpdater; | ||
|
|
||
| // Mutatables | ||
|
|
||
| /// @dev Mapping from operatorSet key to owner address | ||
| mapping(bytes32 => address) internal _operatorSetOwners; | ||
|
|
||
| /// @dev Mapping from operatorSet key to maximum staleness period | ||
| mapping(bytes32 => uint32) internal _maxStalenessPeriods; | ||
|
|
||
| /// @dev Mapping from operatorSet key to latest reference timestamp | ||
| mapping(bytes32 => uint32) internal _latestReferenceTimestamps; | ||
|
|
||
| /// @dev Mapping from referenceTimestamp to the number of operators | ||
| mapping(bytes32 operatorSetKey => mapping(uint32 referenceTimestamp => uint256 numOperators)) internal _numOperators; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Forget why we need this?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Copying arrays of custom types into storage causes lots of issues, easier to just store in indexed mapping but then need this |
||
|
|
||
| /// @dev Mapping from operatorSetKey to referenceTimestamp to operatorInfos | ||
| mapping(bytes32 operatorSetKey => mapping(uint32 referenceTimestamp => mapping(uint32 => ECDSAOperatorInfo))) | ||
| internal _operatorInfos; | ||
|
|
||
| // Construction | ||
|
|
||
| constructor( | ||
| IOperatorTableUpdater _operatorTableUpdater | ||
| ) { | ||
| operatorTableUpdater = _operatorTableUpdater; | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.