diff --git a/onchain/rollups/.changeset/lovely-carpets-change.md b/onchain/rollups/.changeset/lovely-carpets-change.md new file mode 100644 index 00000000..0c27fd50 --- /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..81349ed5 --- /dev/null +++ b/onchain/rollups/contracts/consensus/quorum/Quorum.sol @@ -0,0 +1,180 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.8; + +import {PaymentSplitter} from "@openzeppelin/contracts/finance/PaymentSplitter.sol"; +import {BitMaps} from "@openzeppelin/contracts/utils/structs/BitMaps.sol"; + +import {AbstractConsensus} from "../AbstractConsensus.sol"; +import {IConsensus} from "../IConsensus.sol"; +import {IHistory} from "../../history/IHistory.sol"; + +/// @title Quorum consensus +/// @notice A consensus model controlled by a small set of addresses, the validators. +/// In this version, the validator set is immutable. +/// Claims are stored in an auxiliary contract called history. +/// @dev Each validator is assigned an identifier that spans from 1 to N, +/// where N is the total number of validators in the quorum. +/// These identifiers are used internally instead of addresses for optimization reasons. +/// This contract uses OpenZeppelin `PaymentSplitter` and `BitMaps`. +/// For more information on those, please consult OpenZeppelin's official documentation. +contract Quorum is AbstractConsensus, PaymentSplitter { + using BitMaps for BitMaps.BitMap; + + /// @notice Get the total number of validators. + uint256 public immutable numOfValidators; + + /// @notice Get the ID of a validator by its address. + /// @dev Only validators have non-zero IDs. + mapping(address => uint256) public validatorId; + + /// @notice Get the address of a validator by its ID. + /// @dev Validator IDs span from 1 to the total number of validators. + /// Invalid IDs are assigned to the zero address. + mapping(uint256 => address) public validatorById; + + /// @notice Voting status of a particular claim. + /// @param inFavorCount the number of validators in favor of the claim + /// @param inFavorById the IDs of validators in favor of the claim in bitmap format + struct VotingStatus { + uint256 inFavorCount; + BitMaps.BitMap inFavorById; + } + + /// @notice The voting status of each claim. + mapping(bytes => VotingStatus) internal votingStatuses; + + /// @notice The history contract. + /// @dev See the `getHistory` function. + IHistory internal immutable history; + + /// @notice Construct a Quorum consensus + /// @param _validators the list of validators + /// @param _shares the list of shares + /// @param _history the history contract + /// @dev PaymentSplitter checks for duplicates in _validators + constructor( + address[] memory _validators, + uint256[] memory _shares, + IHistory _history + ) PaymentSplitter(_validators, _shares) { + numOfValidators = _validators.length; + + uint256 id = 1; + for (uint256 i; i < _validators.length; ++i) { + address validator = _validators[i]; + validatorId[validator] = id; + validatorById[id] = validator; + ++id; + } + + history = _history; + } + + /// @notice Vote for a claim to be submitted. + /// If this is the claim that reaches the majority, then + /// the claim is submitted to the history contract. + /// The encoding of `_claimData` might vary depending on the + /// implementation of the current history contract. + /// @param _claimData Data for submitting a claim + /// @dev Can only be called by a validator, + /// and the `Quorum` contract must have ownership over + /// its current history contract. + function submitClaim(bytes calldata _claimData) external { + uint256 id = validatorId[msg.sender]; + require(id != 0, "Quorum: sender is not validator"); + + VotingStatus storage votingStatus = votingStatuses[_claimData]; + BitMaps.BitMap storage inFavorById = votingStatus.inFavorById; + + if (!inFavorById.get(id)) { + // If validator hasn't voted yet, cast their vote + inFavorById.set(id); + + // If this claim has now just over half of the quorum's votes, + // then we can submit it to the history contract. + if (++votingStatus.inFavorCount == 1 + numOfValidators / 2) { + history.submitClaim(_claimData); + } + } + } + + /// @notice Get an array with the addresses of all validators. + /// @return Array of addresses of validators + function validators() external view returns (address[] memory) { + address[] memory array = new address[](numOfValidators); + + uint256 id = 1; + for (uint256 i; i < numOfValidators; ++i) { + array[i] = validatorById[id]; + ++id; + } + + return array; + } + + /// @notice Get the number of validator in favor of a claim. + /// @param _claimData Data for submitting a claim + /// @return Number of validator in favor of claim. + function numOfValidatorsInFavorOf( + bytes calldata _claimData + ) external view returns (uint256) { + VotingStatus storage votingStatus = votingStatuses[_claimData]; + return votingStatus.inFavorCount; + } + + /// @notice Check whether a validator is in favor of a claim. + /// @param _validatorId The ID of the validator + /// @param _claimData Data for submitting a claim + /// @return Whether validator is in favor of claim + /// @dev Assumes the provided ID is valid + function isValidatorInFavorOf( + uint256 _validatorId, + bytes calldata _claimData + ) external view returns (bool) { + VotingStatus storage votingStatus = votingStatuses[_claimData]; + BitMaps.BitMap storage inFavorById = votingStatus.inFavorById; + return inFavorById.get(_validatorId); + } + + /// @notice Get an array with the addresses of all validators in favor of a claim. + /// @param _claimData Data for submitting a claim + /// @return Array of addresses of validators in favor of claim + function validatorsInFavorOf( + bytes calldata _claimData + ) external view returns (address[] memory) { + VotingStatus storage votingStatus = votingStatuses[_claimData]; + BitMaps.BitMap storage inFavorById = votingStatus.inFavorById; + + uint256 validatorsLeft = votingStatus.inFavorCount; + address[] memory array = new address[](validatorsLeft); + + uint256 id = 1; + while (validatorsLeft > 0) { + if (inFavorById.get(id)) { + array[--validatorsLeft] = validatorById[id]; + } + ++id; + } + + return array; + } + + /// @notice Get the history contract. + /// @return The history contract + function getHistory() external view returns (IHistory) { + return history; + } + + /// @notice Get a claim from the current history. + /// The encoding of `_proofContext` might vary depending on the + /// implementation of the current history contract. + /// @inheritdoc IConsensus + function getClaim( + address _dapp, + bytes calldata _proofContext + ) external view override returns (bytes32, uint256, uint256) { + return history.getClaim(_dapp, _proofContext); + } +} 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..84673bc0 --- /dev/null +++ b/onchain/rollups/test/foundry/consensus/quorum/Quorum.t.sol @@ -0,0 +1,308 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.8; + +import {stdError} from "forge-std/StdError.sol"; + +import {Quorum} from "contracts/consensus/quorum/Quorum.sol"; +import {IHistory} from "contracts/history/IHistory.sol"; + +import {TestBase} from "../../util/TestBase.sol"; + +contract HistoryMock is IHistory { + bytes[] internal claims; + + function submitClaim(bytes calldata _claim) external override { + claims.push(_claim); + } + + function migrateToConsensus(address) external override {} + + function getClaim( + address, + bytes calldata + ) external view returns (bytes32, uint256, uint256) {} + + function numOfClaims() external view returns (uint256) { + return claims.length; + } + + function claim(uint256 _index) external view returns (bytes memory) { + return claims[_index]; + } +} + +contract QuorumTest is TestBase { + HistoryMock history; + + // External functions + // ------------------ + + function setUp() external { + history = new HistoryMock(); + } + + function testHistory() external { + assertEq(history.numOfClaims(), 0); + } + + function testConstructorRevertsLengthMismatch( + uint8 numOfValidators, + uint8 numOfShares + ) external { + vm.assume(numOfValidators != numOfShares); + vm.expectRevert("PaymentSplitter: payees and shares length mismatch"); + deployQuorumUnchecked(numOfValidators, numOfShares); + } + + function testConstructorRevertsNoPayees() external { + vm.expectRevert("PaymentSplitter: no payees"); + deployQuorumUnchecked(0); + } + + function testConstructorRevertsAccountIsZeroAddress( + uint8 numOfValidators + ) external { + vm.assume(numOfValidators >= 1); + vm.expectRevert("PaymentSplitter: account is the zero address"); + new Quorum( + new address[](numOfValidators), + generateShares(numOfValidators), + history + ); + } + + function testConstructorRevertsSharesAreZero( + uint8 numOfValidators + ) external { + vm.assume(numOfValidators >= 1); + vm.expectRevert("PaymentSplitter: shares are 0"); + new Quorum( + generateValidators(numOfValidators), + new uint256[](numOfValidators), + history + ); + } + + function testConstructorRevertsAccountAlreadyHasShares( + uint8 numOfValidators + ) external { + vm.assume(numOfValidators >= 2); + vm.expectRevert("PaymentSplitter: account already has shares"); + new Quorum( + generateConstArray(numOfValidators, vm.addr(2)), + generateShares(numOfValidators), + history + ); + } + + function testConstructorRevertsTotalSharesOverflow( + uint8 numOfValidators + ) external { + vm.assume(numOfValidators >= 2); + vm.expectRevert(stdError.arithmeticError); + new Quorum( + generateValidators(numOfValidators), + generateConstArray(numOfValidators, type(uint256).max), + history + ); + } + + function testConstructor(uint8 numOfValidators) external { + vm.assume(numOfValidators >= 1); + + address[] memory validators = generateValidators(numOfValidators); + uint256[] memory shares = generateShares(numOfValidators); + + Quorum quorum = new Quorum(validators, shares, history); + + assertEq(quorum.numOfValidators(), numOfValidators); + assertEq(quorum.validators(), validators); + assertEq(quorum.totalShares(), sum(shares)); + assertEq(address(quorum.getHistory()), address(history)); + + for (uint256 i; i < numOfValidators; ++i) { + address validator = validators[i]; + uint256 id = quorum.validatorId(validator); + + assertEq(quorum.payee(i), validator); + assertEq(quorum.shares(validator), shares[i]); + assertEq(quorum.validatorById(id), validator); + } + } + + function testZeroValidatorId(uint8 numOfValidators, address addr) external { + Quorum quorum = deployQuorum(numOfValidators); + assertEq( + contains(quorum.validators(), addr), + quorum.validatorId(addr) != 0 + ); + } + + function testValidatorByInvalidId( + uint8 numOfValidators, + uint256 validatorId + ) external { + vm.assume(validatorId < 1 || validatorId > numOfValidators); + Quorum quorum = deployQuorum(numOfValidators); + assertEq(quorum.validatorById(validatorId), address(0)); + } + + function testSubmitClaimRevertsNotValidator( + uint8 numOfValidators, + address caller, + bytes calldata claim + ) external { + Quorum quorum = deployQuorum(numOfValidators); + vm.assume(quorum.validatorId(caller) == 0); + vm.prank(caller); + vm.expectRevert("Quorum: sender is not validator"); + quorum.submitClaim(claim); + } + + function testNumOfValidatorsInFavorOfClaim( + uint8 numOfValidators, + bytes calldata claim + ) external { + Quorum quorum = deployQuorum(numOfValidators); + assertEq(quorum.numOfValidatorsInFavorOf(claim), 0); + } + + function testIsValidatorInFavorOf( + uint8 numOfValidators, + uint256 validatorId, + bytes memory claim + ) external { + Quorum quorum = deployQuorum(numOfValidators); + assertFalse(quorum.isValidatorInFavorOf(validatorId, claim)); + } + + function testValidatorsInFavorOfClaim( + uint8 numOfValidators, + bytes calldata claim + ) external { + Quorum quorum = deployQuorum(numOfValidators); + assertEq(quorum.validatorsInFavorOf(claim), new address[](0)); + } + + function testSubmitClaim(bytes memory claim) external { + Quorum quorum = deployQuorum(3); + bool[] memory submitLog = new bool[](4); + + submitClaim(quorum, claim, submitLog, 1); + assertEq(history.numOfClaims(), 0); + + // resubmitting makes no difference + submitClaim(quorum, claim, submitLog, 1); + assertEq(history.numOfClaims(), 0); + + submitClaim(quorum, claim, submitLog, 2); + assertEq(history.numOfClaims(), 1); + assertEq(history.claim(0), claim); + + submitClaim(quorum, claim, submitLog, 3); + assertEq(history.numOfClaims(), 1); + } + + // Internal functions + // ------------------ + + function deployQuorum(uint256 numOfValidators) internal returns (Quorum) { + vm.assume(numOfValidators >= 1); + return deployQuorumUnchecked(numOfValidators, numOfValidators); + } + + function deployQuorumUnchecked( + uint256 numOfValidators + ) internal returns (Quorum) { + return deployQuorumUnchecked(numOfValidators, numOfValidators); + } + + function deployQuorumUnchecked( + uint256 numOfValidators, + uint256 numOfShares + ) internal returns (Quorum) { + return + new Quorum( + generateValidators(numOfValidators), + generateShares(numOfShares), + history + ); + } + + function generateValidators( + uint256 n + ) internal pure returns (address[] memory validators) { + validators = new address[](n); + for (uint256 i; i < n; ++i) { + validators[i] = vm.addr(i + 2); + } + } + + function generateShares( + uint256 n + ) internal pure returns (uint256[] memory shares) { + shares = new uint256[](n); + for (uint256 i; i < n; ++i) { + shares[i] = i + 1; + } + } + + function generateConstArray( + uint256 n, + address cte + ) internal pure returns (address[] memory a) { + a = new address[](n); + for (uint256 i; i < n; ++i) { + a[i] = cte; + } + } + + function generateConstArray( + uint256 n, + uint256 cte + ) internal pure returns (uint256[] memory a) { + a = new uint256[](n); + for (uint256 i; i < n; ++i) { + a[i] = cte; + } + } + + function submitClaim( + Quorum quorum, + bytes memory claim, + bool[] memory submitLog, + uint256 validatorId + ) internal { + vm.prank(quorum.validatorById(validatorId)); + quorum.submitClaim(claim); + + submitLog[validatorId] = true; + + address[] memory validatorsInFavor = quorum.validatorsInFavorOf(claim); + uint256 numOfValidatorsInFavorOfClaim; + + for (uint256 id; id < submitLog.length; ++id) { + bool inFavor = submitLog[id]; + assertEq(quorum.isValidatorInFavorOf(id, claim), inFavor); + if (inFavor) ++numOfValidatorsInFavorOfClaim; + } + + assertEq( + quorum.numOfValidatorsInFavorOf(claim), + numOfValidatorsInFavorOfClaim + ); + + assertEq(validatorsInFavor.length, numOfValidatorsInFavorOfClaim); + + bool[] memory visited = new bool[](submitLog.length); + + for (uint256 i; i < validatorsInFavor.length; ++i) { + uint256 id = quorum.validatorId(validatorsInFavor[i]); + assertFalse(visited[id]); + assertTrue(submitLog[id]); + visited[id] = true; + } + } +} diff --git a/onchain/rollups/test/foundry/util/TestBase.sol b/onchain/rollups/test/foundry/util/TestBase.sol index fd539ba9..1db84d3f 100644 --- a/onchain/rollups/test/foundry/util/TestBase.sol +++ b/onchain/rollups/test/foundry/util/TestBase.sol @@ -17,4 +17,24 @@ contract TestBase is Test { vm.assume(addr != MULTICALL3_ADDRESS); _; } + + function sum(uint256[] memory array) internal pure returns (uint256) { + uint256 total; + for (uint256 i; i < array.length; ++i) { + total += array[i]; + } + return total; + } + + 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; + } }