Update multisig ISMs for merkle hooks (#2722)
Fixes https://github.com/hyperlane-xyz/issues/issues/591pull/2736/head
parent
f4b12a438b
commit
f7dbc71335
@ -1,359 +0,0 @@ |
||||
// SPDX-License-Identifier: MIT OR Apache-2.0 |
||||
pragma solidity >=0.8.0; |
||||
|
||||
// ============ External Imports ============ |
||||
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; |
||||
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; |
||||
import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; |
||||
|
||||
// ============ Internal Imports ============ |
||||
import {IInterchainSecurityModule} from "../../interfaces/IInterchainSecurityModule.sol"; |
||||
import {Message} from "../../libs/Message.sol"; |
||||
import {IMultisigIsm} from "../../interfaces/isms/IMultisigIsm.sol"; |
||||
import {LegacyMultisigIsmMetadata} from "../../libs/isms/LegacyMultisigIsmMetadata.sol"; |
||||
import {MerkleLib} from "../../libs/Merkle.sol"; |
||||
import {LegacyCheckpointLib} from "../../libs/LegacyCheckpointLib.sol"; |
||||
|
||||
/** |
||||
* @title MultisigIsm |
||||
* @notice Manages an ownable set of validators that ECDSA sign checkpoints to |
||||
* reach a quorum. |
||||
*/ |
||||
contract LegacyMultisigIsm is IMultisigIsm, Ownable { |
||||
// ============ Libraries ============ |
||||
|
||||
using EnumerableSet for EnumerableSet.AddressSet; |
||||
using Message for bytes; |
||||
using LegacyMultisigIsmMetadata for bytes; |
||||
using MerkleLib for MerkleLib.Tree; |
||||
|
||||
// ============ Constants ============ |
||||
|
||||
// solhint-disable-next-line const-name-snakecase |
||||
uint8 public constant moduleType = |
||||
uint8(IInterchainSecurityModule.Types.LEGACY_MULTISIG); |
||||
|
||||
// ============ Mutable Storage ============ |
||||
|
||||
/// @notice The validator threshold for each remote domain. |
||||
mapping(uint32 => uint8) public threshold; |
||||
|
||||
/// @notice The validator set for each remote domain. |
||||
mapping(uint32 => EnumerableSet.AddressSet) private validatorSet; |
||||
|
||||
/// @notice A succinct commitment to the validator set and threshold for each remote |
||||
/// domain. |
||||
mapping(uint32 => bytes32) public commitment; |
||||
|
||||
// ============ Events ============ |
||||
|
||||
/** |
||||
* @notice Emitted when a validator is enrolled in a validator set. |
||||
* @param domain The remote domain of the validator set. |
||||
* @param validator The address of the validator. |
||||
* @param validatorCount The number of enrolled validators in the validator set. |
||||
*/ |
||||
event ValidatorEnrolled( |
||||
uint32 indexed domain, |
||||
address indexed validator, |
||||
uint256 validatorCount |
||||
); |
||||
|
||||
/** |
||||
* @notice Emitted when a validator is unenrolled from a validator set. |
||||
* @param domain The remote domain of the validator set. |
||||
* @param validator The address of the validator. |
||||
* @param validatorCount The number of enrolled validators in the validator set. |
||||
*/ |
||||
event ValidatorUnenrolled( |
||||
uint32 indexed domain, |
||||
address indexed validator, |
||||
uint256 validatorCount |
||||
); |
||||
|
||||
/** |
||||
* @notice Emitted when the quorum threshold is set. |
||||
* @param domain The remote domain of the validator set. |
||||
* @param threshold The new quorum threshold. |
||||
*/ |
||||
event ThresholdSet(uint32 indexed domain, uint8 threshold); |
||||
|
||||
/** |
||||
* @notice Emitted when the validator set or threshold changes. |
||||
* @param domain The remote domain of the validator set. |
||||
* @param commitment A commitment to the validator set and threshold. |
||||
*/ |
||||
event CommitmentUpdated(uint32 domain, bytes32 commitment); |
||||
|
||||
// ============ Constructor ============ |
||||
|
||||
// solhint-disable-next-line no-empty-blocks |
||||
constructor() Ownable() {} |
||||
|
||||
// ============ External Functions ============ |
||||
|
||||
/** |
||||
* @notice Enrolls multiple validators into a validator set. |
||||
* @dev Reverts if `_validator` is already in the validator set. |
||||
* @param _domains The remote domains of the validator sets. |
||||
* @param _validators The validators to add to the validator sets. |
||||
* @dev _validators[i] are the validators to enroll for _domains[i]. |
||||
*/ |
||||
function enrollValidators( |
||||
uint32[] calldata _domains, |
||||
address[][] calldata _validators |
||||
) external onlyOwner { |
||||
uint256 domainsLength = _domains.length; |
||||
require(domainsLength == _validators.length, "!length"); |
||||
for (uint256 i = 0; i < domainsLength; i += 1) { |
||||
address[] calldata _domainValidators = _validators[i]; |
||||
uint256 validatorsLength = _domainValidators.length; |
||||
for (uint256 j = 0; j < validatorsLength; j += 1) { |
||||
_enrollValidator(_domains[i], _domainValidators[j]); |
||||
} |
||||
_updateCommitment(_domains[i]); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* @notice Enrolls a validator into a validator set. |
||||
* @dev Reverts if `_validator` is already in the validator set. |
||||
* @param _domain The remote domain of the validator set. |
||||
* @param _validator The validator to add to the validator set. |
||||
*/ |
||||
function enrollValidator(uint32 _domain, address _validator) |
||||
external |
||||
onlyOwner |
||||
{ |
||||
_enrollValidator(_domain, _validator); |
||||
_updateCommitment(_domain); |
||||
} |
||||
|
||||
/** |
||||
* @notice Unenrolls a validator from a validator set. |
||||
* @dev Reverts if `_validator` is not in the validator set. |
||||
* @param _domain The remote domain of the validator set. |
||||
* @param _validator The validator to remove from the validator set. |
||||
*/ |
||||
function unenrollValidator(uint32 _domain, address _validator) |
||||
external |
||||
onlyOwner |
||||
{ |
||||
require(validatorSet[_domain].remove(_validator), "!enrolled"); |
||||
uint256 _validatorCount = validatorCount(_domain); |
||||
require( |
||||
_validatorCount >= threshold[_domain], |
||||
"violates quorum threshold" |
||||
); |
||||
_updateCommitment(_domain); |
||||
emit ValidatorUnenrolled(_domain, _validator, _validatorCount); |
||||
} |
||||
|
||||
/** |
||||
* @notice Sets the quorum threshold for multiple domains. |
||||
* @param _domains The remote domains of the validator sets. |
||||
* @param _thresholds The new quorum thresholds. |
||||
*/ |
||||
function setThresholds( |
||||
uint32[] calldata _domains, |
||||
uint8[] calldata _thresholds |
||||
) external onlyOwner { |
||||
uint256 length = _domains.length; |
||||
require(length == _thresholds.length, "!length"); |
||||
for (uint256 i = 0; i < length; i += 1) { |
||||
setThreshold(_domains[i], _thresholds[i]); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* @notice Returns whether an address is enrolled in a validator set. |
||||
* @param _domain The remote domain of the validator set. |
||||
* @param _address The address to test for set membership. |
||||
* @return True if the address is enrolled, false otherwise. |
||||
*/ |
||||
function isEnrolled(uint32 _domain, address _address) |
||||
external |
||||
view |
||||
returns (bool) |
||||
{ |
||||
EnumerableSet.AddressSet storage _validatorSet = validatorSet[_domain]; |
||||
return _validatorSet.contains(_address); |
||||
} |
||||
|
||||
// ============ Public Functions ============ |
||||
|
||||
/** |
||||
* @notice Sets the quorum threshold. |
||||
* @param _domain The remote domain of the validator set. |
||||
* @param _threshold The new quorum threshold. |
||||
*/ |
||||
function setThreshold(uint32 _domain, uint8 _threshold) public onlyOwner { |
||||
require( |
||||
_threshold > 0 && _threshold <= validatorCount(_domain), |
||||
"!range" |
||||
); |
||||
threshold[_domain] = _threshold; |
||||
emit ThresholdSet(_domain, _threshold); |
||||
|
||||
_updateCommitment(_domain); |
||||
} |
||||
|
||||
/** |
||||
* @notice Verifies that a quorum of the origin domain's validators signed |
||||
* a checkpoint, and verifies the merkle proof of `_message` against that |
||||
* checkpoint. |
||||
* @param _metadata ABI encoded module metadata (see LegacyMultisigIsmMetadata.sol) |
||||
* @param _message Formatted Hyperlane message (see Message.sol). |
||||
*/ |
||||
function verify(bytes calldata _metadata, bytes calldata _message) |
||||
external |
||||
view |
||||
returns (bool) |
||||
{ |
||||
require(_verifyMerkleProof(_metadata, _message), "!merkle"); |
||||
require(_verifyValidatorSignatures(_metadata, _message), "!sigs"); |
||||
return true; |
||||
} |
||||
|
||||
/** |
||||
* @notice Gets the current validator set |
||||
* @param _domain The remote domain of the validator set. |
||||
* @return The addresses of the validator set. |
||||
*/ |
||||
function validators(uint32 _domain) public view returns (address[] memory) { |
||||
EnumerableSet.AddressSet storage _validatorSet = validatorSet[_domain]; |
||||
uint256 _validatorCount = _validatorSet.length(); |
||||
address[] memory _validators = new address[](_validatorCount); |
||||
for (uint256 i = 0; i < _validatorCount; i++) { |
||||
_validators[i] = _validatorSet.at(i); |
||||
} |
||||
return _validators; |
||||
} |
||||
|
||||
/** |
||||
* @notice Returns the set of validators responsible for verifying _message |
||||
* and the number of signatures required |
||||
* @dev Can change based on the content of _message |
||||
* @param _message Hyperlane formatted interchain message |
||||
* @return validators The array of validator addresses |
||||
* @return threshold The number of validator signatures needed |
||||
*/ |
||||
function validatorsAndThreshold(bytes calldata _message) |
||||
external |
||||
view |
||||
returns (address[] memory, uint8) |
||||
{ |
||||
uint32 _origin = _message.origin(); |
||||
address[] memory _validators = validators(_origin); |
||||
uint8 _threshold = threshold[_origin]; |
||||
return (_validators, _threshold); |
||||
} |
||||
|
||||
/** |
||||
* @notice Returns the number of validators enrolled in the validator set. |
||||
* @param _domain The remote domain of the validator set. |
||||
* @return The number of validators enrolled in the validator set. |
||||
*/ |
||||
function validatorCount(uint32 _domain) public view returns (uint256) { |
||||
return validatorSet[_domain].length(); |
||||
} |
||||
|
||||
// ============ Internal Functions ============ |
||||
|
||||
/** |
||||
* @notice Enrolls a validator into a validator set. |
||||
* @dev Reverts if `_validator` is already in the validator set. |
||||
* @param _domain The remote domain of the validator set. |
||||
* @param _validator The validator to add to the validator set. |
||||
*/ |
||||
function _enrollValidator(uint32 _domain, address _validator) internal { |
||||
require(_validator != address(0), "zero address"); |
||||
require(validatorSet[_domain].add(_validator), "already enrolled"); |
||||
emit ValidatorEnrolled(_domain, _validator, validatorCount(_domain)); |
||||
} |
||||
|
||||
/** |
||||
* @notice Updates the commitment to the validator set for `_domain`. |
||||
* @param _domain The remote domain of the validator set. |
||||
* @return The commitment to the validator set for `_domain`. |
||||
*/ |
||||
function _updateCommitment(uint32 _domain) internal returns (bytes32) { |
||||
address[] memory _validators = validators(_domain); |
||||
uint8 _threshold = threshold[_domain]; |
||||
bytes32 _commitment = keccak256( |
||||
abi.encodePacked(_threshold, _validators) |
||||
); |
||||
commitment[_domain] = _commitment; |
||||
emit CommitmentUpdated(_domain, _commitment); |
||||
return _commitment; |
||||
} |
||||
|
||||
/** |
||||
* @notice Verifies the merkle proof of `_message` against the provided |
||||
* checkpoint. |
||||
* @param _metadata ABI encoded module metadata (see LegacyMultisigIsmMetadata.sol) |
||||
* @param _message Formatted Hyperlane message (see Message.sol). |
||||
*/ |
||||
function _verifyMerkleProof( |
||||
bytes calldata _metadata, |
||||
bytes calldata _message |
||||
) internal pure returns (bool) { |
||||
// calculate the expected root based on the proof |
||||
bytes32 _calculatedRoot = MerkleLib.branchRoot( |
||||
_message.id(), |
||||
_metadata.proof(), |
||||
_message.nonce() |
||||
); |
||||
return _calculatedRoot == _metadata.root(); |
||||
} |
||||
|
||||
/** |
||||
* @notice Verifies that a quorum of the origin domain's validators signed |
||||
* the provided checkpoint. |
||||
* @param _metadata ABI encoded module metadata (see LegacyMultisigIsmMetadata.sol) |
||||
* @param _message Formatted Hyperlane message (see Message.sol). |
||||
*/ |
||||
function _verifyValidatorSignatures( |
||||
bytes calldata _metadata, |
||||
bytes calldata _message |
||||
) internal view returns (bool) { |
||||
uint8 _threshold = _metadata.threshold(); |
||||
bytes32 _digest; |
||||
{ |
||||
uint32 _origin = _message.origin(); |
||||
|
||||
bytes32 _commitment = keccak256( |
||||
abi.encodePacked(_threshold, _metadata.validators()) |
||||
); |
||||
// Ensures the validator set encoded in the metadata matches |
||||
// what we've stored on chain. |
||||
// NB: An empty validator set in `_metadata` will result in a |
||||
// non-zero computed commitment, and this check will fail |
||||
// as the commitment in storage will be zero. |
||||
require(_commitment == commitment[_origin], "!commitment"); |
||||
_digest = LegacyCheckpointLib.digest( |
||||
_origin, |
||||
LegacyMultisigIsmMetadata.originMailbox(_metadata), |
||||
LegacyMultisigIsmMetadata.root(_metadata), |
||||
LegacyMultisigIsmMetadata.index(_metadata) |
||||
); |
||||
} |
||||
uint256 _validatorCount = _metadata.validatorCount(); |
||||
uint256 _validatorIndex = 0; |
||||
// Assumes that signatures are ordered by validator |
||||
for (uint256 i = 0; i < _threshold; ++i) { |
||||
address _signer = ECDSA.recover(_digest, _metadata.signatureAt(i)); |
||||
// Loop through remaining validators until we find a match |
||||
while ( |
||||
_validatorIndex < _validatorCount && |
||||
_signer != _metadata.validatorAt(_validatorIndex) |
||||
) { |
||||
++_validatorIndex; |
||||
} |
||||
// Fail if we never found a match |
||||
require(_validatorIndex < _validatorCount, "!threshold"); |
||||
++_validatorIndex; |
||||
} |
||||
return true; |
||||
} |
||||
} |
@ -1,50 +0,0 @@ |
||||
// SPDX-License-Identifier: MIT OR Apache-2.0 |
||||
pragma solidity >=0.8.0; |
||||
|
||||
// ============ External Imports ============ |
||||
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; |
||||
|
||||
// ============ Internal Imports ============ |
||||
import {CheckpointLib} from "./CheckpointLib.sol"; |
||||
|
||||
library LegacyCheckpointLib { |
||||
/** |
||||
* @notice Returns the digest validators are expected to sign when signing legacy checkpoints. |
||||
* @param _origin The origin domain of the checkpoint. |
||||
* @param _originMailbox The address of the origin mailbox as bytes32. |
||||
* @return The digest of the legacy checkpoint. |
||||
*/ |
||||
function digest( |
||||
uint32 _origin, |
||||
bytes32 _originMailbox, |
||||
bytes32 _checkpointRoot, |
||||
uint32 _checkpointIndex |
||||
) internal pure returns (bytes32) { |
||||
bytes32 _domainHash = domainHash(_origin, _originMailbox); |
||||
return |
||||
ECDSA.toEthSignedMessageHash( |
||||
keccak256( |
||||
abi.encodePacked( |
||||
_domainHash, |
||||
_checkpointRoot, |
||||
_checkpointIndex |
||||
) |
||||
) |
||||
); |
||||
} |
||||
|
||||
/** |
||||
* @notice Returns the domain hash that validators are expected to use |
||||
* when signing checkpoints. |
||||
* @param _origin The origin domain of the checkpoint. |
||||
* @param _originMailbox The address of the origin mailbox as bytes32. |
||||
* @return The domain hash. |
||||
*/ |
||||
function domainHash(uint32 _origin, bytes32 _originMailbox) |
||||
internal |
||||
pure |
||||
returns (bytes32) |
||||
{ |
||||
return CheckpointLib.domainHash(_origin, _originMailbox); |
||||
} |
||||
} |
@ -1,170 +0,0 @@ |
||||
// SPDX-License-Identifier: MIT OR Apache-2.0 |
||||
pragma solidity >=0.8.0; |
||||
|
||||
/** |
||||
* Format of metadata: |
||||
* [ 0: 32] Merkle root |
||||
* [ 32: 36] Root index |
||||
* [ 36: 68] Origin mailbox address |
||||
* [ 68:1092] Merkle proof |
||||
* [1092:1093] Threshold |
||||
* [1093:????] Validator signatures, 65 bytes each, length == Threshold |
||||
* [????:????] Addresses of the entire validator set, left padded to bytes32 |
||||
*/ |
||||
library LegacyMultisigIsmMetadata { |
||||
uint256 private constant MERKLE_ROOT_OFFSET = 0; |
||||
uint256 private constant MERKLE_INDEX_OFFSET = 32; |
||||
uint256 private constant ORIGIN_MAILBOX_OFFSET = 36; |
||||
uint256 private constant MERKLE_PROOF_OFFSET = 68; |
||||
uint256 private constant THRESHOLD_OFFSET = 1092; |
||||
uint256 private constant SIGNATURES_OFFSET = 1093; |
||||
uint256 private constant SIGNATURE_LENGTH = 65; |
||||
|
||||
/** |
||||
* @notice Returns the merkle root of the signed checkpoint. |
||||
* @param _metadata ABI encoded Multisig ISM metadata. |
||||
* @return Merkle root of the signed checkpoint |
||||
*/ |
||||
function root(bytes calldata _metadata) internal pure returns (bytes32) { |
||||
return bytes32(_metadata[MERKLE_ROOT_OFFSET:MERKLE_INDEX_OFFSET]); |
||||
} |
||||
|
||||
/** |
||||
* @notice Returns the index of the signed checkpoint. |
||||
* @param _metadata ABI encoded Multisig ISM metadata. |
||||
* @return Index of the signed checkpoint |
||||
*/ |
||||
function index(bytes calldata _metadata) internal pure returns (uint32) { |
||||
return |
||||
uint32( |
||||
bytes4(_metadata[MERKLE_INDEX_OFFSET:ORIGIN_MAILBOX_OFFSET]) |
||||
); |
||||
} |
||||
|
||||
/** |
||||
* @notice Returns the origin mailbox of the signed checkpoint as bytes32. |
||||
* @param _metadata ABI encoded Multisig ISM metadata. |
||||
* @return Origin mailbox of the signed checkpoint as bytes32 |
||||
*/ |
||||
function originMailbox(bytes calldata _metadata) |
||||
internal |
||||
pure |
||||
returns (bytes32) |
||||
{ |
||||
return bytes32(_metadata[ORIGIN_MAILBOX_OFFSET:MERKLE_PROOF_OFFSET]); |
||||
} |
||||
|
||||
/** |
||||
* @notice Returns the merkle proof branch of the message. |
||||
* @dev This appears to be more gas efficient than returning a calldata |
||||
* slice and using that. |
||||
* @param _metadata ABI encoded Multisig ISM metadata. |
||||
* @return Merkle proof branch of the message. |
||||
*/ |
||||
function proof(bytes calldata _metadata) |
||||
internal |
||||
pure |
||||
returns (bytes32[32] memory) |
||||
{ |
||||
return |
||||
abi.decode( |
||||
_metadata[MERKLE_PROOF_OFFSET:THRESHOLD_OFFSET], |
||||
(bytes32[32]) |
||||
); |
||||
} |
||||
|
||||
/** |
||||
* @notice Returns the number of required signatures. Verified against |
||||
* the commitment stored in the module. |
||||
* @param _metadata ABI encoded Multisig ISM metadata. |
||||
* @return The number of required signatures. |
||||
*/ |
||||
function threshold(bytes calldata _metadata) internal pure returns (uint8) { |
||||
return uint8(bytes1(_metadata[THRESHOLD_OFFSET:SIGNATURES_OFFSET])); |
||||
} |
||||
|
||||
/** |
||||
* @notice Returns the validator ECDSA signature at `_index`. |
||||
* @dev Assumes signatures are sorted by validator |
||||
* @dev Assumes `_metadata` encodes `threshold` signatures. |
||||
* @dev Assumes `_index` is less than `threshold` |
||||
* @param _metadata ABI encoded Multisig ISM metadata. |
||||
* @param _index The index of the signature to return. |
||||
* @return The validator ECDSA signature at `_index`. |
||||
*/ |
||||
function signatureAt(bytes calldata _metadata, uint256 _index) |
||||
internal |
||||
pure |
||||
returns (bytes calldata) |
||||
{ |
||||
uint256 _start = SIGNATURES_OFFSET + (_index * SIGNATURE_LENGTH); |
||||
uint256 _end = _start + SIGNATURE_LENGTH; |
||||
return _metadata[_start:_end]; |
||||
} |
||||
|
||||
/** |
||||
* @notice Returns the validator address at `_index`. |
||||
* @dev Assumes `_index` is less than the number of validators |
||||
* @param _metadata ABI encoded Multisig ISM metadata. |
||||
* @param _index The index of the validator to return. |
||||
* @return The validator address at `_index`. |
||||
*/ |
||||
function validatorAt(bytes calldata _metadata, uint256 _index) |
||||
internal |
||||
pure |
||||
returns (address) |
||||
{ |
||||
// Validator addresses are left padded to bytes32 in order to match |
||||
// abi.encodePacked(address[]). |
||||
uint256 _start = _validatorsOffset(_metadata) + (_index * 32) + 12; |
||||
uint256 _end = _start + 20; |
||||
return address(bytes20(_metadata[_start:_end])); |
||||
} |
||||
|
||||
/** |
||||
* @notice Returns the validator set encoded as bytes. Verified against the |
||||
* commitment stored in the module. |
||||
* @dev Validator addresses are encoded as tightly packed array of bytes32, |
||||
* sorted to match the enumerable set stored by the module. |
||||
* @param _metadata ABI encoded Multisig ISM metadata. |
||||
* @return The validator set encoded as bytes. |
||||
*/ |
||||
function validators(bytes calldata _metadata) |
||||
internal |
||||
pure |
||||
returns (bytes calldata) |
||||
{ |
||||
return _metadata[_validatorsOffset(_metadata):]; |
||||
} |
||||
|
||||
/** |
||||
* @notice Returns the size of the validator set encoded in the metadata |
||||
* @dev Validator addresses are encoded as tightly packed array of bytes32, |
||||
* sorted to match the enumerable set stored by the module. |
||||
* @param _metadata ABI encoded Multisig ISM metadata. |
||||
* @return The size of the validator set encoded in the metadata |
||||
*/ |
||||
function validatorCount(bytes calldata _metadata) |
||||
internal |
||||
pure |
||||
returns (uint256) |
||||
{ |
||||
return (_metadata.length - _validatorsOffset(_metadata)) / 32; |
||||
} |
||||
|
||||
/** |
||||
* @notice Returns the offset in bytes of the list of validators within |
||||
* `_metadata`. |
||||
* @param _metadata ABI encoded Multisig ISM metadata. |
||||
* @return The index at which the list of validators starts |
||||
*/ |
||||
function _validatorsOffset(bytes calldata _metadata) |
||||
private |
||||
pure |
||||
returns (uint256) |
||||
{ |
||||
return |
||||
SIGNATURES_OFFSET + |
||||
(uint256(threshold(_metadata)) * SIGNATURE_LENGTH); |
||||
} |
||||
} |
@ -1,16 +0,0 @@ |
||||
// SPDX-License-Identifier: MIT OR Apache-2.0 |
||||
pragma solidity >=0.8.0; |
||||
|
||||
// ============ Internal Imports ============ |
||||
import {LegacyMultisigIsm} from "../isms/multisig/LegacyMultisigIsm.sol"; |
||||
import {LegacyCheckpointLib} from "../libs/LegacyCheckpointLib.sol"; |
||||
|
||||
contract TestLegacyMultisigIsm is LegacyMultisigIsm { |
||||
function getDomainHash(uint32 _origin, bytes32 _originMailbox) |
||||
external |
||||
pure |
||||
returns (bytes32) |
||||
{ |
||||
return LegacyCheckpointLib.domainHash(_origin, _originMailbox); |
||||
} |
||||
} |
@ -1,595 +0,0 @@ |
||||
/* eslint-disable @typescript-eslint/no-floating-promises */ |
||||
import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; |
||||
import { expect } from 'chai'; |
||||
import { ethers } from 'hardhat'; |
||||
|
||||
import { |
||||
InterchainSecurityModuleType, |
||||
Validator, |
||||
addressToBytes32, |
||||
formatLegacyMultisigIsmMetadata, |
||||
parseLegacyMultisigIsmMetadata, |
||||
} from '@hyperlane-xyz/utils'; |
||||
|
||||
import domainHashTestCases from '../../../vectors/domainHash.json'; |
||||
import { |
||||
LightTestRecipient__factory, |
||||
TestIsm__factory, |
||||
TestLegacyMultisigIsm, |
||||
TestLegacyMultisigIsm__factory, |
||||
TestMailbox, |
||||
TestMailbox__factory, |
||||
TestMerkleTreeHook, |
||||
TestMerkleTreeHook__factory, |
||||
TestPostDispatchHook, |
||||
TestPostDispatchHook__factory, |
||||
TestRecipient__factory, |
||||
} from '../../types'; |
||||
import { |
||||
dispatchMessage, |
||||
dispatchMessageAndReturnMetadata, |
||||
getCommitment, |
||||
signCheckpoint, |
||||
} from '../lib/mailboxes'; |
||||
|
||||
const ORIGIN_DOMAIN = 1234; |
||||
const DESTINATION_DOMAIN = 4321; |
||||
|
||||
describe('LegacyMultisigIsm', async () => { |
||||
let multisigIsm: TestLegacyMultisigIsm, |
||||
mailbox: TestMailbox, |
||||
defaultHook: TestMerkleTreeHook, |
||||
requiredHook: TestPostDispatchHook, |
||||
signer: SignerWithAddress, |
||||
nonOwner: SignerWithAddress, |
||||
validators: Validator[]; |
||||
|
||||
before(async () => { |
||||
const signers = await ethers.getSigners(); |
||||
[signer, nonOwner] = signers; |
||||
const mailboxFactory = new TestMailbox__factory(signer); |
||||
mailbox = await mailboxFactory.deploy(ORIGIN_DOMAIN); |
||||
const defaultHookFactory = new TestMerkleTreeHook__factory(signer); |
||||
defaultHook = await defaultHookFactory.deploy(mailbox.address); |
||||
requiredHook = await new TestPostDispatchHook__factory(signer).deploy(); |
||||
await requiredHook.setFee(0); |
||||
const testIsm = await new TestIsm__factory(signer).deploy(); |
||||
mailbox.initialize( |
||||
signer.address, |
||||
testIsm.address, |
||||
defaultHook.address, |
||||
requiredHook.address, |
||||
); |
||||
validators = await Promise.all( |
||||
signers |
||||
.filter((_, i) => i > 1) |
||||
.map((s) => Validator.fromSigner(s, ORIGIN_DOMAIN, mailbox.address)), |
||||
); |
||||
}); |
||||
|
||||
beforeEach(async () => { |
||||
const multisigIsmFactory = new TestLegacyMultisigIsm__factory(signer); |
||||
multisigIsm = await multisigIsmFactory.deploy(); |
||||
await mailbox.setDefaultIsm(multisigIsm.address); |
||||
}); |
||||
|
||||
describe('#constructor', () => { |
||||
it('sets the owner', async () => { |
||||
expect(await multisigIsm.owner()).to.equal(signer.address); |
||||
}); |
||||
}); |
||||
|
||||
describe('#moduleType', () => { |
||||
it('returns the correct type', async () => { |
||||
expect(await multisigIsm.moduleType()).to.equal( |
||||
InterchainSecurityModuleType.MULTISIG, |
||||
); |
||||
}); |
||||
}); |
||||
|
||||
describe('#enrollValidators', () => { |
||||
let validatorAddresses: string[]; |
||||
const domains = [ORIGIN_DOMAIN, DESTINATION_DOMAIN]; |
||||
before(async () => { |
||||
validatorAddresses = validators.map((v) => v.address); |
||||
}); |
||||
|
||||
it('enrolls validators into multiple validator sets', async () => { |
||||
await multisigIsm.enrollValidators( |
||||
domains, |
||||
domains.map(() => validatorAddresses), |
||||
); |
||||
|
||||
await Promise.all( |
||||
domains.map(async (domain) => { |
||||
expect(await multisigIsm.validators(domain)).to.deep.equal( |
||||
validatorAddresses, |
||||
); |
||||
}), |
||||
); |
||||
}); |
||||
|
||||
it('emits the ValidatorEnrolled event', async () => { |
||||
expect( |
||||
await multisigIsm.enrollValidators( |
||||
domains, |
||||
domains.map(() => validatorAddresses), |
||||
), |
||||
) |
||||
.to.emit(multisigIsm, 'ValidatorEnrolled') |
||||
.withArgs(ORIGIN_DOMAIN, validatorAddresses[0], 1); |
||||
}); |
||||
|
||||
it('emits the CommitmentUpdated event', async () => { |
||||
const expectedCommitment = getCommitment(0, validatorAddresses); |
||||
expect( |
||||
await multisigIsm.enrollValidators( |
||||
domains, |
||||
domains.map(() => validatorAddresses), |
||||
), |
||||
) |
||||
.to.emit(multisigIsm, 'CommitmentUpdated') |
||||
.withArgs(ORIGIN_DOMAIN, expectedCommitment); |
||||
}); |
||||
|
||||
it('reverts when called by a non-owner', async () => { |
||||
await expect( |
||||
multisigIsm.connect(nonOwner).enrollValidators( |
||||
domains, |
||||
domains.map(() => validatorAddresses), |
||||
), |
||||
).to.be.revertedWith('Ownable: caller is not the owner'); |
||||
}); |
||||
}); |
||||
|
||||
describe('#enrollValidator', () => { |
||||
it('enrolls a validator into the validator set', async () => { |
||||
await multisigIsm.enrollValidator(ORIGIN_DOMAIN, validators[0].address); |
||||
|
||||
expect(await multisigIsm.validators(ORIGIN_DOMAIN)).to.deep.equal([ |
||||
validators[0].address, |
||||
]); |
||||
}); |
||||
|
||||
it('emits the ValidatorEnrolled event', async () => { |
||||
expect( |
||||
await multisigIsm.enrollValidator(ORIGIN_DOMAIN, validators[0].address), |
||||
) |
||||
.to.emit(multisigIsm, 'ValidatorEnrolled') |
||||
.withArgs(ORIGIN_DOMAIN, validators[0].address, 1); |
||||
}); |
||||
|
||||
it('emits the CommitmentUpdated event', async () => { |
||||
const expectedCommitment = getCommitment(0, [validators[0].address]); |
||||
expect( |
||||
await multisigIsm.enrollValidator(ORIGIN_DOMAIN, validators[0].address), |
||||
) |
||||
.to.emit(multisigIsm, 'CommitmentUpdated') |
||||
.withArgs(ORIGIN_DOMAIN, expectedCommitment); |
||||
}); |
||||
|
||||
it('reverts if the validator is already enrolled', async () => { |
||||
await multisigIsm.enrollValidator(ORIGIN_DOMAIN, validators[0].address); |
||||
await expect( |
||||
multisigIsm.enrollValidator(ORIGIN_DOMAIN, validators[0].address), |
||||
).to.be.revertedWith('already enrolled'); |
||||
}); |
||||
|
||||
it('reverts when called by a non-owner', async () => { |
||||
await expect( |
||||
multisigIsm |
||||
.connect(nonOwner) |
||||
.enrollValidator(ORIGIN_DOMAIN, validators[0].address), |
||||
).to.be.revertedWith('Ownable: caller is not the owner'); |
||||
}); |
||||
}); |
||||
|
||||
describe('#unenrollValidator', () => { |
||||
beforeEach(async () => { |
||||
await multisigIsm.enrollValidator(ORIGIN_DOMAIN, validators[0].address); |
||||
}); |
||||
|
||||
it('unenrolls a validator from the validator set', async () => { |
||||
await multisigIsm.unenrollValidator(ORIGIN_DOMAIN, validators[0].address); |
||||
|
||||
expect(await multisigIsm.validators(ORIGIN_DOMAIN)).to.deep.equal([]); |
||||
}); |
||||
|
||||
it('emits the ValidatorUnenrolled event', async () => { |
||||
expect( |
||||
await multisigIsm.unenrollValidator( |
||||
ORIGIN_DOMAIN, |
||||
validators[0].address, |
||||
), |
||||
) |
||||
.to.emit(multisigIsm, 'ValidatorUnenrolled') |
||||
.withArgs(ORIGIN_DOMAIN, validators[0].address, 0); |
||||
}); |
||||
|
||||
it('emits the CommitmentUpdated event', async () => { |
||||
const expectedCommitment = getCommitment(0, []); |
||||
expect( |
||||
await multisigIsm.unenrollValidator( |
||||
ORIGIN_DOMAIN, |
||||
validators[0].address, |
||||
), |
||||
) |
||||
.to.emit(multisigIsm, 'CommitmentUpdated') |
||||
.withArgs(ORIGIN_DOMAIN, expectedCommitment); |
||||
}); |
||||
|
||||
it('reverts if the resulting validator set size will be less than the quorum threshold', async () => { |
||||
await multisigIsm.setThreshold(ORIGIN_DOMAIN, 1); |
||||
|
||||
await expect( |
||||
multisigIsm.unenrollValidator(ORIGIN_DOMAIN, validators[0].address), |
||||
).to.be.revertedWith('violates quorum threshold'); |
||||
}); |
||||
|
||||
it('reverts if the validator is not already enrolled', async () => { |
||||
await expect( |
||||
multisigIsm.unenrollValidator(ORIGIN_DOMAIN, validators[1].address), |
||||
).to.be.revertedWith('!enrolled'); |
||||
}); |
||||
|
||||
it('reverts when called by a non-owner', async () => { |
||||
await expect( |
||||
multisigIsm |
||||
.connect(nonOwner) |
||||
.unenrollValidator(ORIGIN_DOMAIN, validators[0].address), |
||||
).to.be.revertedWith('Ownable: caller is not the owner'); |
||||
}); |
||||
}); |
||||
|
||||
describe('#setThresholds', () => { |
||||
let validatorAddresses: string[]; |
||||
const domains = [ORIGIN_DOMAIN, DESTINATION_DOMAIN]; |
||||
const thresholds = [2, 4]; |
||||
before(async () => { |
||||
validatorAddresses = validators.map((v) => v.address); |
||||
}); |
||||
|
||||
beforeEach(async () => { |
||||
await multisigIsm.enrollValidators( |
||||
domains, |
||||
domains.map(() => validatorAddresses), |
||||
); |
||||
}); |
||||
|
||||
it('sets the quorum thresholds', async () => { |
||||
await multisigIsm.setThresholds(domains, thresholds); |
||||
|
||||
await Promise.all( |
||||
domains.map(async (domain, i) => { |
||||
expect(await multisigIsm.threshold(domain)).to.equal(thresholds[i]); |
||||
}), |
||||
); |
||||
}); |
||||
|
||||
it('emits the SetThreshold event', async () => { |
||||
expect(await multisigIsm.setThresholds(domains, thresholds)) |
||||
.to.emit(multisigIsm, 'ThresholdSet') |
||||
.withArgs(ORIGIN_DOMAIN, 2); |
||||
}); |
||||
|
||||
it('emits the CommitmentUpdated event', async () => { |
||||
const expectedCommitment = getCommitment(2, validatorAddresses); |
||||
expect(await multisigIsm.setThresholds(domains, thresholds)) |
||||
.to.emit(multisigIsm, 'CommitmentUpdated') |
||||
.withArgs(ORIGIN_DOMAIN, expectedCommitment); |
||||
}); |
||||
|
||||
it('reverts when called by a non-owner', async () => { |
||||
await expect( |
||||
multisigIsm.connect(nonOwner).setThresholds(domains, thresholds), |
||||
).to.be.revertedWith('Ownable: caller is not the owner'); |
||||
}); |
||||
}); |
||||
|
||||
describe('#setThreshold', () => { |
||||
beforeEach(async () => { |
||||
// Have 2 validators to allow us to have more than 1 valid
|
||||
// quorum threshold
|
||||
await multisigIsm.enrollValidator(ORIGIN_DOMAIN, validators[0].address); |
||||
await multisigIsm.enrollValidator(ORIGIN_DOMAIN, validators[1].address); |
||||
}); |
||||
|
||||
it('sets the quorum threshold', async () => { |
||||
await multisigIsm.setThreshold(ORIGIN_DOMAIN, 2); |
||||
|
||||
expect(await multisigIsm.threshold(ORIGIN_DOMAIN)).to.equal(2); |
||||
}); |
||||
|
||||
it('emits the SetThreshold event', async () => { |
||||
expect(await multisigIsm.setThreshold(ORIGIN_DOMAIN, 2)) |
||||
.to.emit(multisigIsm, 'ThresholdSet') |
||||
.withArgs(ORIGIN_DOMAIN, 2); |
||||
}); |
||||
|
||||
it('emits the CommitmentUpdated event', async () => { |
||||
const expectedCommitment = getCommitment(2, [ |
||||
validators[0].address, |
||||
validators[1].address, |
||||
]); |
||||
expect(await multisigIsm.setThreshold(ORIGIN_DOMAIN, 2)) |
||||
.to.emit(multisigIsm, 'CommitmentUpdated') |
||||
.withArgs(ORIGIN_DOMAIN, expectedCommitment); |
||||
}); |
||||
|
||||
it('reverts if the new quorum threshold is zero', async () => { |
||||
await expect( |
||||
multisigIsm.setThreshold(ORIGIN_DOMAIN, 0), |
||||
).to.be.revertedWith('!range'); |
||||
}); |
||||
|
||||
it('reverts if the new quorum threshold is greater than the validator set size', async () => { |
||||
await expect( |
||||
multisigIsm.setThreshold(ORIGIN_DOMAIN, 3), |
||||
).to.be.revertedWith('!range'); |
||||
}); |
||||
|
||||
it('reverts when called by a non-owner', async () => { |
||||
await expect( |
||||
multisigIsm.connect(nonOwner).setThreshold(ORIGIN_DOMAIN, 2), |
||||
).to.be.revertedWith('Ownable: caller is not the owner'); |
||||
}); |
||||
}); |
||||
|
||||
describe('#validators', () => { |
||||
beforeEach(async () => { |
||||
await multisigIsm.enrollValidators( |
||||
[ORIGIN_DOMAIN], |
||||
[validators.map((v) => v.address)], |
||||
); |
||||
}); |
||||
|
||||
it('returns the validators', async () => { |
||||
expect(await multisigIsm.validators(ORIGIN_DOMAIN)).to.deep.equal( |
||||
validators.map((v) => v.address), |
||||
); |
||||
}); |
||||
}); |
||||
|
||||
describe('#validatorsAndThreshold', () => { |
||||
const threshold = 7; |
||||
let message: string; |
||||
beforeEach(async () => { |
||||
await multisigIsm.enrollValidators( |
||||
[ORIGIN_DOMAIN], |
||||
[validators.map((v) => v.address)], |
||||
); |
||||
await multisigIsm.setThreshold(ORIGIN_DOMAIN, threshold); |
||||
const dispatch = await dispatchMessage( |
||||
mailbox, |
||||
DESTINATION_DOMAIN, |
||||
addressToBytes32(multisigIsm.address), |
||||
'hello', |
||||
); |
||||
message = dispatch.message; |
||||
}); |
||||
|
||||
it('returns the validators and threshold', async () => { |
||||
expect(await multisigIsm.validatorsAndThreshold(message)).to.deep.equal([ |
||||
validators.map((v) => v.address), |
||||
threshold, |
||||
]); |
||||
}); |
||||
}); |
||||
|
||||
describe('#validatorCount', () => { |
||||
beforeEach(async () => { |
||||
// Must be done sequentially so gas estimation is correct.
|
||||
for (const v of validators) { |
||||
await multisigIsm.enrollValidator(ORIGIN_DOMAIN, v.address); |
||||
} |
||||
}); |
||||
|
||||
it('returns the number of validators enrolled in the validator set', async () => { |
||||
expect(await multisigIsm.validatorCount(ORIGIN_DOMAIN)).to.equal( |
||||
validators.length, |
||||
); |
||||
}); |
||||
}); |
||||
|
||||
describe('#verify', () => { |
||||
let metadata: string, message: string, recipient: string; |
||||
before(async () => { |
||||
const recipientF = new TestRecipient__factory(signer); |
||||
recipient = (await recipientF.deploy()).address; |
||||
}); |
||||
|
||||
beforeEach(async () => { |
||||
// Must be done sequentially so gas estimation is correct
|
||||
// and so that signatures are produced in the same order.
|
||||
for (const v of validators) { |
||||
await multisigIsm.enrollValidator(ORIGIN_DOMAIN, v.address); |
||||
} |
||||
await multisigIsm.setThreshold(ORIGIN_DOMAIN, validators.length - 1); |
||||
|
||||
({ message, metadata } = await dispatchMessageAndReturnMetadata( |
||||
mailbox, |
||||
defaultHook, |
||||
multisigIsm, |
||||
DESTINATION_DOMAIN, |
||||
recipient, |
||||
'hello world', |
||||
validators.slice(1), |
||||
)); |
||||
}); |
||||
|
||||
it('returns true when valid metadata is provided', async () => { |
||||
expect(await multisigIsm.verify(metadata, message)).to.be.true; |
||||
}); |
||||
|
||||
it('allows for message processing when valid metadata is provided', async () => { |
||||
const mailboxFactory = new TestMailbox__factory(signer); |
||||
const destinationMailbox = await mailboxFactory.deploy( |
||||
DESTINATION_DOMAIN, |
||||
); |
||||
await destinationMailbox.initialize( |
||||
signer.address, |
||||
multisigIsm.address, |
||||
defaultHook.address, |
||||
requiredHook.address, |
||||
); |
||||
await destinationMailbox.process(metadata, message); |
||||
}); |
||||
|
||||
it('reverts when non-validator signatures are provided', async () => { |
||||
const nonValidator = await Validator.fromSigner( |
||||
signer, |
||||
ORIGIN_DOMAIN, |
||||
mailbox.address, |
||||
); |
||||
const parsedMetadata = parseLegacyMultisigIsmMetadata(metadata); |
||||
const nonValidatorSignature = ( |
||||
await signCheckpoint( |
||||
parsedMetadata.checkpointRoot, |
||||
parsedMetadata.checkpointIndex, |
||||
mailbox.address, |
||||
[nonValidator], |
||||
) |
||||
)[0]; |
||||
parsedMetadata.signatures.push(nonValidatorSignature); |
||||
const modifiedMetadata = formatLegacyMultisigIsmMetadata({ |
||||
...parsedMetadata, |
||||
signatures: parsedMetadata.signatures.slice(1), |
||||
}); |
||||
await expect( |
||||
multisigIsm.verify(modifiedMetadata, message), |
||||
).to.be.revertedWith('!threshold'); |
||||
}); |
||||
|
||||
it('reverts when the provided validator set does not match the stored commitment', async () => { |
||||
const parsedMetadata = parseLegacyMultisigIsmMetadata(metadata); |
||||
const modifiedMetadata = formatLegacyMultisigIsmMetadata({ |
||||
...parsedMetadata, |
||||
validators: parsedMetadata.validators.slice(1), |
||||
}); |
||||
await expect( |
||||
multisigIsm.verify(modifiedMetadata, message), |
||||
).to.be.revertedWith('!commitment'); |
||||
}); |
||||
|
||||
it('reverts when an invalid merkle proof is provided', async () => { |
||||
const parsedMetadata = parseLegacyMultisigIsmMetadata(metadata); |
||||
const modifiedMetadata = formatLegacyMultisigIsmMetadata({ |
||||
...parsedMetadata, |
||||
proof: parsedMetadata.proof.reverse(), |
||||
}); |
||||
await expect( |
||||
multisigIsm.verify(modifiedMetadata, message), |
||||
).to.be.revertedWith('!merkle'); |
||||
}); |
||||
}); |
||||
|
||||
describe('#isEnrolled', () => { |
||||
beforeEach(async () => { |
||||
await multisigIsm.enrollValidator(ORIGIN_DOMAIN, validators[0].address); |
||||
}); |
||||
|
||||
it('returns true if an address is enrolled in the validator set', async () => { |
||||
expect(await multisigIsm.isEnrolled(ORIGIN_DOMAIN, validators[0].address)) |
||||
.to.be.true; |
||||
}); |
||||
|
||||
it('returns false if an address is not enrolled in the validator set', async () => { |
||||
expect(await multisigIsm.isEnrolled(ORIGIN_DOMAIN, validators[1].address)) |
||||
.to.be.false; |
||||
}); |
||||
}); |
||||
|
||||
describe('#_getDomainHash', () => { |
||||
it('matches Rust-produced domain hashes', async () => { |
||||
// Compare Rust output in json file to solidity output (json file matches
|
||||
// hash for local domain of 1000)
|
||||
for (const testCase of domainHashTestCases) { |
||||
const { expectedDomainHash } = testCase; |
||||
// This public function on TestLegacyMultisigIsm exposes
|
||||
// the internal _domainHash on MultisigIsm.
|
||||
const domainHash = await multisigIsm.getDomainHash( |
||||
testCase.domain, |
||||
testCase.mailbox, |
||||
); |
||||
expect(domainHash).to.equal(expectedDomainHash); |
||||
} |
||||
}); |
||||
}); |
||||
|
||||
// Manually unskip to run gas instrumentation.
|
||||
// The JSON that's logged can then be copied to `typescript/sdk/src/consts/multisigIsmVerifyCosts.json`,
|
||||
// which is ultimately used for configuring the default ISM overhead IGP.
|
||||
describe.skip('#verify gas instrumentation for the OverheadISM', () => { |
||||
const MAX_VALIDATOR_COUNT = 18; |
||||
let metadata: string, message: string, recipient: string; |
||||
|
||||
const gasOverhead: Record<number, Record<number, number>> = {}; |
||||
|
||||
before(async () => { |
||||
const recipientF = new LightTestRecipient__factory(signer); |
||||
recipient = (await recipientF.deploy()).address; |
||||
}); |
||||
|
||||
after(() => { |
||||
// eslint-disable-next-line no-console
|
||||
console.log('Instrumented gas overheads:'); |
||||
// eslint-disable-next-line no-console
|
||||
console.log(JSON.stringify(gasOverhead)); |
||||
}); |
||||
|
||||
for ( |
||||
let numValidators = 1; |
||||
numValidators <= MAX_VALIDATOR_COUNT; |
||||
numValidators++ |
||||
) { |
||||
for (let threshold = 1; threshold <= numValidators; threshold++) { |
||||
it(`instrument mailbox.process gas costs with ${threshold} of ${numValidators} multisig`, async () => { |
||||
const adjustedValidators = validators.slice(0, numValidators); |
||||
// Must be done sequentially so gas estimation is correct
|
||||
// and so that signatures are produced in the same order.
|
||||
for (const v of adjustedValidators) { |
||||
await multisigIsm.enrollValidator(ORIGIN_DOMAIN, v.address); |
||||
} |
||||
|
||||
await multisigIsm.setThreshold(ORIGIN_DOMAIN, threshold); |
||||
|
||||
const maxBodySize = 2 ** 16 - 1; |
||||
// The max body is used to estimate an upper bound on gas usage.
|
||||
const maxBody = '0x' + 'AA'.repeat(maxBodySize); |
||||
|
||||
({ message, metadata } = await dispatchMessageAndReturnMetadata( |
||||
mailbox, |
||||
defaultHook, |
||||
multisigIsm, |
||||
DESTINATION_DOMAIN, |
||||
recipient, |
||||
maxBody, |
||||
adjustedValidators, |
||||
threshold, |
||||
false, |
||||
)); |
||||
|
||||
const mailboxFactory = new TestMailbox__factory(signer); |
||||
const destinationMailbox = await mailboxFactory.deploy( |
||||
DESTINATION_DOMAIN, |
||||
); |
||||
await destinationMailbox.initialize( |
||||
signer.address, |
||||
multisigIsm.address, |
||||
defaultHook.address, |
||||
requiredHook.address, |
||||
); |
||||
const gas = await destinationMailbox.estimateGas.process( |
||||
metadata, |
||||
message, |
||||
); |
||||
|
||||
if (gasOverhead[numValidators] === undefined) { |
||||
gasOverhead[numValidators] = {}; |
||||
} |
||||
gasOverhead[numValidators][threshold] = gas.toNumber(); |
||||
}); |
||||
} |
||||
} |
||||
}); |
||||
}); |
Loading…
Reference in new issue