Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/contracts/interfaces/IECDSACertificateVerifier.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import "./IBaseCertificateVerifier.sol";
import "./IECDSATableCalculator.sol";

interface IECDSACertificateVerifierTypes is IECDSATableCalculatorTypes {
// Errors
error InvalidSignatureLength();

/**
* @notice A ECDSA Certificate
* @param referenceTimestamp the timestamp at which the certificate was created
Expand Down
310 changes: 310 additions & 0 deletions src/contracts/multichain/ECDSACertificateVerifier.sol
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 src/contracts/multichain/ECDSACertificateVerifierStorage.sol
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;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forget why we need this?

Copy link
Contributor Author

@eigenmikem eigenmikem Jun 16, 2025

Choose a reason for hiding this comment

The 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;
}
}
Loading