diff --git a/onchain/rollups/.changeset/lovely-carpets-change.md b/onchain/rollups/.changeset/lovely-carpets-change.md new file mode 100644 index 00000000..d41445dd --- /dev/null +++ b/onchain/rollups/.changeset/lovely-carpets-change.md @@ -0,0 +1,5 @@ +--- +"@cartesi/rollups": minor +--- + +Added `Quorum` consensus contract. diff --git a/onchain/rollups/contracts/consensus/quorum/Quorum.sol b/onchain/rollups/contracts/consensus/quorum/Quorum.sol new file mode 100644 index 00000000..23e64c1e --- /dev/null +++ b/onchain/rollups/contracts/consensus/quorum/Quorum.sol @@ -0,0 +1,152 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.8; + +import {BitMaps} from "@openzeppelin/contracts/utils/structs/BitMaps.sol"; + +import {AbstractConsensus} from "../AbstractConsensus.sol"; +import {InputRange} from "../../common/InputRange.sol"; + +/// @notice A consensus model controlled by a small, immutable set of `n` validators. +/// @notice You can know the value of `n` by calling the `numOfValidators` function. +/// @notice Upon construction, each validator is assigned a unique number from 1 and `n`. +/// These numbers are used internally instead of addresses for optimization reasons. +/// @notice You can list the validators in the quorum by calling the `validatorById` +/// function for each ID from 1 to `n`. +contract Quorum is AbstractConsensus { + using BitMaps for BitMaps.BitMap; + + /// @notice The total number of validators. + /// @notice See the `numOfValidators` function. + uint256 private immutable _numOfValidators; + + /// @notice Validator IDs indexed by address. + /// @notice See the `validatorId` function. + /// @dev Non-validators are assigned to ID zero. + /// @dev Validators have IDs greater than zero. + mapping(address => uint256) private _validatorId; + + /// @notice Validator addresses indexed by ID. + /// @notice See the `validatorById` function. + /// @dev Invalid IDs are assigned to address zero. + mapping(uint256 => address) private _validatorById; + + /// @notice Votes in favor of a particular claim. + /// @param inFavorCount The number of validators in favor of the claim + /// @param inFavorById The set of validators in favor of the claim + /// @dev `inFavorById` is a bitmap indexed by validator IDs. + struct Votes { + uint256 inFavorCount; + BitMaps.BitMap inFavorById; + } + + /// @notice Votes indexed by claim. + /// @dev See the `numOfValidatorsInFavorOf` and `isValidatorInFavorOf` functions. + mapping(address => mapping(uint256 => mapping(uint256 => mapping(bytes32 => Votes)))) + private _votes; + + /// @param validators The array of validator addresses + /// @dev Duplicates in the `validators` array are ignored. + constructor(address[] memory validators) { + uint256 n; + for (uint256 i; i < validators.length; ++i) { + address validator = validators[i]; + if (_validatorId[validator] == 0) { + uint256 id = ++n; + _validatorId[validator] = id; + _validatorById[id] = validator; + } + } + _numOfValidators = n; + } + + /// @notice Submit a claim. + /// @notice If the majority of the quorum submit a claim, it is accepted. + /// @param application The application address + /// @param r The input range + /// @param epochHash The epoch hash + /// @dev Can only be called by a validator. + function submitClaim( + address application, + InputRange calldata r, + bytes32 epochHash + ) external { + uint256 id = _validatorId[msg.sender]; + require(id > 0, "Quorum: caller is not validator"); + + emit ClaimSubmission(msg.sender, application, r, epochHash); + + Votes storage votes = _getVotes(application, r, epochHash); + + if (!votes.inFavorById.get(id)) { + votes.inFavorById.set(id); + if (++votes.inFavorCount == 1 + _numOfValidators / 2) { + _acceptClaim(application, r, epochHash); + } + } + } + + /// @notice Get the number of validators. + function numOfValidators() external view returns (uint256) { + return _numOfValidators; + } + + /// @notice Get the ID of a validator. + /// @param validator The validator address + /// @dev Validators have IDs greater than zero. + /// @dev Non-validators are assigned to ID zero. + function validatorId(address validator) external view returns (uint256) { + return _validatorId[validator]; + } + + /// @notice Get the address of a validator by its ID. + /// @param id The validator ID + /// @dev Validator IDs range from 1 to `N`, the total number of validators. + /// @dev Invalid IDs are assigned to the zero address. + function validatorById(uint256 id) external view returns (address) { + return _validatorById[id]; + } + + /// @notice Get the number of validators in favor of a claim. + /// @param application The application address + /// @param r The input range + /// @param epochHash The epoch hash + /// @return Number of validators in favor of claim. + function numOfValidatorsInFavorOf( + address application, + InputRange calldata r, + bytes32 epochHash + ) external view returns (uint256) { + return _getVotes(application, r, epochHash).inFavorCount; + } + + /// @notice Check whether a validator is in favor of a claim. + /// @param application The application address + /// @param r The input range + /// @param epochHash The epoch hash + /// @param id The ID of the validator + /// @return Whether validator is in favor of claim + /// @dev Assumes the provided ID is valid + function isValidatorInFavorOf( + address application, + InputRange calldata r, + bytes32 epochHash, + uint256 id + ) external view returns (bool) { + return _getVotes(application, r, epochHash).inFavorById.get(id); + } + + /// @notice Get a `Votes` structure from storage from a given claim. + /// @param application The application address + /// @param r The input range + /// @param epochHash The epoch hash + /// @return The `Votes` structure related to given claim + function _getVotes( + address application, + InputRange calldata r, + bytes32 epochHash + ) internal view returns (Votes storage) { + return _votes[application][r.firstIndex][r.lastIndex][epochHash]; + } +} diff --git a/onchain/rollups/test/foundry/consensus/quorum/Quorum.t.sol b/onchain/rollups/test/foundry/consensus/quorum/Quorum.t.sol new file mode 100644 index 00000000..b5beea62 --- /dev/null +++ b/onchain/rollups/test/foundry/consensus/quorum/Quorum.t.sol @@ -0,0 +1,293 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.8; + +import {InputRange} from "contracts/common/InputRange.sol"; +import {Quorum} from "contracts/consensus/quorum/Quorum.sol"; + +import {TestBase} from "../../util/TestBase.sol"; + +import {Vm} from "forge-std/Vm.sol"; + +struct Claim { + address dapp; + InputRange inputRange; + bytes32 epochHash; +} + +library LibClaim { + function numOfValidatorsInFavorOf( + Quorum quorum, + Claim calldata claim + ) internal view returns (uint256) { + return + quorum.numOfValidatorsInFavorOf( + claim.dapp, + claim.inputRange, + claim.epochHash + ); + } + + function isValidatorInFavorOf( + Quorum quorum, + Claim calldata claim, + uint256 id + ) internal view returns (bool) { + return + quorum.isValidatorInFavorOf( + claim.dapp, + claim.inputRange, + claim.epochHash, + id + ); + } + + function submitClaim(Quorum quorum, Claim calldata claim) internal { + quorum.submitClaim(claim.dapp, claim.inputRange, claim.epochHash); + } +} + +contract QuorumTest is TestBase { + using LibClaim for Quorum; + + event ClaimSubmission( + address indexed submitter, + address indexed dapp, + InputRange inputRange, + bytes32 epochHash + ); + + event ClaimAcceptance( + address indexed dapp, + InputRange inputRange, + bytes32 epochHash + ); + + function testConstructor(uint8 numOfValidators) external { + address[] memory validators = generateAddresses(numOfValidators); + + Quorum quorum = new Quorum(validators); + + assertEq(quorum.numOfValidators(), numOfValidators); + + for (uint256 i; i < numOfValidators; ++i) { + address validator = validators[i]; + uint256 id = quorum.validatorId(validator); + assertEq(quorum.validatorById(id), validator); + assertLe(1, id); + assertLe(id, numOfValidators); + } + } + + function testConstructorIgnoresDuplicates() external { + address[] memory validators = new address[](7); + + validators[0] = vm.addr(1); + validators[1] = vm.addr(2); + validators[2] = vm.addr(1); + validators[3] = vm.addr(3); + validators[4] = vm.addr(2); + validators[5] = vm.addr(1); + validators[6] = vm.addr(3); + + Quorum quorum = new Quorum(validators); + + assertEq(quorum.numOfValidators(), 3); + + for (uint256 i = 1; i <= 3; ++i) { + assertEq(quorum.validatorId(vm.addr(i)), i); + assertEq(quorum.validatorById(i), vm.addr(i)); + } + } + + function testValidatorId(uint8 numOfValidators, address addr) external { + address[] memory validators = generateAddresses(numOfValidators); + + Quorum quorum = new Quorum(validators); + + uint256 id = quorum.validatorId(addr); + + if (contains(validators, addr)) { + assertLe(1, id); + assertLe(id, numOfValidators); + } else { + assertEq(id, 0); + } + } + + function testValidatorById(uint8 numOfValidators, uint8 id) external { + Quorum quorum = _deployQuorum(numOfValidators); + + address validator = quorum.validatorById(id); + + if (id >= 1 && id <= numOfValidators) { + assertEq(quorum.validatorId(validator), id); + } else { + assertEq(validator, address(0)); + } + } + + function testSubmitClaimRevertsNotValidator( + uint8 numOfValidators, + address caller, + Claim calldata claim + ) external { + Quorum quorum = _deployQuorum(numOfValidators); + + vm.assume(quorum.validatorId(caller) == 0); + + vm.expectRevert("Quorum: caller is not validator"); + + vm.prank(caller); + quorum.submitClaim(claim); + } + + function testNumOfValidatorsInFavorOf( + uint8 numOfValidators, + Claim calldata claim + ) external { + Quorum quorum = _deployQuorum(numOfValidators); + assertEq(quorum.numOfValidatorsInFavorOf(claim), 0); + } + + function testIsValidatorInFavorOf( + uint8 numOfValidators, + Claim calldata claim, + uint256 id + ) external { + Quorum quorum = _deployQuorum(numOfValidators); + assertFalse(quorum.isValidatorInFavorOf(claim, id)); + } + + function testSubmitClaim( + uint8 numOfValidators, + Claim calldata claim + ) external { + numOfValidators = uint8(bound(numOfValidators, 1, 7)); + Quorum quorum = _deployQuorum(numOfValidators); + bool[] memory submitted = new bool[](numOfValidators + 1); + for (uint256 id = 1; id <= numOfValidators; ++id) { + _submitClaimAs(quorum, claim, id); + submitted[id] = true; + _checkSubmitted(quorum, claim, submitted); + } + } + + function testSubmitClaim300(Claim calldata claim) external { + uint256 numOfValidators = 256; + + Quorum quorum = _deployQuorum(numOfValidators); + + uint256 id = numOfValidators; + + vm.prank(quorum.validatorById(id)); + quorum.submitClaim(claim); + + assertTrue(quorum.isValidatorInFavorOf(claim, id)); + assertEq(quorum.numOfValidatorsInFavorOf(claim), 1); + } + + // Internal functions + // ------------------ + + function _deployQuorum(uint256 numOfValidators) internal returns (Quorum) { + return new Quorum(generateAddresses(numOfValidators)); + } + + function _checkSubmitted( + Quorum quorum, + Claim calldata claim, + bool[] memory submitted + ) internal { + uint256 inFavorCount; + uint256 numOfValidators = quorum.numOfValidators(); + + for (uint256 id = 1; id <= numOfValidators; ++id) { + assertEq(quorum.isValidatorInFavorOf(claim, id), submitted[id]); + if (submitted[id]) ++inFavorCount; + } + + assertEq(quorum.numOfValidatorsInFavorOf(claim), inFavorCount); + } + + function _submitClaimAs( + Quorum quorum, + Claim calldata claim, + uint256 id + ) internal { + address validator = quorum.validatorById(id); + + vm.recordLogs(); + + vm.prank(validator); + quorum.submitClaim(claim); + + Vm.Log[] memory entries = vm.getRecordedLogs(); + + uint256 numOfSubmissions; + uint256 numOfAcceptances; + + for (uint256 i; i < entries.length; ++i) { + Vm.Log memory entry = entries[i]; + + if ( + entry.emitter == address(quorum) && + entry.topics[0] == ClaimSubmission.selector + ) { + address submitter = address(uint160(uint256(entry.topics[1]))); + address dapp = address(uint160(uint256(entry.topics[2]))); + + (InputRange memory inputRange, bytes32 epochHash) = abi.decode( + entry.data, + (InputRange, bytes32) + ); + + assertEq(submitter, validator); + assertEq(dapp, claim.dapp); + assertEq(inputRange.firstIndex, claim.inputRange.firstIndex); + assertEq(inputRange.lastIndex, claim.inputRange.lastIndex); + assertEq(epochHash, claim.epochHash); + + ++numOfSubmissions; + } + + if ( + entry.emitter == address(quorum) && + entry.topics[0] == ClaimAcceptance.selector + ) { + address dapp = address(uint160(uint256(entry.topics[1]))); + + (InputRange memory inputRange, bytes32 epochHash) = abi.decode( + entry.data, + (InputRange, bytes32) + ); + + assertEq(dapp, claim.dapp); + assertEq(inputRange.firstIndex, claim.inputRange.firstIndex); + assertEq(inputRange.lastIndex, claim.inputRange.lastIndex); + assertEq(epochHash, claim.epochHash); + + ++numOfAcceptances; + } + } + + assertEq(numOfSubmissions, 1); + + uint256 inFavorCount = quorum.numOfValidatorsInFavorOf(claim); + uint256 numOfValidators = quorum.numOfValidators(); + + if (inFavorCount == 1 + (numOfValidators / 2)) { + assertEq(numOfAcceptances, 1); + } else { + assertEq(numOfAcceptances, 0); + } + + if (inFavorCount > (numOfValidators / 2)) { + assertEq( + quorum.getEpochHash(claim.dapp, claim.inputRange), + claim.epochHash + ); + } + } +} diff --git a/onchain/rollups/test/foundry/util/TestBase.sol b/onchain/rollups/test/foundry/util/TestBase.sol index fd539ba9..ef32338a 100644 --- a/onchain/rollups/test/foundry/util/TestBase.sol +++ b/onchain/rollups/test/foundry/util/TestBase.sol @@ -17,4 +17,26 @@ contract TestBase is Test { vm.assume(addr != MULTICALL3_ADDRESS); _; } + + function contains( + address[] memory array, + address elem + ) internal pure returns (bool) { + for (uint256 i; i < array.length; ++i) { + if (array[i] == elem) { + return true; + } + } + return false; + } + + function generateAddresses( + uint256 n + ) internal pure returns (address[] memory) { + address[] memory array = new address[](n); + for (uint256 i; i < n; ++i) { + array[i] = vm.addr(i + 1); + } + return array; + } }