diff --git a/l1-contracts/src/governance/Gerousia.sol b/l1-contracts/src/governance/Gerousia.sol new file mode 100644 index 00000000000..1307be7929e --- /dev/null +++ b/l1-contracts/src/governance/Gerousia.sol @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {IRegistry} from "@aztec/governance/interfaces/IRegistry.sol"; +import {IApella} from "@aztec/governance/interfaces/IApella.sol"; +import {IGerousia} from "@aztec/governance/interfaces/IGerousia.sol"; +import {Errors} from "@aztec/governance/libraries/Errors.sol"; + +import {Slot, SlotLib} from "@aztec/core/libraries/TimeMath.sol"; +import {ILeonidas} from "@aztec/core/interfaces/ILeonidas.sol"; + +/** + * @notice A Gerousia implementation following the empire model + * Beware that while governance generally do not care about the implementation + * this implementation will since it is dependent on the sequencer selection. + * This also means that the implementation here will need to be "updated" if + * the interfaces of the sequencer selection changes, for exampel going optimistic. + */ +contract Gerousia is IGerousia { + using SlotLib for Slot; + + struct RoundAccounting { + Slot lastVote; + address leader; + bool executed; + mapping(address proposal => uint256 count) yeaCount; + } + + uint256 public constant LIFETIME_IN_ROUNDS = 5; + + IApella public immutable APELLA; + IRegistry public immutable REGISTRY; + uint256 public immutable N; + uint256 public immutable M; + + mapping(address instance => mapping(uint256 roundNumber => RoundAccounting)) public rounds; + + constructor(IApella _apella, IRegistry _registry, uint256 _n, uint256 _m) { + APELLA = _apella; + REGISTRY = _registry; + N = _n; + M = _m; + + require(N > M / 2, Errors.Gerousia__InvalidNAndMValues(N, M)); + require(N <= M, Errors.Gerousia__NCannotBeLargerTHanM(N, M)); + } + + // Note that this one is heavily realying on the fact that this contract + // could be updated at the same time as another upgrade is made. + + /** + * @notice Cast a vote on a proposal + * Note that this is assuming that the canonical rollup will cast it as + * part of block production, we will perform it here + * + * @param _proposal - The proposal to cast a vote on + * + * @return True if executed successfully, false otherwise + */ + function vote(address _proposal) external override(IGerousia) returns (bool) { + require(_proposal.code.length > 0, Errors.Gerousia__ProposalHaveNoCode(_proposal)); + + address instance = REGISTRY.getRollup(); + require(instance.code.length > 0, Errors.Gerousia__InstanceHaveNoCode(instance)); + + ILeonidas selection = ILeonidas(instance); + Slot currentSlot = selection.getCurrentSlot(); + + uint256 roundNumber = computeRound(currentSlot); + + RoundAccounting storage round = rounds[instance][roundNumber]; + + require(currentSlot > round.lastVote, Errors.Gerousia__VoteAlreadyCastForSlot(currentSlot)); + + address proposer = selection.getCurrentProposer(); + require(msg.sender == proposer, Errors.Gerousia__OnlyProposerCanVote(msg.sender, proposer)); + + round.yeaCount[_proposal] += 1; + round.lastVote = currentSlot; + + // @todo We can optimise here for gas by storing some of it packed with the leader. + if (round.leader != _proposal && round.yeaCount[_proposal] > round.yeaCount[round.leader]) { + round.leader = _proposal; + } + + emit VoteCast(_proposal, roundNumber, msg.sender); + + return true; + } + + /** + * @notice Push the proposal to the appela + * + * @param _roundNumber - The round number to execute + * + * @return True if executed successfully, false otherwise + */ + function pushProposal(uint256 _roundNumber) external override(IGerousia) returns (bool) { + // Need to ensure that the round is not active. + address instance = REGISTRY.getRollup(); + require(instance.code.length > 0, Errors.Gerousia__InstanceHaveNoCode(instance)); + + ILeonidas selection = ILeonidas(instance); + Slot currentSlot = selection.getCurrentSlot(); + + uint256 currentRound = computeRound(currentSlot); + require(_roundNumber < currentRound, Errors.Gerousia__CanOnlyPushProposalInPast()); + require( + _roundNumber + LIFETIME_IN_ROUNDS >= currentRound, + Errors.Gerousia__ProposalTooOld(_roundNumber) + ); + + RoundAccounting storage round = rounds[instance][_roundNumber]; + require(!round.executed, Errors.Gerousia__ProposalAlreadyExecuted(_roundNumber)); + require(round.leader != address(0), Errors.Gerousia__ProposalCannotBeAddressZero()); + require(round.yeaCount[round.leader] >= N, Errors.Gerousia__InsufficientVotes()); + + round.executed = true; + + emit ProposalPushed(round.leader, _roundNumber); + + require(APELLA.propose(round.leader), Errors.Gerousia__FailedToPropose(round.leader)); + return true; + } + + /** + * @notice Fetch the yea count for a specific proposal in a specific round on a specific instance + * + * @param _instance - The address of the instance + * @param _round - The round to lookup + * @param _proposal - The address of the proposal + * + * @return The number of yea votes + */ + function yeaCount(address _instance, uint256 _round, address _proposal) + external + view + override(IGerousia) + returns (uint256) + { + return rounds[_instance][_round].yeaCount[_proposal]; + } + + /** + * @notice Computes the round at the given slot + * + * @param _slot - The slot to compute round for + * + * @return The round number + */ + function computeRound(Slot _slot) public view override(IGerousia) returns (uint256) { + return _slot.unwrap() / M; + } +} diff --git a/l1-contracts/src/governance/interfaces/IApella.sol b/l1-contracts/src/governance/interfaces/IApella.sol new file mode 100644 index 00000000000..15728b7143f --- /dev/null +++ b/l1-contracts/src/governance/interfaces/IApella.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +interface IApella { + function propose(address _proposal) external returns (bool); +} diff --git a/l1-contracts/src/governance/interfaces/IGerousia.sol b/l1-contracts/src/governance/interfaces/IGerousia.sol new file mode 100644 index 00000000000..bbd8a6c2066 --- /dev/null +++ b/l1-contracts/src/governance/interfaces/IGerousia.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {Slot} from "@aztec/core/libraries/TimeMath.sol"; + +interface IGerousia { + event VoteCast(address indexed proposal, uint256 indexed round, address indexed voter); + event ProposalPushed(address indexed proposal, uint256 indexed round); + + function vote(address _proposa) external returns (bool); + function pushProposal(uint256 _roundNumber) external returns (bool); + function yeaCount(address _instance, uint256 _round, address _proposal) + external + view + returns (uint256); + function computeRound(Slot _slot) external view returns (uint256); +} diff --git a/l1-contracts/src/governance/libraries/Errors.sol b/l1-contracts/src/governance/libraries/Errors.sol index 80a2c29e8f2..41611a86eec 100644 --- a/l1-contracts/src/governance/libraries/Errors.sol +++ b/l1-contracts/src/governance/libraries/Errors.sol @@ -2,6 +2,8 @@ // Copyright 2023 Aztec Labs. pragma solidity >=0.8.27; +import {Slot} from "@aztec/core/libraries/TimeMath.sol"; + /** * @title Errors Library * @author Aztec Labs @@ -10,7 +12,19 @@ pragma solidity >=0.8.27; * when there are multiple contracts that could have thrown the error. */ library Errors { - // Registry + error Gerousia__CanOnlyPushProposalInPast(); // 0x49fdf611" + error Gerousia__FailedToPropose(address proposal); // 0x6ca2a2ed + error Gerousia__InstanceHaveNoCode(address instance); // 0x20a3b441 + error Gerousia__InsufficientVotes(); // 0xba1e05ef + error Gerousia__InvalidNAndMValues(uint256 N, uint256 M); // 0x520d9704 + error Gerousia__NCannotBeLargerTHanM(uint256 N, uint256 M); // 0x2fdfc063 + error Gerousia__OnlyProposerCanVote(address caller, address proposer); // 0xba27df38 + error Gerousia__ProposalAlreadyExecuted(uint256 roundNumber); // 0x7aeacb17 + error Gerousia__ProposalCannotBeAddressZero(); // 0xdb3e4b6e + error Gerousia__ProposalHaveNoCode(address proposal); // 0xdce0615b + error Gerousia__ProposalTooOld(uint256 roundNumber); //0x02283b1a + error Gerousia__VoteAlreadyCastForSlot(Slot slot); //0xc2201452 + error Nomismatokopio__InssuficientMintAvailable(uint256 available, uint256 needed); // 0xf268b931 error Registry__RollupAlreadyRegistered(address rollup); // 0x3c34eabf diff --git a/l1-contracts/test/governance/gerousia/Base.t.sol b/l1-contracts/test/governance/gerousia/Base.t.sol new file mode 100644 index 00000000000..a13484f1f8e --- /dev/null +++ b/l1-contracts/test/governance/gerousia/Base.t.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {Test} from "forge-std/Test.sol"; + +import {Registry} from "@aztec/governance/Registry.sol"; +import {Gerousia} from "@aztec/governance/Gerousia.sol"; + +import {IApella} from "@aztec/governance/interfaces/IApella.sol"; + +contract FakeApella is IApella { + address public gerousia; + + mapping(address => bool) public proposals; + + function setGerousia(address _gerousia) external { + gerousia = _gerousia; + } + + function propose(address _proposal) external override(IApella) returns (bool) { + proposals[_proposal] = true; + return true; + } +} + +contract GerousiaBase is Test { + Registry internal registry; + FakeApella internal apella; + Gerousia internal gerousia; + + function setUp() public virtual { + registry = new Registry(address(this)); + apella = new FakeApella(); + + gerousia = new Gerousia(apella, registry, 667, 1000); + + apella.setGerousia(address(gerousia)); + } +} diff --git a/l1-contracts/test/governance/gerousia/constructor.t.sol b/l1-contracts/test/governance/gerousia/constructor.t.sol new file mode 100644 index 00000000000..e3fb6c87b01 --- /dev/null +++ b/l1-contracts/test/governance/gerousia/constructor.t.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {Test} from "forge-std/Test.sol"; +import {Gerousia} from "@aztec/governance/Gerousia.sol"; +import {Errors} from "@aztec/governance/libraries/Errors.sol"; +import {IRegistry} from "@aztec/governance/interfaces/IRegistry.sol"; +import {IApella} from "@aztec/governance/interfaces/IApella.sol"; + +contract ConstructorTest is Test { + IApella internal constant APELLA = IApella(address(0x01)); + IRegistry internal constant REGISTRY = IRegistry(address(0x02)); + + function test_WhenNIsLessThanOrEqualHalfOfM(uint256 _n, uint256 _m) external { + // it revert + + uint256 n = bound(_n, 0, _m / 2); + + vm.expectRevert(abi.encodeWithSelector(Errors.Gerousia__InvalidNAndMValues.selector, n, _m)); + new Gerousia(APELLA, REGISTRY, n, _m); + } + + function test_WhenNLargerThanM(uint256 _n, uint256 _m) external { + // it revert + uint256 m = bound(_m, 0, type(uint256).max - 1); + uint256 n = bound(_n, m + 1, type(uint256).max); + + vm.expectRevert(abi.encodeWithSelector(Errors.Gerousia__NCannotBeLargerTHanM.selector, n, m)); + new Gerousia(APELLA, REGISTRY, n, m); + } + + function test_WhenNIsGreatherThanHalfOfM(uint256 _n, uint256 _m) external { + // it deploys + + uint256 m = bound(_m, 1, type(uint256).max); + uint256 n = bound(_n, m / 2 + 1, m); + + Gerousia g = new Gerousia(APELLA, REGISTRY, n, m); + + assertEq(address(g.APELLA()), address(APELLA)); + assertEq(address(g.REGISTRY()), address(REGISTRY)); + assertEq(g.N(), n); + assertEq(g.M(), m); + } +} diff --git a/l1-contracts/test/governance/gerousia/constructor.tree b/l1-contracts/test/governance/gerousia/constructor.tree new file mode 100644 index 00000000000..dbbe248ce5d --- /dev/null +++ b/l1-contracts/test/governance/gerousia/constructor.tree @@ -0,0 +1,7 @@ +ConstructorTest +├── when N is less than or equal half of M +│ └── it revert +├── when N larger than M +│ └── it revert +└── when N is greather than half of M + └── it deploys \ No newline at end of file diff --git a/l1-contracts/test/governance/gerousia/mocks/FalsyApella.sol b/l1-contracts/test/governance/gerousia/mocks/FalsyApella.sol new file mode 100644 index 00000000000..65fb4c201e7 --- /dev/null +++ b/l1-contracts/test/governance/gerousia/mocks/FalsyApella.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {IApella} from "@aztec/governance/interfaces/IApella.sol"; + +contract FalsyApella is IApella { + function propose(address) external pure override(IApella) returns (bool) { + return false; + } +} diff --git a/l1-contracts/test/governance/gerousia/mocks/FaultyApella.sol b/l1-contracts/test/governance/gerousia/mocks/FaultyApella.sol new file mode 100644 index 00000000000..69e65231917 --- /dev/null +++ b/l1-contracts/test/governance/gerousia/mocks/FaultyApella.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {IApella} from "@aztec/governance/interfaces/IApella.sol"; + +contract FaultyApella is IApella { + error Faulty(); + + function propose(address) external pure override(IApella) returns (bool) { + require(false, Faulty()); + return true; + } +} diff --git a/l1-contracts/test/governance/gerousia/pushProposal.t.sol b/l1-contracts/test/governance/gerousia/pushProposal.t.sol new file mode 100644 index 00000000000..ee4d86d75ad --- /dev/null +++ b/l1-contracts/test/governance/gerousia/pushProposal.t.sol @@ -0,0 +1,243 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {IGerousia} from "@aztec/governance/interfaces/IGerousia.sol"; +import {GerousiaBase} from "./Base.t.sol"; +import {Leonidas} from "@aztec/core/Leonidas.sol"; +import {Errors} from "@aztec/governance/libraries/Errors.sol"; +import {Slot, SlotLib, Timestamp} from "@aztec/core/libraries/TimeMath.sol"; + +import {FaultyApella} from "./mocks/FaultyApella.sol"; +import {FalsyApella} from "./mocks/FalsyApella.sol"; + +contract PushProposalTest is GerousiaBase { + using SlotLib for Slot; + + Leonidas internal leonidas; + + address internal proposal = address(this); + address internal proposer = address(0); + + function test_GivenCanonicalInstanceHoldNoCode(uint256 _roundNumber) external { + // it revert + vm.expectRevert( + abi.encodeWithSelector(Errors.Gerousia__InstanceHaveNoCode.selector, address(0xdead)) + ); + gerousia.pushProposal(_roundNumber); + } + + modifier givenCanonicalInstanceHoldCode() { + leonidas = new Leonidas(address(this)); + registry.upgrade(address(leonidas)); + + // We jump into the future since slot 0, will behave as if already voted in + vm.warp(Timestamp.unwrap(leonidas.getTimestampForSlot(Slot.wrap(1)))); + _; + } + + function test_WhenRoundNotInPast() external givenCanonicalInstanceHoldCode { + // it revert + vm.expectRevert(abi.encodeWithSelector(Errors.Gerousia__CanOnlyPushProposalInPast.selector)); + gerousia.pushProposal(0); + } + + modifier whenRoundInPast() { + vm.warp(Timestamp.unwrap(leonidas.getTimestampForSlot(Slot.wrap(gerousia.M())))); + _; + } + + function test_WhenRoundTooFarInPast() external givenCanonicalInstanceHoldCode whenRoundInPast { + // it revert + + vm.warp( + Timestamp.unwrap( + leonidas.getTimestampForSlot(Slot.wrap((gerousia.LIFETIME_IN_ROUNDS() + 1) * gerousia.M())) + ) + ); + + vm.expectRevert(abi.encodeWithSelector(Errors.Gerousia__ProposalTooOld.selector, 0)); + gerousia.pushProposal(0); + } + + modifier whenRoundInRecentPast() { + _; + } + + function test_GivenRoundAlreadyExecuted() + external + givenCanonicalInstanceHoldCode + whenRoundInPast + whenRoundInRecentPast + { + // it revert + + { + // Need to execute a proposal first here. + for (uint256 i = 0; i < gerousia.N(); i++) { + vm.prank(proposer); + assertTrue(gerousia.vote(proposal)); + vm.warp( + Timestamp.unwrap(leonidas.getTimestampForSlot(leonidas.getCurrentSlot() + Slot.wrap(1))) + ); + } + vm.warp( + Timestamp.unwrap( + leonidas.getTimestampForSlot(leonidas.getCurrentSlot() + Slot.wrap(gerousia.M())) + ) + ); + gerousia.pushProposal(1); + } + + vm.expectRevert(abi.encodeWithSelector(Errors.Gerousia__ProposalAlreadyExecuted.selector, 1)); + gerousia.pushProposal(1); + } + + modifier givenRoundNotExecutedBefore() { + _; + } + + function test_GivenLeaderIsAddress0() + external + givenCanonicalInstanceHoldCode + whenRoundInPast + whenRoundInRecentPast + givenRoundNotExecutedBefore + { + // it revert + vm.expectRevert(abi.encodeWithSelector(Errors.Gerousia__ProposalCannotBeAddressZero.selector)); + gerousia.pushProposal(0); + } + + modifier givenLeaderIsNotAddress0() { + _; + } + + function test_GivenInsufficientYea() + external + givenCanonicalInstanceHoldCode + whenRoundInPast + whenRoundInRecentPast + givenRoundNotExecutedBefore + givenLeaderIsNotAddress0 + { + // it revert + + vm.prank(proposer); + gerousia.vote(proposal); + + vm.warp( + Timestamp.unwrap( + leonidas.getTimestampForSlot(leonidas.getCurrentSlot() + Slot.wrap(gerousia.M())) + ) + ); + vm.expectRevert(abi.encodeWithSelector(Errors.Gerousia__InsufficientVotes.selector)); + gerousia.pushProposal(1); + } + + modifier givenSufficientYea() { + for (uint256 i = 0; i < gerousia.N(); i++) { + vm.prank(proposer); + assertTrue(gerousia.vote(proposal)); + vm.warp( + Timestamp.unwrap(leonidas.getTimestampForSlot(leonidas.getCurrentSlot() + Slot.wrap(1))) + ); + } + vm.warp( + Timestamp.unwrap( + leonidas.getTimestampForSlot(leonidas.getCurrentSlot() + Slot.wrap(gerousia.M())) + ) + ); + + _; + } + + function test_GivenNewCanonicalInstance() + external + givenCanonicalInstanceHoldCode + whenRoundInPast + whenRoundInRecentPast + givenRoundNotExecutedBefore + givenLeaderIsNotAddress0 + givenSufficientYea + { + // it revert + + // When using a new registry we change the gerousia's interpetation of time :O + Leonidas freshInstance = new Leonidas(address(this)); + registry.upgrade(address(freshInstance)); + + // The old is still there, just not executable. + (, address leader, bool executed) = gerousia.rounds(address(leonidas), 1); + assertFalse(executed); + assertEq(leader, proposal); + + // As time is perceived differently, round 1 is currently in the future + vm.expectRevert(abi.encodeWithSelector(Errors.Gerousia__CanOnlyPushProposalInPast.selector)); + gerousia.pushProposal(1); + + // Jump 2 rounds, since we are currently in round 0 + vm.warp( + Timestamp.unwrap( + freshInstance.getTimestampForSlot( + freshInstance.getCurrentSlot() + Slot.wrap(2 * gerousia.M()) + ) + ) + ); + vm.expectRevert(abi.encodeWithSelector(Errors.Gerousia__ProposalCannotBeAddressZero.selector)); + gerousia.pushProposal(1); + } + + function test_GivenApellaCallReturnFalse() + external + givenCanonicalInstanceHoldCode + whenRoundInPast + whenRoundInRecentPast + givenRoundNotExecutedBefore + givenLeaderIsNotAddress0 + givenSufficientYea + { + // it revert + FalsyApella falsy = new FalsyApella(); + vm.etch(address(apella), address(falsy).code); + + vm.expectRevert(abi.encodeWithSelector(Errors.Gerousia__FailedToPropose.selector, proposal)); + gerousia.pushProposal(1); + } + + function test_GivenApellaCallFails() + external + givenCanonicalInstanceHoldCode + whenRoundInPast + whenRoundInRecentPast + givenRoundNotExecutedBefore + givenLeaderIsNotAddress0 + givenSufficientYea + { + // it revert + FaultyApella faulty = new FaultyApella(); + vm.etch(address(apella), address(faulty).code); + + vm.expectRevert(abi.encodeWithSelector(FaultyApella.Faulty.selector)); + gerousia.pushProposal(1); + } + + function test_GivenApellaCallSucceeds() + external + givenCanonicalInstanceHoldCode + whenRoundInPast + whenRoundInRecentPast + givenRoundNotExecutedBefore + givenLeaderIsNotAddress0 + givenSufficientYea + { + // it update executed to true + // it emits {ProposalPushed} event + // it return true + vm.expectEmit(true, true, true, true, address(gerousia)); + emit IGerousia.ProposalPushed(proposal, 1); + assertTrue(gerousia.pushProposal(1)); + (, address leader, bool executed) = gerousia.rounds(address(leonidas), 1); + assertTrue(executed); + assertEq(leader, proposal); + } +} diff --git a/l1-contracts/test/governance/gerousia/pushProposal.tree b/l1-contracts/test/governance/gerousia/pushProposal.tree new file mode 100644 index 00000000000..c201b0ce67a --- /dev/null +++ b/l1-contracts/test/governance/gerousia/pushProposal.tree @@ -0,0 +1,29 @@ +PushProposalTest +├── given canonical instance hold no code +│ └── it revert +└── given canonical instance hold code + ├── when round not in past + │ └── it revert + └── when round in past + ├── when round too far in past + │ └── it revert + └── when round in recent past + ├── given round already executed + │ └── it revert + └── given round not executed before + ├── given leader is address 0 + │ └── it revert + └── given leader is not address 0 + ├── given insufficient yea + │ └── it revert + └── given sufficient yea + ├── given new canonical instance + │ └── it revert + ├── given apella call return false + │ └── it revert + ├── given apella call fails + │ └── it revert + └── given apella call succeeds + ├── it update executed to true + ├── it emits {ProposalPushed} event + └── it return true \ No newline at end of file diff --git a/l1-contracts/test/governance/gerousia/vote.t.sol b/l1-contracts/test/governance/gerousia/vote.t.sol new file mode 100644 index 00000000000..2999d3459d4 --- /dev/null +++ b/l1-contracts/test/governance/gerousia/vote.t.sol @@ -0,0 +1,301 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {IGerousia} from "@aztec/governance/interfaces/IGerousia.sol"; +import {GerousiaBase} from "./Base.t.sol"; +import {Leonidas} from "@aztec/core/Leonidas.sol"; +import {Errors} from "@aztec/governance/libraries/Errors.sol"; +import {Slot, SlotLib, Timestamp} from "@aztec/core/libraries/TimeMath.sol"; + +contract VoteTest is GerousiaBase { + using SlotLib for Slot; + + address internal proposal = address(0xdeadbeef); + address internal proposer = address(0); + Leonidas internal leonidas; + + function test_WhenProposalHoldNoCode() external { + // it revert + vm.expectRevert(abi.encodeWithSelector(Errors.Gerousia__ProposalHaveNoCode.selector, proposal)); + gerousia.vote(proposal); + } + + modifier whenProposalHoldCode() { + proposal = address(this); + _; + } + + function test_GivenCanonicalRollupHoldNoCode() external whenProposalHoldCode { + // it revert + vm.expectRevert( + abi.encodeWithSelector(Errors.Gerousia__InstanceHaveNoCode.selector, address(0xdead)) + ); + gerousia.vote(proposal); + } + + modifier givenCanonicalRollupHoldCode() { + leonidas = new Leonidas(address(this)); + registry.upgrade(address(leonidas)); + + // We jump into the future since slot 0, will behave as if already voted in + vm.warp(Timestamp.unwrap(leonidas.getTimestampForSlot(Slot.wrap(1)))); + _; + } + + function test_GivenAVoteAlreadyCastInTheSlot() + external + whenProposalHoldCode + givenCanonicalRollupHoldCode + { + // it revert + + Slot currentSlot = leonidas.getCurrentSlot(); + assertEq(currentSlot.unwrap(), 1); + vm.prank(proposer); + gerousia.vote(proposal); + + vm.expectRevert( + abi.encodeWithSelector(Errors.Gerousia__VoteAlreadyCastForSlot.selector, currentSlot) + ); + gerousia.vote(proposal); + } + + modifier givenNoVoteAlreadyCastInTheSlot() { + _; + } + + function test_WhenCallerIsNotProposer(address _proposer) + external + whenProposalHoldCode + givenCanonicalRollupHoldCode + givenNoVoteAlreadyCastInTheSlot + { + // it revert + vm.assume(_proposer != proposer); + vm.prank(_proposer); + vm.expectRevert( + abi.encodeWithSelector(Errors.Gerousia__OnlyProposerCanVote.selector, _proposer, proposer) + ); + gerousia.vote(proposal); + } + + modifier whenCallerIsProposer() { + // Lets make sure that there first is a leader + uint256 votesOnProposal = 5; + + for (uint256 i = 0; i < votesOnProposal; i++) { + vm.warp( + Timestamp.unwrap(leonidas.getTimestampForSlot(leonidas.getCurrentSlot() + Slot.wrap(1))) + ); + vm.prank(proposer); + gerousia.vote(proposal); + } + + Slot currentSlot = leonidas.getCurrentSlot(); + uint256 round = gerousia.computeRound(currentSlot); + (Slot lastVote, address leader, bool executed) = gerousia.rounds(address(leonidas), round); + assertEq( + gerousia.yeaCount(address(leonidas), round, leader), + votesOnProposal, + "invalid number of votes" + ); + assertFalse(executed); + assertEq(leader, proposal); + assertEq(currentSlot.unwrap(), lastVote.unwrap()); + + vm.warp( + Timestamp.unwrap(leonidas.getTimestampForSlot(leonidas.getCurrentSlot() + Slot.wrap(1))) + ); + + _; + } + + function test_GivenNewCanonicalInstance() + external + whenProposalHoldCode + givenCanonicalRollupHoldCode + givenNoVoteAlreadyCastInTheSlot + whenCallerIsProposer + { + // it ignore votes from prior instance + // it increase the yea count + // it updates the leader to the proposal + // it emits {VoteCast} event + // it returns true + + Slot leonidasSlot = leonidas.getCurrentSlot(); + uint256 leonidasRound = gerousia.computeRound(leonidasSlot); + uint256 yeaBefore = gerousia.yeaCount(address(leonidas), leonidasRound, proposal); + + Leonidas freshInstance = new Leonidas(address(this)); + registry.upgrade(address(freshInstance)); + + vm.warp(Timestamp.unwrap(freshInstance.getTimestampForSlot(Slot.wrap(1)))); + + Slot freshSlot = freshInstance.getCurrentSlot(); + uint256 freshRound = gerousia.computeRound(freshSlot); + + vm.prank(proposer); + vm.expectEmit(true, true, true, true, address(gerousia)); + emit IGerousia.VoteCast(proposal, freshRound, proposer); + assertTrue(gerousia.vote(proposal)); + + // Check the new instance + { + (Slot lastVote, address leader, bool executed) = + gerousia.rounds(address(freshInstance), freshRound); + assertEq( + gerousia.yeaCount(address(freshInstance), freshRound, leader), 1, "invalid number of votes" + ); + assertFalse(executed); + assertEq(leader, proposal); + assertEq(freshSlot.unwrap(), lastVote.unwrap(), "invalid slot [FRESH]"); + } + + // The old instance + { + (Slot lastVote, address leader, bool executed) = + gerousia.rounds(address(leonidas), leonidasRound); + assertEq( + gerousia.yeaCount(address(leonidas), leonidasRound, proposal), + yeaBefore, + "invalid number of votes" + ); + assertFalse(executed); + assertEq(leader, proposal); + assertEq(leonidasSlot.unwrap(), lastVote.unwrap() + 1, "invalid slot [LEONIDAS]"); + } + } + + function test_GivenRoundChanged() + external + whenProposalHoldCode + givenCanonicalRollupHoldCode + givenNoVoteAlreadyCastInTheSlot + whenCallerIsProposer + { + // it ignore votes in prior round + // it increase the yea count + // it updates the leader to the proposal + // it emits {VoteCast} event + // it returns true + } + + modifier givenRoundAndInstanceIsStable() { + _; + } + + function test_GivenProposalIsLeader() + external + whenProposalHoldCode + givenCanonicalRollupHoldCode + givenNoVoteAlreadyCastInTheSlot + whenCallerIsProposer + givenRoundAndInstanceIsStable + { + // it increase the yea count + // it emits {VoteCast} event + // it returns true + + Slot currentSlot = leonidas.getCurrentSlot(); + uint256 round = gerousia.computeRound(currentSlot); + + uint256 yeaBefore = gerousia.yeaCount(address(leonidas), round, proposal); + + vm.prank(proposer); + vm.expectEmit(true, true, true, true, address(gerousia)); + emit IGerousia.VoteCast(proposal, round, proposer); + assertTrue(gerousia.vote(proposal)); + + (Slot lastVote, address leader, bool executed) = gerousia.rounds(address(leonidas), round); + assertEq( + gerousia.yeaCount(address(leonidas), round, leader), yeaBefore + 1, "invalid number of votes" + ); + assertFalse(executed); + assertEq(leader, proposal); + assertEq(currentSlot.unwrap(), lastVote.unwrap()); + } + + function test_GivenProposalHaveFeverVotesThanLeader() + external + whenProposalHoldCode + givenCanonicalRollupHoldCode + givenNoVoteAlreadyCastInTheSlot + whenCallerIsProposer + givenRoundAndInstanceIsStable + { + // it increase the yea count + // it emits {VoteCast} event + // it returns true + + Slot currentSlot = leonidas.getCurrentSlot(); + uint256 round = gerousia.computeRound(currentSlot); + + uint256 leaderYeaBefore = gerousia.yeaCount(address(leonidas), round, proposal); + + vm.prank(proposer); + vm.expectEmit(true, true, true, true, address(gerousia)); + emit IGerousia.VoteCast(address(leonidas), round, proposer); + assertTrue(gerousia.vote(address(leonidas))); + + (Slot lastVote, address leader, bool executed) = gerousia.rounds(address(leonidas), round); + assertEq( + gerousia.yeaCount(address(leonidas), round, leader), + leaderYeaBefore, + "invalid number of votes" + ); + assertEq( + gerousia.yeaCount(address(leonidas), round, address(leonidas)), 1, "invalid number of votes" + ); + assertFalse(executed); + assertEq(leader, proposal); + assertEq(currentSlot.unwrap(), lastVote.unwrap()); + } + + function test_GivenProposalHaveMoreVotesThanLeader() + external + whenProposalHoldCode + givenCanonicalRollupHoldCode + givenNoVoteAlreadyCastInTheSlot + whenCallerIsProposer + givenRoundAndInstanceIsStable + { + // it increase the yea count + // it updates the leader to the proposal + // it emits {VoteCast} event + // it returns true + + Slot currentSlot = leonidas.getCurrentSlot(); + uint256 round = gerousia.computeRound(currentSlot); + + uint256 leaderYeaBefore = gerousia.yeaCount(address(leonidas), round, proposal); + + for (uint256 i = 0; i < leaderYeaBefore + 1; i++) { + vm.prank(proposer); + vm.expectEmit(true, true, true, true, address(gerousia)); + emit IGerousia.VoteCast(address(leonidas), round, proposer); + assertTrue(gerousia.vote(address(leonidas))); + + vm.warp( + Timestamp.unwrap(leonidas.getTimestampForSlot(leonidas.getCurrentSlot() + Slot.wrap(1))) + ); + } + + { + (Slot lastVote, address leader, bool executed) = gerousia.rounds(address(leonidas), round); + assertEq( + gerousia.yeaCount(address(leonidas), round, address(leonidas)), + leaderYeaBefore + 1, + "invalid number of votes" + ); + assertFalse(executed); + assertEq(leader, address(leonidas)); + assertEq( + gerousia.yeaCount(address(leonidas), round, proposal), + leaderYeaBefore, + "invalid number of votes" + ); + assertEq(lastVote.unwrap(), currentSlot.unwrap() + leaderYeaBefore); + } + } +} diff --git a/l1-contracts/test/governance/gerousia/vote.tree b/l1-contracts/test/governance/gerousia/vote.tree new file mode 100644 index 00000000000..44dad7fb3fc --- /dev/null +++ b/l1-contracts/test/governance/gerousia/vote.tree @@ -0,0 +1,39 @@ +VoteTest +├── when proposal hold no code +│ └── it revert +└── when proposal hold code + ├── given canonical rollup hold no code + │ └── it revert + └── given canonical rollup hold code + ├── given a vote already cast in the slot + │ └── it revert + └── given no vote already cast in the slot + ├── when caller is not proposer + │ └── it revert + └── when caller is proposer + ├── given new canonical instance + │ ├── it ignore votes from prior instance + │ ├── it increase the yea count + │ ├── it updates the leader to the proposal + │ ├── it emits {VoteCast} event + │ └── it returns true + ├── given round changed + │ ├── it ignore votes in prior round + │ ├── it increase the yea count + │ ├── it updates the leader to the proposal + │ ├── it emits {VoteCast} event + │ └── it returns true + └── given round and instance is stable + ├── given proposal is leader + │ ├── it increase the yea count + │ ├── it emits {VoteCast} event + │ └── it returns true + ├── given proposal have fever votes than leader + │ ├── it increase the yea count + │ ├── it emits {VoteCast} event + │ └── it returns true + └── given proposal have more votes than leader + ├── it increase the yea count + ├── it updates the leader to the proposal + ├── it emits {VoteCast} event + └── it returns true \ No newline at end of file