From 2acc8152629d9e9ec07c6eb0fd6554940821b108 Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Mon, 24 Jul 2023 23:49:49 -0300 Subject: [PATCH 1/2] feat: add `Quorum` contract - Special thanks to @ZzzzHui for helping with gas optimizations! --- .../.changeset/lovely-carpets-change.md | 5 + .../contracts/consensus/quorum/Quorum.sol | 153 +++++++++ .../foundry/consensus/quorum/Quorum.t.sol | 295 ++++++++++++++++++ .../rollups/test/foundry/util/TestBase.sol | 22 ++ 4 files changed, 475 insertions(+) create mode 100644 onchain/rollups/.changeset/lovely-carpets-change.md create mode 100644 onchain/rollups/contracts/consensus/quorum/Quorum.sol create mode 100644 onchain/rollups/test/foundry/consensus/quorum/Quorum.t.sol 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..2974e870 --- /dev/null +++ b/onchain/rollups/contracts/consensus/quorum/Quorum.sol @@ -0,0 +1,153 @@ +// (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 between 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 map 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 + /// (application address, first input index, last input index, and epoch hash). + /// @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 map to address zero. + 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..a6630278 --- /dev/null +++ b/onchain/rollups/test/foundry/consensus/quorum/Quorum.t.sol @@ -0,0 +1,295 @@ +// (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); + assertEq(id, i + 1); + } + } + + 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); + } + } + + /// @notice Tests the storage of votes in bitmap format + /// @dev Each slot has 256 bits, one for each validator ID. + /// The first bit is skipped because validator IDs start from 1. + /// Therefore, validator ID 256 is the first to use a new slot. + function testSubmitClaim256(Claim calldata claim) external { + uint256 numOfValidators = 256; + + Quorum quorum = _deployQuorum(numOfValidators); + + uint256 id = numOfValidators; + + _submitClaimAs(quorum, claim, id); + + 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 70d9f2ba..073bacf6 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; + } } From d3fe1405e41223a4f6137a110734bffc4987c219 Mon Sep 17 00:00:00 2001 From: Zehui Zheng Date: Fri, 12 Jan 2024 21:36:50 +0800 Subject: [PATCH 2/2] chore: update readme to include quorum --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c031fb70..16b19f59 100644 --- a/README.md +++ b/README.md @@ -220,7 +220,9 @@ Notices are informational statements that can be proved by contracts in the base This module is responsible for providing valid claims to DApps after reaching some form of consensus. Each DApp has its own mapping of claims, each of which is mapped by the range of input indices of an epoch. -The module's interface aims to be as generic as possible to accommodate any consensus model, since there are plenty to choose from. One type of consensus implemented by Cartesi is called Authority. It is owned by a single address, who has complete power over the consensus. It is arguably the simplest consensus to implement, although quite vulnerable. +The module's interface aims to be as generic as possible to accommodate any consensus model, since there are plenty to choose from. The types of consensus currently implemented include: +- Authority: managed by a single address, who has complete power over the consensus. It is trivial to implement, yet quite vulnerable. +- Quorum: managed by a generally small, finite set of validators. Consensus is reached when the majority of the quorum agrees on any given claim. ### Dispute Resolution