diff --git a/l1-contracts/.solhint.json b/l1-contracts/.solhint.json index 970f17dbcc5..3c14ae3cf16 100644 --- a/l1-contracts/.solhint.json +++ b/l1-contracts/.solhint.json @@ -20,10 +20,10 @@ "error" ], "not-rely-on-time": "off", - "const-name-snakecase": [ - "error", + "immutable-vars-naming": [ + "warn", { - "treatImmutableVarAsConstant": true + "immutablesAsConstants": true } ], "var-name-mixedcase": [ @@ -47,6 +47,7 @@ "func-param-name-leading-underscore": [ "error" ], + "interface-starts-with-i": "warn", "func-param-name-mixedcase": [ "error" ], @@ -62,6 +63,7 @@ "comprehensive-interface": [ "error" ], - "custom-error-over-require": "off" + "custom-error-over-require": "off", + "no-unused-import": "error" } } \ No newline at end of file diff --git a/l1-contracts/foundry.toml b/l1-contracts/foundry.toml index b511b3d64c2..dbf806aecb9 100644 --- a/l1-contracts/foundry.toml +++ b/l1-contracts/foundry.toml @@ -6,7 +6,8 @@ solc = "0.8.27" remappings = [ "@oz/=lib/openzeppelin-contracts/contracts/", - "@aztec/=src" + "@aztec/=src", + "@test/=test" ] # See more config options https://github.com/foundry-rs/foundry/tree/master/config diff --git a/l1-contracts/src/governance/Apella.sol b/l1-contracts/src/governance/Apella.sol new file mode 100644 index 00000000000..da05aa10e3c --- /dev/null +++ b/l1-contracts/src/governance/Apella.sol @@ -0,0 +1,288 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {IPayload} from "@aztec/governance/interfaces/IPayload.sol"; +import {IApella} from "@aztec/governance/interfaces/IApella.sol"; + +import {DataStructures} from "@aztec/governance/libraries/DataStructures.sol"; +import {ConfigurationLib} from "@aztec/governance/libraries/ConfigurationLib.sol"; +import {Errors} from "@aztec/governance/libraries/Errors.sol"; +import {ProposalLib, VoteTabulationReturn} from "@aztec/governance/libraries/ProposalLib.sol"; +import {UserLib} from "@aztec/governance/libraries/UserLib.sol"; + +import {Timestamp} from "@aztec/core/libraries/TimeMath.sol"; + +import {IERC20} from "@oz/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@oz/token/ERC20/utils/SafeERC20.sol"; + +contract Apella is IApella { + using SafeERC20 for IERC20; + using ProposalLib for DataStructures.Proposal; + using UserLib for DataStructures.User; + using ConfigurationLib for DataStructures.Configuration; + + IERC20 public immutable ASSET; + + address public gerousia; + + mapping(uint256 proposalId => DataStructures.Proposal) internal proposals; + mapping(uint256 proposalId => mapping(address user => DataStructures.Ballot)) public ballots; + mapping(address => DataStructures.User) internal users; + mapping(uint256 withdrawalId => DataStructures.Withdrawal) internal withdrawals; + + DataStructures.Configuration internal configuration; + DataStructures.User internal total; + uint256 public proposalCount; + uint256 public withdrawalCount; + + constructor(IERC20 _asset, address _gerousia) { + ASSET = _asset; + gerousia = _gerousia; + + configuration = DataStructures.Configuration({ + votingDelay: Timestamp.wrap(3600), + votingDuration: Timestamp.wrap(3600), + executionDelay: Timestamp.wrap(3600), + gracePeriod: Timestamp.wrap(3600), + quorum: 0.1e18, + voteDifferential: 0.04e18, + minimumVotes: 1000e18 + }); + configuration.assertValid(); + } + + function updateGerousia(address _gerousia) external override(IApella) { + require(msg.sender == address(this), Errors.Apella__CallerNotSelf(msg.sender, address(this))); + gerousia = _gerousia; + emit GerousiaUpdated(_gerousia); + } + + function updateConfiguration(DataStructures.Configuration memory _configuration) + external + override(IApella) + { + require(msg.sender == address(this), Errors.Apella__CallerNotSelf(msg.sender, address(this))); + + // This following MUST revert if the configuration is invalid + _configuration.assertValid(); + + configuration = _configuration; + + emit ConfigurationUpdated(Timestamp.wrap(block.timestamp)); + } + + function deposit(address _onBehalfOf, uint256 _amount) external override(IApella) { + ASSET.safeTransferFrom(msg.sender, address(this), _amount); + users[_onBehalfOf].add(_amount); + total.add(_amount); + + emit Deposit(msg.sender, _onBehalfOf, _amount); + } + + function initiateWithdraw(address _to, uint256 _amount) + external + override(IApella) + returns (uint256) + { + users[msg.sender].sub(_amount); + total.sub(_amount); + + uint256 withdrawalId = withdrawalCount++; + + withdrawals[withdrawalId] = DataStructures.Withdrawal({ + amount: _amount, + unlocksAt: Timestamp.wrap(block.timestamp) + configuration.lockDelay(), + recipient: _to, + claimed: false + }); + + emit WithdrawInitiated(withdrawalId, _to, _amount); + + return withdrawalId; + } + + function finaliseWithdraw(uint256 _withdrawalId) external override(IApella) { + DataStructures.Withdrawal storage withdrawal = withdrawals[_withdrawalId]; + require(!withdrawal.claimed, Errors.Apella__WithdrawalAlreadyclaimed()); + require( + Timestamp.wrap(block.timestamp) >= withdrawal.unlocksAt, + Errors.Apella__WithdrawalNotUnlockedYet(Timestamp.wrap(block.timestamp), withdrawal.unlocksAt) + ); + withdrawal.claimed = true; + + emit WithdrawFinalised(_withdrawalId); + + ASSET.safeTransfer(withdrawal.recipient, withdrawal.amount); + } + + function propose(IPayload _proposal) external override(IApella) returns (bool) { + require(msg.sender == gerousia, Errors.Apella__CallerNotGerousia(msg.sender, gerousia)); + + uint256 proposalId = proposalCount++; + + proposals[proposalId] = DataStructures.Proposal({ + config: configuration, + state: DataStructures.ProposalState.Pending, + payload: _proposal, + creator: msg.sender, + creation: Timestamp.wrap(block.timestamp), + summedBallot: DataStructures.Ballot({yea: 0, nea: 0}) + }); + + emit Proposed(proposalId, address(_proposal)); + + return true; + } + + function vote(uint256 _proposalId, uint256 _amount, bool _support) + external + override(IApella) + returns (bool) + { + DataStructures.ProposalState state = getProposalState(_proposalId); + require(state == DataStructures.ProposalState.Active, Errors.Apella__ProposalNotActive()); + + // Compute the power at the time where we became active + uint256 userPower = users[msg.sender].powerAt(proposals[_proposalId].pendingThrough()); + + DataStructures.Ballot storage userBallot = ballots[_proposalId][msg.sender]; + + uint256 availablePower = userPower - (userBallot.nea + userBallot.yea); + require( + _amount <= availablePower, + Errors.Apella__InsufficientPower(msg.sender, availablePower, _amount) + ); + + DataStructures.Ballot storage summedBallot = proposals[_proposalId].summedBallot; + if (_support) { + userBallot.yea += _amount; + summedBallot.yea += _amount; + } else { + userBallot.nea += _amount; + summedBallot.nea += _amount; + } + + emit VoteCast(_proposalId, msg.sender, _support, _amount); + + return true; + } + + function execute(uint256 _proposalId) external override(IApella) returns (bool) { + DataStructures.ProposalState state = getProposalState(_proposalId); + require( + state == DataStructures.ProposalState.Executable, Errors.Apella__ProposalNotExecutable() + ); + + DataStructures.Proposal storage proposal = proposals[_proposalId]; + proposal.state = DataStructures.ProposalState.Executed; + + IPayload.Action[] memory actions = proposal.payload.getActions(); + + for (uint256 i = 0; i < actions.length; i++) { + require(actions[i].target != address(ASSET), Errors.Apella__CannotCallAsset()); + // We allow calls to EOAs. If you really want be my guest. + (bool success,) = actions[i].target.call(actions[i].data); + require(success, Errors.Apella__CallFailed(actions[i].target)); + } + + emit ProposalExecuted(_proposalId); + + return true; + } + + function powerAt(address _owner, Timestamp _ts) external view override(IApella) returns (uint256) { + if (_ts == Timestamp.wrap(block.timestamp)) { + return users[_owner].powerNow(); + } + return users[_owner].powerAt(_ts); + } + + function totalPowerAt(Timestamp _ts) external view override(IApella) returns (uint256) { + if (_ts == Timestamp.wrap(block.timestamp)) { + return total.powerNow(); + } + return total.powerAt(_ts); + } + + function getConfiguration() external view returns (DataStructures.Configuration memory) { + return configuration; + } + + function getProposal(uint256 _proposalId) external view returns (DataStructures.Proposal memory) { + return proposals[_proposalId]; + } + + function getWithdrawal(uint256 _withdrawalId) + external + view + returns (DataStructures.Withdrawal memory) + { + return withdrawals[_withdrawalId]; + } + + function dropProposal(uint256 _proposalId) external returns (bool) { + DataStructures.Proposal storage self = proposals[_proposalId]; + require( + self.state != DataStructures.ProposalState.Dropped, Errors.Apella__ProposalAlreadyDropped() + ); + require( + getProposalState(_proposalId) == DataStructures.ProposalState.Dropped, + Errors.Apella__ProposalCannotBeDropped() + ); + + self.state = DataStructures.ProposalState.Dropped; + return true; + } + + /** + * @notice Get the state of the proposal + * + * @dev Currently optimised for readability NOT gas. + * + */ + function getProposalState(uint256 _proposalId) + public + view + override(IApella) + returns (DataStructures.ProposalState) + { + require(_proposalId < proposalCount, Errors.Apella__ProposalDoesNotExists(_proposalId)); + + DataStructures.Proposal storage self = proposals[_proposalId]; + + if (self.isStable()) { + return self.state; + } + + // If the gerousia have changed we mark is as dropped + if (gerousia != self.creator) { + return DataStructures.ProposalState.Dropped; + } + + Timestamp currentTime = Timestamp.wrap(block.timestamp); + + if (currentTime <= self.pendingThrough()) { + return DataStructures.ProposalState.Pending; + } + + if (currentTime <= self.activeThrough()) { + return DataStructures.ProposalState.Active; + } + + uint256 totalPower = total.powerAt(self.pendingThrough()); + (VoteTabulationReturn vtr,) = self.voteTabulation(totalPower); + if (vtr != VoteTabulationReturn.Accepted) { + return DataStructures.ProposalState.Rejected; + } + + if (currentTime <= self.queuedThrough()) { + return DataStructures.ProposalState.Queued; + } + + if (currentTime <= self.executableThrough()) { + return DataStructures.ProposalState.Executable; + } + + return DataStructures.ProposalState.Expired; + } +} diff --git a/l1-contracts/src/governance/Gerousia.sol b/l1-contracts/src/governance/Gerousia.sol index 1307be7929e..58bfe80ab6d 100644 --- a/l1-contracts/src/governance/Gerousia.sol +++ b/l1-contracts/src/governance/Gerousia.sol @@ -4,6 +4,7 @@ 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 {IPayload} from "@aztec/governance/interfaces/IPayload.sol"; import {Errors} from "@aztec/governance/libraries/Errors.sol"; import {Slot, SlotLib} from "@aztec/core/libraries/TimeMath.sol"; @@ -21,22 +22,20 @@ contract Gerousia is IGerousia { struct RoundAccounting { Slot lastVote; - address leader; + IPayload leader; bool executed; - mapping(address proposal => uint256 count) yeaCount; + mapping(IPayload 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; + constructor(IRegistry _registry, uint256 _n, uint256 _m) { REGISTRY = _registry; N = _n; M = _m; @@ -57,8 +56,8 @@ contract Gerousia is IGerousia { * * @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)); + function vote(IPayload _proposal) external override(IGerousia) returns (bool) { + require(address(_proposal).code.length > 0, Errors.Gerousia__ProposalHaveNoCode(_proposal)); address instance = REGISTRY.getRollup(); require(instance.code.length > 0, Errors.Gerousia__InstanceHaveNoCode(instance)); @@ -107,19 +106,19 @@ contract Gerousia is IGerousia { require(_roundNumber < currentRound, Errors.Gerousia__CanOnlyPushProposalInPast()); require( _roundNumber + LIFETIME_IN_ROUNDS >= currentRound, - Errors.Gerousia__ProposalTooOld(_roundNumber) + Errors.Gerousia__ProposalTooOld(_roundNumber, currentRound) ); RoundAccounting storage round = rounds[instance][_roundNumber]; require(!round.executed, Errors.Gerousia__ProposalAlreadyExecuted(_roundNumber)); - require(round.leader != address(0), Errors.Gerousia__ProposalCannotBeAddressZero()); + require(round.leader != IPayload(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)); + require(getApella().propose(round.leader), Errors.Gerousia__FailedToPropose(round.leader)); return true; } @@ -128,11 +127,11 @@ contract Gerousia is IGerousia { * * @param _instance - The address of the instance * @param _round - The round to lookup - * @param _proposal - The address of the proposal + * @param _proposal - The proposal * * @return The number of yea votes */ - function yeaCount(address _instance, uint256 _round, address _proposal) + function yeaCount(address _instance, uint256 _round, IPayload _proposal) external view override(IGerousia) @@ -151,4 +150,8 @@ contract Gerousia is IGerousia { function computeRound(Slot _slot) public view override(IGerousia) returns (uint256) { return _slot.unwrap() / M; } + + function getApella() public view override(IGerousia) returns (IApella) { + return IApella(REGISTRY.getApella()); + } } diff --git a/l1-contracts/src/governance/Registry.sol b/l1-contracts/src/governance/Registry.sol index bab4b52ee80..8595e664f9c 100644 --- a/l1-contracts/src/governance/Registry.sol +++ b/l1-contracts/src/governance/Registry.sol @@ -84,6 +84,14 @@ contract Registry is IRegistry, Ownable { return currentSnapshot; } + /** + * @notice Returns the address of the apella + * @return The apella address + */ + function getApella() external view override(IRegistry) returns (address) { + return owner(); + } + /** * @notice Creates a new snapshot of the registry * diff --git a/l1-contracts/src/governance/interfaces/IApella.sol b/l1-contracts/src/governance/interfaces/IApella.sol index 15728b7143f..52a84c2e1b0 100644 --- a/l1-contracts/src/governance/interfaces/IApella.sol +++ b/l1-contracts/src/governance/interfaces/IApella.sol @@ -1,6 +1,33 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.27; +import {IPayload} from "@aztec/governance/interfaces/IPayload.sol"; +import {DataStructures} from "@aztec/governance/libraries/DataStructures.sol"; +import {Timestamp} from "@aztec/core/libraries/TimeMath.sol"; + interface IApella { - function propose(address _proposal) external returns (bool); + event Proposed(uint256 indexed proposalId, address indexed proposal); + event VoteCast(uint256 indexed proposalId, address indexed voter, bool support, uint256 amount); + event ProposalExecuted(uint256 indexed proposalId); + event GerousiaUpdated(address indexed gerousia); + event ConfigurationUpdated(Timestamp indexed time); + + event Deposit(address indexed depositor, address indexed onBehalfOf, uint256 amount); + event WithdrawInitiated(uint256 indexed withdrawalId, address indexed recipient, uint256 amount); + event WithdrawFinalised(uint256 indexed withdrawalId); + + function updateGerousia(address _gerousia) external; + function updateConfiguration(DataStructures.Configuration memory _configuration) external; + function deposit(address _onBehalfOf, uint256 _amount) external; + function initiateWithdraw(address _to, uint256 _amount) external returns (uint256); + function finaliseWithdraw(uint256 _withdrawalId) external; + function propose(IPayload _proposal) external returns (bool); + function vote(uint256 _proposalId, uint256 _amount, bool _support) external returns (bool); + function execute(uint256 _proposalId) external returns (bool); + function powerAt(address _owner, Timestamp _ts) external view returns (uint256); + function totalPowerAt(Timestamp _ts) external view returns (uint256); + function getProposalState(uint256 _proposalId) + external + view + returns (DataStructures.ProposalState); } diff --git a/l1-contracts/src/governance/interfaces/IGerousia.sol b/l1-contracts/src/governance/interfaces/IGerousia.sol index bbd8a6c2066..12a5afd600f 100644 --- a/l1-contracts/src/governance/interfaces/IGerousia.sol +++ b/l1-contracts/src/governance/interfaces/IGerousia.sol @@ -2,16 +2,19 @@ pragma solidity >=0.8.27; import {Slot} from "@aztec/core/libraries/TimeMath.sol"; +import {IApella} from "@aztec/governance/interfaces/IApella.sol"; +import {IPayload} from "@aztec/governance/interfaces/IPayload.sol"; interface IGerousia { - event VoteCast(address indexed proposal, uint256 indexed round, address indexed voter); - event ProposalPushed(address indexed proposal, uint256 indexed round); + event VoteCast(IPayload indexed proposal, uint256 indexed round, address indexed voter); + event ProposalPushed(IPayload indexed proposal, uint256 indexed round); - function vote(address _proposa) external returns (bool); + function vote(IPayload _proposa) external returns (bool); function pushProposal(uint256 _roundNumber) external returns (bool); - function yeaCount(address _instance, uint256 _round, address _proposal) + function yeaCount(address _instance, uint256 _round, IPayload _proposal) external view returns (uint256); function computeRound(Slot _slot) external view returns (uint256); + function getApella() external view returns (IApella); } diff --git a/l1-contracts/src/governance/interfaces/IPayload.sol b/l1-contracts/src/governance/interfaces/IPayload.sol new file mode 100644 index 00000000000..e8f33910ec5 --- /dev/null +++ b/l1-contracts/src/governance/interfaces/IPayload.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +interface IPayload { + struct Action { + address target; + bytes data; + } + + function getActions() external view returns (Action[] memory); +} diff --git a/l1-contracts/src/governance/interfaces/IRegistry.sol b/l1-contracts/src/governance/interfaces/IRegistry.sol index 6c9fd5d1a45..844c45bbddc 100644 --- a/l1-contracts/src/governance/interfaces/IRegistry.sol +++ b/l1-contracts/src/governance/interfaces/IRegistry.sol @@ -34,4 +34,6 @@ interface IRegistry { // docs:end:registry_number_of_versions function isRollupRegistered(address _rollup) external view returns (bool); + + function getApella() external view returns (address); } diff --git a/l1-contracts/src/governance/libraries/ConfigurationLib.sol b/l1-contracts/src/governance/libraries/ConfigurationLib.sol new file mode 100644 index 00000000000..266e9c83ac7 --- /dev/null +++ b/l1-contracts/src/governance/libraries/ConfigurationLib.sol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {DataStructures} from "@aztec/governance/libraries/DataStructures.sol"; +import {Timestamp} from "@aztec/core/libraries/TimeMath.sol"; +import {Errors} from "@aztec/governance/libraries/Errors.sol"; + +library ConfigurationLib { + uint256 internal constant YEAR_2100 = 4102444800; + + uint256 internal constant QUORUM_LOWER = 1; + uint256 internal constant QUORUM_UPPER = 1e18; + + uint256 internal constant DIFFERENTIAL_UPPER = 1e18; + + uint256 internal constant VOTES_LOWER = 1; + + Timestamp internal constant TIME_LOWER = Timestamp.wrap(3600); + Timestamp internal constant TIME_UPPER = Timestamp.wrap(30 * 24 * 3600); + + function lockDelay(DataStructures.Configuration storage self) internal view returns (Timestamp) { + return Timestamp.wrap(Timestamp.unwrap(self.votingDelay) / 5) + self.votingDuration + + self.executionDelay; + } + + /** + * @notice + * @dev We specify `memory` here since it is called on outside import for validation + * before writing it to state. + */ + function assertValid(DataStructures.Configuration memory self) internal pure returns (bool) { + require(self.quorum >= QUORUM_LOWER, Errors.Apella__ConfigurationLib__QuorumTooSmall()); + require(self.quorum <= QUORUM_UPPER, Errors.Apella__ConfigurationLib__QuorumTooBig()); + + require( + self.voteDifferential <= DIFFERENTIAL_UPPER, + Errors.Apella__ConfigurationLib__DifferentialTooBig() + ); + + require( + self.minimumVotes >= VOTES_LOWER, Errors.Apella__ConfigurationLib__InvalidMinimumVotes() + ); + + require( + self.votingDelay >= TIME_LOWER, Errors.Apella__ConfigurationLib__TimeTooSmall("VotingDelay") + ); + require( + self.votingDelay <= TIME_UPPER, Errors.Apella__ConfigurationLib__TimeTooBig("VotingDelay") + ); + + require( + self.votingDuration >= TIME_LOWER, + Errors.Apella__ConfigurationLib__TimeTooSmall("VotingDuration") + ); + require( + self.votingDuration <= TIME_UPPER, + Errors.Apella__ConfigurationLib__TimeTooBig("VotingDuration") + ); + + require( + self.executionDelay >= TIME_LOWER, + Errors.Apella__ConfigurationLib__TimeTooSmall("ExecutionDelay") + ); + require( + self.executionDelay <= TIME_UPPER, + Errors.Apella__ConfigurationLib__TimeTooBig("ExecutionDelay") + ); + + require( + self.gracePeriod >= TIME_LOWER, Errors.Apella__ConfigurationLib__TimeTooSmall("GracePeriod") + ); + require( + self.gracePeriod <= TIME_UPPER, Errors.Apella__ConfigurationLib__TimeTooBig("GracePeriod") + ); + + return true; + } +} diff --git a/l1-contracts/src/governance/libraries/DataStructures.sol b/l1-contracts/src/governance/libraries/DataStructures.sol index 6f0ebd13d48..821fadc4e52 100644 --- a/l1-contracts/src/governance/libraries/DataStructures.sol +++ b/l1-contracts/src/governance/libraries/DataStructures.sol @@ -2,6 +2,9 @@ // Copyright 2023 Aztec Labs. pragma solidity >=0.8.27; +import {Timestamp} from "@aztec/core/libraries/TimeMath.sol"; +import {IPayload} from "@aztec/governance/interfaces/IPayload.sol"; + /** * @title Data Structures Library * @author Aztec Labs @@ -19,4 +22,56 @@ library DataStructures { uint256 blockNumber; } // docs:end:registry_snapshot + + struct Configuration { + Timestamp votingDelay; + Timestamp votingDuration; + Timestamp executionDelay; + Timestamp gracePeriod; + uint256 quorum; + uint256 voteDifferential; + uint256 minimumVotes; + } + + struct Ballot { + uint256 yea; + uint256 nea; + } + + enum ProposalState { + Pending, + Active, + Queued, + Executable, + Rejected, + Executed, + Dropped, + Expired + } + + struct Proposal { + Configuration config; + ProposalState state; + IPayload payload; + address creator; + Timestamp creation; + Ballot summedBallot; + } + + struct CheckPoint { + Timestamp time; + uint256 power; + } + + struct User { + uint256 numCheckPoints; + mapping(uint256 checkpointIndex => CheckPoint) checkpoints; + } + + struct Withdrawal { + uint256 amount; + Timestamp unlocksAt; + address recipient; + bool claimed; + } } diff --git a/l1-contracts/src/governance/libraries/Errors.sol b/l1-contracts/src/governance/libraries/Errors.sol index 41611a86eec..df3ebc1cb55 100644 --- a/l1-contracts/src/governance/libraries/Errors.sol +++ b/l1-contracts/src/governance/libraries/Errors.sol @@ -2,7 +2,8 @@ // Copyright 2023 Aztec Labs. pragma solidity >=0.8.27; -import {Slot} from "@aztec/core/libraries/TimeMath.sol"; +import {IPayload} from "@aztec/governance/interfaces/IPayload.sol"; +import {Slot, Timestamp} from "@aztec/core/libraries/TimeMath.sol"; /** * @title Errors Library @@ -12,8 +13,39 @@ import {Slot} from "@aztec/core/libraries/TimeMath.sol"; * when there are multiple contracts that could have thrown the error. */ library Errors { + error Apella__CallerNotGerousia(address caller, address gerousia); + error Apella__CallerNotSelf(address caller, address self); + error Apella__NoCheckpointsFound(); + error Apella__InsufficientPower(address voter, uint256 have, uint256 required); + error Apella__InvalidConfiguration(); + error Apella__WithdrawalAlreadyclaimed(); + error Apella__WithdrawalNotUnlockedYet(Timestamp currentTime, Timestamp unlocksAt); + error Apella__ProposalNotActive(); + error Apella__ProposalNotExecutable(); + error Apella__CannotCallAsset(); + error Apella__CallFailed(address target); + error Apella__ProposalDoesNotExists(uint256 proposalId); + error Apella__ProposalAlreadyDropped(); + error Apella__ProposalCannotBeDropped(); + + error Apella__UserLib__NotInPast(); + + error Apella__ConfigurationLib__InvalidMinimumVotes(); + error Apella__ConfigurationLib__QuorumTooSmall(); + error Apella__ConfigurationLib__QuorumTooBig(); + error Apella__ConfigurationLib__DifferentialTooSmall(); + error Apella__ConfigurationLib__DifferentialTooBig(); + error Apella__ConfigurationLib__TimeTooSmall(string name); + error Apella__ConfigurationLib__TimeTooBig(string name); + + error Apella__ProposalLib__ZeroMinimum(); + error Apella__ProposalLib__ZeroVotesNeeded(); + error Apella__ProposalLib__MoreVoteThanExistNeeded(); + error Apella__ProposalLib__ZeroYeaVotesNeeded(); + error Apella__ProposalLib__MoreYeaVoteThanExistNeeded(); + error Gerousia__CanOnlyPushProposalInPast(); // 0x49fdf611" - error Gerousia__FailedToPropose(address proposal); // 0x6ca2a2ed + error Gerousia__FailedToPropose(IPayload proposal); // 0x6ca2a2ed error Gerousia__InstanceHaveNoCode(address instance); // 0x20a3b441 error Gerousia__InsufficientVotes(); // 0xba1e05ef error Gerousia__InvalidNAndMValues(uint256 N, uint256 M); // 0x520d9704 @@ -21,8 +53,8 @@ library Errors { 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__ProposalHaveNoCode(IPayload proposal); // 0xdce0615b + error Gerousia__ProposalTooOld(uint256 roundNumber, uint256 currentRoundNumber); //0x02283b1a error Gerousia__VoteAlreadyCastForSlot(Slot slot); //0xc2201452 error Nomismatokopio__InssuficientMintAvailable(uint256 available, uint256 needed); // 0xf268b931 diff --git a/l1-contracts/src/governance/libraries/ProposalLib.sol b/l1-contracts/src/governance/libraries/ProposalLib.sol new file mode 100644 index 00000000000..4a72201a887 --- /dev/null +++ b/l1-contracts/src/governance/libraries/ProposalLib.sol @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {DataStructures} from "@aztec/governance/libraries/DataStructures.sol"; +import {Timestamp} from "@aztec/core/libraries/TimeMath.sol"; +import {Math} from "@oz/utils/math/Math.sol"; + +enum VoteTabulationReturn { + Accepted, + Rejected, + Invalid +} + +enum VoteTabulationInfo { + MinimumEqZero, + TotalPowerLtMinimum, + VotesNeededEqZero, + VotesNeededGtTotalPower, + VotesCastLtVotesNeeded, + YeaLimitEqZero, + YeaLimitGtVotesCast, + YeaLimitEqVotesCast, + YeaVotesEqVotesCast, + YeaVotesLeYeaLimit, + YeaVotesGtYeaLimit +} + +/** + * @notice Library for dealing with proposal math + * + * In particular, dealing with the computation to evaluate + * whether a proposal should be rejected or not. + * + * We will be using `Ceil` as the rounding. + * The intuition is fairly straight forward, see the voting + * as repaying a debt. We want to ensure that the protocol + * is never "underpaid" when it is to perform an election. + * So when it computes the total debt repayment needed to + * execute the proposal, it will be rounding up. + * + * If we do not round up, a mulDiv with small values could + * for example ending at 0, having a case where no votes are needed + */ +library ProposalLib { + function voteTabulation(DataStructures.Proposal storage self, uint256 _totalPower) + internal + view + returns (VoteTabulationReturn, VoteTabulationInfo) + { + if (self.config.minimumVotes == 0) { + return (VoteTabulationReturn.Invalid, VoteTabulationInfo.MinimumEqZero); + } + if (_totalPower < self.config.minimumVotes) { + return (VoteTabulationReturn.Rejected, VoteTabulationInfo.TotalPowerLtMinimum); + } + + uint256 votesNeeded = Math.mulDiv(_totalPower, self.config.quorum, 1e18, Math.Rounding.Ceil); + if (votesNeeded == 0) { + return (VoteTabulationReturn.Invalid, VoteTabulationInfo.VotesNeededEqZero); + } + if (votesNeeded > _totalPower) { + return (VoteTabulationReturn.Invalid, VoteTabulationInfo.VotesNeededGtTotalPower); + } + + uint256 votesCast = self.summedBallot.nea + self.summedBallot.yea; + if (votesCast < votesNeeded) { + return (VoteTabulationReturn.Rejected, VoteTabulationInfo.VotesCastLtVotesNeeded); + } + + // Edge case where all the votes are yea, no need to compute differential + // Assumes a "sane" value for differential, e.g., you cannot require more votes + // to be yes than total votes. + if (self.summedBallot.yea == votesCast) { + return (VoteTabulationReturn.Accepted, VoteTabulationInfo.YeaVotesEqVotesCast); + } + + uint256 yeaLimitFraction = Math.ceilDiv(1e18 + self.config.voteDifferential, 2); + uint256 yeaLimit = Math.mulDiv(votesCast, yeaLimitFraction, 1e18, Math.Rounding.Ceil); + + /*if (yeaLimit == 0) { + // It should be impossible to hit this case as `yeaLimitFraction` cannot be 0, + // and due to rounding, only way to hit this would be if `votesCast = 0`, + // which is already handled as `votesCast >= votesNeeded` and `votesNeeded > 0`. + return (VoteTabulationReturn.Invalid, VoteTabulationInfo.YeaLimitEqZero); + }*/ + if (yeaLimit > votesCast) { + return (VoteTabulationReturn.Invalid, VoteTabulationInfo.YeaLimitGtVotesCast); + } + + // We want to see that there are MORE votes on yea than needed + // We explictly need MORE to ensure we don't "tie". + // If we need as many yea as there are votes, we know it is impossible already. + // due to the check earlier, that summedBallot.yea == votesCast. + if (self.summedBallot.yea <= yeaLimit) { + return (VoteTabulationReturn.Rejected, VoteTabulationInfo.YeaVotesLeYeaLimit); + } + + return (VoteTabulationReturn.Accepted, VoteTabulationInfo.YeaVotesGtYeaLimit); + } + + /** + * @notice A stable state is one which cannoted be moved away from + */ + function isStable(DataStructures.Proposal storage self) internal view returns (bool) { + DataStructures.ProposalState s = self.state; // cache + return s == DataStructures.ProposalState.Executed || s == DataStructures.ProposalState.Dropped; + } + + function pendingThrough(DataStructures.Proposal storage self) internal view returns (Timestamp) { + return self.creation + self.config.votingDelay; + } + + function activeThrough(DataStructures.Proposal storage self) internal view returns (Timestamp) { + return ProposalLib.pendingThrough(self) + self.config.votingDuration; + } + + function queuedThrough(DataStructures.Proposal storage self) internal view returns (Timestamp) { + return ProposalLib.activeThrough(self) + self.config.executionDelay; + } + + function executableThrough(DataStructures.Proposal storage self) + internal + view + returns (Timestamp) + { + return ProposalLib.queuedThrough(self) + self.config.gracePeriod; + } +} diff --git a/l1-contracts/src/governance/libraries/UserLib.sol b/l1-contracts/src/governance/libraries/UserLib.sol new file mode 100644 index 00000000000..bc3ca1f7d34 --- /dev/null +++ b/l1-contracts/src/governance/libraries/UserLib.sol @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {DataStructures} from "@aztec/governance/libraries/DataStructures.sol"; +import {Timestamp} from "@aztec/core/libraries/TimeMath.sol"; +import {Errors} from "@aztec/governance/libraries/Errors.sol"; + +library UserLib { + function add(DataStructures.User storage self, uint256 _amount) internal { + if (_amount == 0) { + return; + } + if (self.numCheckPoints == 0) { + self.checkpoints[0] = + DataStructures.CheckPoint({time: Timestamp.wrap(block.timestamp), power: _amount}); + self.numCheckPoints += 1; + } else { + DataStructures.CheckPoint storage last = self.checkpoints[self.numCheckPoints - 1]; + if (last.time == Timestamp.wrap(block.timestamp)) { + last.power += _amount; + } else { + self.checkpoints[self.numCheckPoints] = DataStructures.CheckPoint({ + time: Timestamp.wrap(block.timestamp), + power: last.power + _amount + }); + self.numCheckPoints += 1; + } + } + } + + function sub(DataStructures.User storage self, uint256 _amount) internal { + if (_amount == 0) { + return; + } + require(self.numCheckPoints > 0, Errors.Apella__NoCheckpointsFound()); + DataStructures.CheckPoint storage last = self.checkpoints[self.numCheckPoints - 1]; + require( + last.power >= _amount, Errors.Apella__InsufficientPower(msg.sender, last.power, _amount) + ); + if (last.time == Timestamp.wrap(block.timestamp)) { + last.power -= _amount; + } else { + self.checkpoints[self.numCheckPoints] = DataStructures.CheckPoint({ + time: Timestamp.wrap(block.timestamp), + power: last.power - _amount + }); + self.numCheckPoints += 1; + } + } + + function powerNow(DataStructures.User storage self) internal view returns (uint256) { + uint256 numCheckPoints = self.numCheckPoints; + if (numCheckPoints == 0) { + return 0; + } + return self.checkpoints[numCheckPoints - 1].power; + } + + function powerAt(DataStructures.User storage self, Timestamp _time) + internal + view + returns (uint256) + { + // If not in the past, the values are not stable. + // We disallow using it to avoid potential misuse. + require(_time < Timestamp.wrap(block.timestamp), Errors.Apella__UserLib__NotInPast()); + + uint256 numCheckPoints = self.numCheckPoints; + if (numCheckPoints == 0) { + return 0; + } + + if (self.checkpoints[numCheckPoints - 1].time <= _time) { + return self.checkpoints[numCheckPoints - 1].power; + } + + if (self.checkpoints[0].time > _time) { + return 0; + } + + uint256 lower = 0; + uint256 upper = numCheckPoints - 1; + while (upper > lower) { + uint256 center = upper - (upper - lower) / 2; // ceil, avoiding overflow + DataStructures.CheckPoint memory cp = self.checkpoints[center]; + if (cp.time == _time) { + return cp.power; + } else if (cp.time < _time) { + lower = center; + } else { + upper = center - 1; + } + } + return self.checkpoints[lower].power; + } +} diff --git a/l1-contracts/test/base/Base.sol b/l1-contracts/test/base/Base.sol new file mode 100644 index 00000000000..f05cef62d5e --- /dev/null +++ b/l1-contracts/test/base/Base.sol @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {Timestamp, Slot, Epoch, SlotLib, EpochLib} from "@aztec/core/libraries/TimeMath.sol"; +import {Test} from "forge-std/Test.sol"; + +contract TestBase is Test { + using SlotLib for Slot; + using EpochLib for Epoch; + + function assertGt(Timestamp a, Timestamp b) internal { + if (a <= b) { + emit log("Error: a > b not satisfied [Timestamp]"); + emit log_named_uint(" Left", Timestamp.unwrap(a)); + emit log_named_uint(" Right", Timestamp.unwrap(b)); + fail(); + } + } + + function assertGt(Timestamp a, uint256 b) internal { + if (a <= Timestamp.wrap(b)) { + emit log("Error: a > b not satisfied [Timestamp]"); + emit log_named_uint(" Left", Timestamp.unwrap(a)); + emit log_named_uint(" Right", b); + fail(); + } + } + + function assertGt(Timestamp a, Timestamp b, string memory err) internal { + if (a <= b) { + emit log_named_string("Error", err); + assertGt(a, b); + } + } + + function assertGt(Timestamp a, uint256 b, string memory err) internal { + if (a <= Timestamp.wrap(b)) { + emit log_named_string("Error", err); + assertGt(a, b); + } + } + + function assertLe(Timestamp a, Timestamp b) internal { + if (a > b) { + emit log("Error: a <= b not satisfied [Timestamp]"); + emit log_named_uint(" Left", Timestamp.unwrap(a)); + emit log_named_uint(" Right", Timestamp.unwrap(b)); + fail(); + } + } + + function assertLe(Timestamp a, uint256 b) internal { + if (a > Timestamp.wrap(b)) { + emit log("Error: a <= b not satisfied [Timestamp]"); + emit log_named_uint(" Left", Timestamp.unwrap(a)); + emit log_named_uint(" Right", b); + fail(); + } + } + + function assertLe(Timestamp a, Timestamp b, string memory err) internal { + if (a > b) { + emit log_named_string("Error", err); + assertLe(a, b); + } + } + + function assertLe(Timestamp a, uint256 b, string memory err) internal { + if (a > Timestamp.wrap(b)) { + emit log_named_string("Error", err); + assertLe(a, b); + } + } + + function assertLt(Timestamp a, Timestamp b) internal { + if (a >= b) { + emit log("Error: a < b not satisfied [Timestamp]"); + emit log_named_uint(" Left", Timestamp.unwrap(a)); + emit log_named_uint(" Right", Timestamp.unwrap(b)); + fail(); + } + } + + function assertLt(Timestamp a, uint256 b) internal { + if (a >= Timestamp.wrap(b)) { + emit log("Error: a < b not satisfied [Timestamp]"); + emit log_named_uint(" Left", Timestamp.unwrap(a)); + emit log_named_uint(" Right", b); + fail(); + } + } + + function assertLt(Timestamp a, Timestamp b, string memory err) internal { + if (a >= b) { + emit log_named_string("Error", err); + assertLt(a, b); + } + } + + function assertLt(Timestamp a, uint256 b, string memory err) internal { + if (a >= Timestamp.wrap(b)) { + emit log_named_string("Error", err); + assertLt(a, b); + } + } + + function assertEq(Timestamp a, Timestamp b) internal { + if (a != b) { + emit log("Error: a == b not satisfied [Timestamp]"); + emit log_named_uint(" Left", Timestamp.unwrap(a)); + emit log_named_uint(" Right", Timestamp.unwrap(b)); + fail(); + } + } + + function assertEq(Timestamp a, uint256 b) internal { + if (a != Timestamp.wrap(b)) { + emit log("Error: a == b not satisfied [Timestamp]"); + emit log_named_uint(" Left", Timestamp.unwrap(a)); + emit log_named_uint(" Right", b); + fail(); + } + } + + function assertEq(Timestamp a, Timestamp b, string memory err) internal { + if (a != b) { + emit log_named_string("Error", err); + assertEq(a, b); + } + } + + function assertEq(Timestamp a, uint256 b, string memory err) internal { + if (a != Timestamp.wrap(b)) { + emit log_named_string("Error", err); + assertEq(a, b); + } + } + + // Slots + + function assertEq(Slot a, Slot b) internal { + if (a != b) { + emit log("Error: a == b not satisfied [Slot]"); + emit log_named_uint(" Left", a.unwrap()); + emit log_named_uint(" Right", b.unwrap()); + fail(); + } + } + + function assertEq(Slot a, uint256 b) internal { + if (a != Slot.wrap(b)) { + emit log("Error: a == b not satisfied [Slot]"); + emit log_named_uint(" Left", a.unwrap()); + emit log_named_uint(" Right", b); + fail(); + } + } + + function assertEq(Slot a, Slot b, string memory err) internal { + if (a != b) { + emit log_named_string("Error", err); + assertEq(a, b); + } + } + + function assertEq(Slot a, uint256 b, string memory err) internal { + if (a != Slot.wrap(b)) { + emit log_named_string("Error", err); + assertEq(a, b); + } + } + + // Epochs + + function assertEq(Epoch a, Epoch b) internal { + if (a != b) { + emit log("Error: a == b not satisfied [Epoch]"); + emit log_named_uint(" Left", a.unwrap()); + emit log_named_uint(" Right", b.unwrap()); + fail(); + } + } + + function assertEq(Epoch a, uint256 b) internal { + if (a != Epoch.wrap(b)) { + emit log("Error: a == b not satisfied [Epoch]"); + emit log_named_uint(" Left", a.unwrap()); + emit log_named_uint(" Right", b); + fail(); + } + } + + function assertEq(Epoch a, Epoch b, string memory err) internal { + if (a != b) { + emit log_named_string("Error", err); + assertEq(a, b); + } + } + + function assertEq(Epoch a, uint256 b, string memory err) internal { + if (a != Epoch.wrap(b)) { + emit log_named_string("Error", err); + assertEq(a, b); + } + } +} diff --git a/l1-contracts/test/decoders/Base.sol b/l1-contracts/test/decoders/Base.sol index 1f5eb075f0a..106584015a0 100644 --- a/l1-contracts/test/decoders/Base.sol +++ b/l1-contracts/test/decoders/Base.sol @@ -3,17 +3,13 @@ pragma solidity >=0.8.27; import {Test} from "forge-std/Test.sol"; - -import {Timestamp, Slot, Epoch, SlotLib, EpochLib} from "@aztec/core/libraries/TimeMath.sol"; +import {TestBase} from "../base/Base.sol"; // Many of the structs in here match what you see in `header` but with very important exceptions! // The order of variables is sorted alphabetically in the structs in here to work with the // JSON cheatcodes. -contract DecoderBase is Test { - using SlotLib for Slot; - using EpochLib for Epoch; - +contract DecoderBase is TestBase { struct AppendOnlyTreeSnapshot { uint32 nextAvailableLeafIndex; bytes32 root; @@ -103,138 +99,4 @@ contract DecoderBase is Test { function max(uint256 a, uint256 b) internal pure returns (uint256) { return a > b ? a : b; } - - // timestamps - - function assertLe(Timestamp a, Timestamp b) internal { - if (a > b) { - emit log("Error: a <= b not satisfied [Timestamp]"); - emit log_named_uint(" Left", Timestamp.unwrap(a)); - emit log_named_uint(" Right", Timestamp.unwrap(b)); - fail(); - } - } - - function assertLe(Timestamp a, uint256 b) internal { - if (a > Timestamp.wrap(b)) { - emit log("Error: a <= b not satisfied [Timestamp]"); - emit log_named_uint(" Left", Timestamp.unwrap(a)); - emit log_named_uint(" Right", b); - fail(); - } - } - - function assertLe(Timestamp a, Timestamp b, string memory err) internal { - if (a > b) { - emit log_named_string("Error", err); - assertEq(a, b); - } - } - - function assertLe(Timestamp a, uint256 b, string memory err) internal { - if (a > Timestamp.wrap(b)) { - emit log_named_string("Error", err); - assertEq(a, b); - } - } - - function assertEq(Timestamp a, Timestamp b) internal { - if (a != b) { - emit log("Error: a == b not satisfied [Timestamp]"); - emit log_named_uint(" Left", Timestamp.unwrap(a)); - emit log_named_uint(" Right", Timestamp.unwrap(b)); - fail(); - } - } - - function assertEq(Timestamp a, uint256 b) internal { - if (a != Timestamp.wrap(b)) { - emit log("Error: a == b not satisfied [Timestamp]"); - emit log_named_uint(" Left", Timestamp.unwrap(a)); - emit log_named_uint(" Right", b); - fail(); - } - } - - function assertEq(Timestamp a, Timestamp b, string memory err) internal { - if (a != b) { - emit log_named_string("Error", err); - assertEq(a, b); - } - } - - function assertEq(Timestamp a, uint256 b, string memory err) internal { - if (a != Timestamp.wrap(b)) { - emit log_named_string("Error", err); - assertEq(a, b); - } - } - - // Slots - - function assertEq(Slot a, Slot b) internal { - if (a != b) { - emit log("Error: a == b not satisfied [Slot]"); - emit log_named_uint(" Left", a.unwrap()); - emit log_named_uint(" Right", b.unwrap()); - fail(); - } - } - - function assertEq(Slot a, uint256 b) internal { - if (a != Slot.wrap(b)) { - emit log("Error: a == b not satisfied [Slot]"); - emit log_named_uint(" Left", a.unwrap()); - emit log_named_uint(" Right", b); - fail(); - } - } - - function assertEq(Slot a, Slot b, string memory err) internal { - if (a != b) { - emit log_named_string("Error", err); - assertEq(a, b); - } - } - - function assertEq(Slot a, uint256 b, string memory err) internal { - if (a != Slot.wrap(b)) { - emit log_named_string("Error", err); - assertEq(a, b); - } - } - - // Epochs - - function assertEq(Epoch a, Epoch b) internal { - if (a != b) { - emit log("Error: a == b not satisfied [Epoch]"); - emit log_named_uint(" Left", a.unwrap()); - emit log_named_uint(" Right", b.unwrap()); - fail(); - } - } - - function assertEq(Epoch a, uint256 b) internal { - if (a != Epoch.wrap(b)) { - emit log("Error: a == b not satisfied [Epoch]"); - emit log_named_uint(" Left", a.unwrap()); - emit log_named_uint(" Right", b); - fail(); - } - } - - function assertEq(Epoch a, Epoch b, string memory err) internal { - if (a != b) { - emit log_named_string("Error", err); - assertEq(a, b); - } - } - - function assertEq(Epoch a, uint256 b, string memory err) internal { - if (a != Epoch.wrap(b)) { - emit log_named_string("Error", err); - assertEq(a, b); - } - } } diff --git a/l1-contracts/test/governance/apella/TestPayloads.sol b/l1-contracts/test/governance/apella/TestPayloads.sol new file mode 100644 index 00000000000..1d541015ab0 --- /dev/null +++ b/l1-contracts/test/governance/apella/TestPayloads.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {IPayload} from "@aztec/governance/interfaces/IPayload.sol"; + +import {IMintableERC20} from "@aztec/governance/interfaces/IMintableERC20.sol"; +import {IRegistry} from "@aztec/governance/interfaces/IRegistry.sol"; + +contract EmptyPayload is IPayload { + function getActions() external view override(IPayload) returns (IPayload.Action[] memory) {} +} + +contract CallAssetPayload is IPayload { + IMintableERC20 internal immutable ASSET; + address internal immutable OWNER; + address internal immutable APELLA; + + constructor(IMintableERC20 _asset, address _apella) { + ASSET = _asset; + OWNER = msg.sender; + APELLA = _apella; + } + + function getActions() external view override(IPayload) returns (IPayload.Action[] memory) { + IPayload.Action[] memory res = new IPayload.Action[](1); + uint256 balance = ASSET.balanceOf(APELLA); + + res[0] = Action({ + target: address(ASSET), + data: abi.encodeWithSelector(ASSET.transfer.selector, OWNER, balance) + }); + + return res; + } +} + +contract UpgradePayload is IPayload { + IRegistry public immutable REGISTRY; + address public constant NEW_ROLLUP = + address(uint160(uint256(keccak256(bytes("a new fancy rollup built with magic"))))); + + constructor(IRegistry _registry) { + REGISTRY = _registry; + } + + function getActions() external view override(IPayload) returns (IPayload.Action[] memory) { + IPayload.Action[] memory res = new IPayload.Action[](1); + + res[0] = Action({ + target: address(REGISTRY), + data: abi.encodeWithSelector(REGISTRY.upgrade.selector, NEW_ROLLUP) + }); + + return res; + } +} + +contract CallRevertingPayload is IPayload { + RevertingCall public immutable TARGET = new RevertingCall(); + + function getActions() external view override(IPayload) returns (IPayload.Action[] memory) { + IPayload.Action[] memory res = new IPayload.Action[](1); + + res[0] = Action({ + target: address(TARGET), + data: abi.encodeWithSelector(TARGET.skibBobFlipFlop.selector) + }); + + return res; + } +} + +contract RevertingCall { + error TrapCardActivated(); + + function skibBobFlipFlop() external pure { + revert TrapCardActivated(); + } +} diff --git a/l1-contracts/test/governance/apella/base.t.sol b/l1-contracts/test/governance/apella/base.t.sol new file mode 100644 index 00000000000..41f5ed392e5 --- /dev/null +++ b/l1-contracts/test/governance/apella/base.t.sol @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {TestBase} from "@test/base/Base.sol"; +import {Apella} from "@aztec/governance/Apella.sol"; +import {Gerousia} from "@aztec/governance/Gerousia.sol"; +import {Registry} from "@aztec/governance/Registry.sol"; +import {DataStructures} from "@aztec/governance/libraries/DataStructures.sol"; +import {IMintableERC20} from "@aztec/governance/interfaces/IMintableERC20.sol"; +import {TestERC20} from "@aztec/mock/TestERC20.sol"; +import {Timestamp} from "@aztec/core/libraries/TimeMath.sol"; +import {Math} from "@oz/utils/math/Math.sol"; + +import { + ProposalLib, + VoteTabulationReturn, + VoteTabulationInfo +} from "@aztec/governance/libraries/ProposalLib.sol"; + +import { + CallAssetPayload, UpgradePayload, CallRevertingPayload, EmptyPayload +} from "./TestPayloads.sol"; + +contract ApellaBase is TestBase { + using ProposalLib for DataStructures.Proposal; + + IMintableERC20 internal token; + Registry internal registry; + Apella internal apella; + Gerousia internal gerousia; + + mapping(bytes32 => DataStructures.Proposal) internal proposals; + mapping(bytes32 => uint256) internal proposalIds; + DataStructures.Proposal internal proposal; + uint256 proposalId; + + function setUp() public virtual { + token = IMintableERC20(address(new TestERC20())); + + registry = new Registry(address(this)); + gerousia = new Gerousia(registry, 677, 1000); + + apella = new Apella(token, address(gerousia)); + registry.transferOwnership(address(apella)); + + { + CallAssetPayload payload = new CallAssetPayload(token, address(apella)); + vm.prank(address(gerousia)); + assertTrue(apella.propose(payload)); + + proposalIds["call_asset"] = apella.proposalCount() - 1; + proposals["call_asset"] = apella.getProposal(proposalIds["call_asset"]); + } + + { + UpgradePayload payload = new UpgradePayload(registry); + vm.prank(address(gerousia)); + assertTrue(apella.propose(payload)); + + proposalIds["upgrade"] = apella.proposalCount() - 1; + proposals["upgrade"] = apella.getProposal(proposalIds["upgrade"]); + } + + { + CallRevertingPayload payload = new CallRevertingPayload(); + vm.prank(address(gerousia)); + assertTrue(apella.propose(payload)); + + proposalIds["revert"] = apella.proposalCount() - 1; + proposals["revert"] = apella.getProposal(proposalIds["revert"]); + } + + { + EmptyPayload payload = new EmptyPayload(); + vm.prank(address(gerousia)); + assertTrue(apella.propose(payload)); + + proposalIds["empty"] = apella.proposalCount() - 1; + proposals["empty"] = apella.getProposal(proposalIds["empty"]); + } + } + + function _statePending(bytes32 _proposalName) internal { + proposal = proposals[_proposalName]; + proposalId = proposalIds[_proposalName]; + } + + function _stateActive(bytes32 _proposalName) internal { + proposal = proposals[_proposalName]; + proposalId = proposalIds[_proposalName]; + + // @note We jump to the point where it becomes active + vm.warp(Timestamp.unwrap(proposal.pendingThrough()) + 1); + + assertTrue(apella.getProposalState(proposalId) == DataStructures.ProposalState.Active); + } + + function _stateDropped(bytes32 _proposalName, address _gerousia) internal { + proposal = proposals[_proposalName]; + proposalId = proposalIds[_proposalName]; + + vm.assume(_gerousia != proposal.creator); + + vm.prank(address(apella)); + apella.updateGerousia(_gerousia); + } + + function _stateRejected(bytes32 _proposalName) internal { + // We just take a really simple case here. As the cases area covered separately in `voteTabulation.t.sol` + // We simple throw no votes at all. + proposal = proposals[_proposalName]; + proposalId = proposalIds[_proposalName]; + + vm.warp(Timestamp.unwrap(proposal.activeThrough()) + 1); + + assertTrue(apella.getProposalState(proposalId) == DataStructures.ProposalState.Rejected); + } + + function _stateQueued( + bytes32 _proposalName, + address _voter, + uint256 _totalPower, + uint256 _votesCast, + uint256 _yeas + ) internal { + vm.assume(_voter != address(0)); + proposal = proposals[_proposalName]; + proposalId = proposalIds[_proposalName]; + + uint256 totalPower = bound(_totalPower, proposal.config.minimumVotes, type(uint128).max); + uint256 votesNeeded = Math.mulDiv(totalPower, proposal.config.quorum, 1e18, Math.Rounding.Ceil); + uint256 votesCast = bound(_votesCast, votesNeeded, totalPower); + + uint256 yeaLimitFraction = Math.ceilDiv(1e18 + proposal.config.voteDifferential, 2); + uint256 yeaLimit = Math.mulDiv(votesCast, yeaLimitFraction, 1e18, Math.Rounding.Ceil); + + uint256 yeas = yeaLimit == votesCast ? votesCast : bound(_yeas, yeaLimit + 1, votesCast); + + token.mint(_voter, totalPower); + vm.startPrank(_voter); + token.approve(address(apella), totalPower); + apella.deposit(_voter, totalPower); + vm.stopPrank(); + + _stateActive(_proposalName); + + vm.startPrank(_voter); + apella.vote(proposalId, yeas, true); + apella.vote(proposalId, votesCast - yeas, false); + vm.stopPrank(); + + vm.warp(Timestamp.unwrap(proposal.activeThrough()) + 1); + + assertEq( + apella.getProposalState(proposalId), DataStructures.ProposalState.Queued, "invalid state" + ); + } + + function _stateExecutable( + bytes32 _proposalName, + address _voter, + uint256 _totalPower, + uint256 _votesCast, + uint256 _yeas + ) internal { + proposal = proposals[_proposalName]; + proposalId = proposalIds[_proposalName]; + + _stateQueued(_proposalName, _voter, _totalPower, _votesCast, _yeas); + + vm.warp(Timestamp.unwrap(proposal.queuedThrough()) + 1); + + assertEq( + apella.getProposalState(proposalId), DataStructures.ProposalState.Executable, "invalid state" + ); + } + + function _stateExpired( + bytes32 _proposalName, + address _voter, + uint256 _totalPower, + uint256 _votesCast, + uint256 _yeas + ) internal { + proposal = proposals[_proposalName]; + proposalId = proposalIds[_proposalName]; + + _stateExecutable(_proposalName, _voter, _totalPower, _votesCast, _yeas); + + vm.warp(Timestamp.unwrap(proposal.executableThrough()) + 1); + + assertEq( + apella.getProposalState(proposalId), DataStructures.ProposalState.Expired, "invalid state" + ); + } + + function assertEq(VoteTabulationReturn a, VoteTabulationReturn b) internal { + if (a != b) { + emit log("Error: a == b not satisfied [VoteTabulationReturn]"); + emit log_named_uint(" Left", uint256(a)); + emit log_named_uint(" Right", uint256(b)); + fail(); + } + } + + function assertEq(VoteTabulationReturn a, VoteTabulationReturn b, string memory err) internal { + if (a != b) { + emit log_named_string("Error", err); + assertEq(a, b); + } + } + + function assertEq(VoteTabulationInfo a, VoteTabulationInfo b) internal { + if (a != b) { + emit log("Error: a == b not satisfied [VoteTabulationInfo]"); + emit log_named_uint(" Left", uint256(a)); + emit log_named_uint(" Right", uint256(b)); + fail(); + } + } + + function assertEq(VoteTabulationInfo a, VoteTabulationInfo b, string memory err) internal { + if (a != b) { + emit log_named_string("Error", err); + assertEq(a, b); + } + } + + function assertEq(DataStructures.ProposalState a, DataStructures.ProposalState b) internal { + if (a != b) { + emit log("Error: a == b not satisfied [DataStructures.ProposalState]"); + emit log_named_uint(" Left", uint256(a)); + emit log_named_uint(" Right", uint256(b)); + fail(); + } + } + + function assertEq( + DataStructures.ProposalState a, + DataStructures.ProposalState b, + string memory err + ) internal { + if (a != b) { + emit log_named_string("Error", err); + assertEq(a, b); + } + } +} diff --git a/l1-contracts/test/governance/apella/deposit.t.sol b/l1-contracts/test/governance/apella/deposit.t.sol new file mode 100644 index 00000000000..fb1edfc1a06 --- /dev/null +++ b/l1-contracts/test/governance/apella/deposit.t.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {ApellaBase} from "./base.t.sol"; +import {IApella} from "@aztec/governance/interfaces/IApella.sol"; +import {IERC20Errors} from "@oz/interfaces/draft-IERC6093.sol"; +import {Timestamp} from "@aztec/core/libraries/TimeMath.sol"; + +contract DepositTest is ApellaBase { + uint256 internal constant DEPOSIT_COUNT = 8; + mapping(address => uint256) internal sums; + + function test_WhenCallerHaveInsufficientAllowance(uint256 _amount) external { + // it revert + + uint256 amount = bound(_amount, 1, type(uint256).max); + vm.expectRevert( + abi.encodeWithSelector( + IERC20Errors.ERC20InsufficientAllowance.selector, address(apella), 0, amount + ) + ); + apella.deposit(address(this), amount); + } + + modifier whenCallerHaveSufficientAllowance() { + _; + } + + function test_WhenCallerHaveInsufficientFunds(uint256 _amount) + external + whenCallerHaveSufficientAllowance + { + // it revert + uint256 amount = bound(_amount, 1, type(uint256).max); + + token.approve(address(apella), amount); + + vm.expectRevert( + abi.encodeWithSelector( + IERC20Errors.ERC20InsufficientBalance.selector, address(this), 0, amount + ) + ); + apella.deposit(address(this), amount); + } + + function test_WhenCallerHaveSufficientFunds( + address[DEPOSIT_COUNT] memory _onBehalfOfs, + uint256[DEPOSIT_COUNT] memory _deposits, + uint256[DEPOSIT_COUNT] memory _timejumps + ) external whenCallerHaveSufficientAllowance { + // it transfer funds from caller + // it add snapshot to user + // it add snapshot to the total + // it emits a {Deposit} event + + uint256 sum = 0; + for (uint256 i = 0; i < DEPOSIT_COUNT; i++) { + address onBehalfOf = i % 2 == 0 ? _onBehalfOfs[i] : address(0xdeadbeef); + uint256 amount = bound(_deposits[i], 1, type(uint128).max); + uint256 timeJump = bound(_timejumps[i], 1, type(uint32).max); + + token.mint(address(this), amount); + token.approve(address(apella), amount); + + assertEq(token.balanceOf(address(this)), amount); + assertEq(token.allowance(address(this), address(apella)), amount); + + sums[onBehalfOf] += amount; + sum += amount; + vm.warp(block.timestamp + timeJump); + + vm.expectEmit(true, true, true, true, address(apella)); + emit IApella.Deposit(address(this), onBehalfOf, amount); + apella.deposit(onBehalfOf, amount); + + assertEq( + apella.powerAt(onBehalfOf, Timestamp.wrap(block.timestamp - 1)), sums[onBehalfOf] - amount + ); + assertEq(apella.powerAt(onBehalfOf, Timestamp.wrap(block.timestamp)), sums[onBehalfOf]); + assertEq(apella.totalPowerAt(Timestamp.wrap(block.timestamp - 1)), sum - amount); + assertEq(apella.totalPowerAt(Timestamp.wrap(block.timestamp)), sum); + + assertEq(token.balanceOf(address(this)), 0); + assertEq(token.allowance(address(this), address(apella)), 0); + } + } +} diff --git a/l1-contracts/test/governance/apella/deposit.tree b/l1-contracts/test/governance/apella/deposit.tree new file mode 100644 index 00000000000..e443c8a3d05 --- /dev/null +++ b/l1-contracts/test/governance/apella/deposit.tree @@ -0,0 +1,11 @@ +DepositTest +├── when caller have insufficient allowance +│ └── it revert +└── when caller have sufficient allowance + ├── when caller have insufficient funds + │ └── it revert + └── when caller have sufficient funds + ├── it transfer funds from caller + ├── it add snapshot to user + ├── it add snapshot to the total + └── it emits a {Deposit} event \ No newline at end of file diff --git a/l1-contracts/test/governance/apella/dropProposal.t.sol b/l1-contracts/test/governance/apella/dropProposal.t.sol new file mode 100644 index 00000000000..4cb9794547e --- /dev/null +++ b/l1-contracts/test/governance/apella/dropProposal.t.sol @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {ApellaBase} from "./base.t.sol"; +import {DataStructures} from "@aztec/governance/libraries/DataStructures.sol"; +import {Errors} from "@aztec/governance/libraries/Errors.sol"; + +contract DropProposalTest is ApellaBase { + modifier givenProposalIsStable() { + _; + } + + function test_GivenProposalIsDropped(address _gerousia) external givenProposalIsStable { + // it revert + _stateDropped("empty", _gerousia); + assertEq(apella.getProposal(proposalId).state, DataStructures.ProposalState.Pending); + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Dropped); + assertTrue(apella.dropProposal(proposalId)); + assertEq(apella.getProposal(proposalId).state, DataStructures.ProposalState.Dropped); + + vm.expectRevert(abi.encodeWithSelector(Errors.Apella__ProposalAlreadyDropped.selector)); + apella.dropProposal(proposalId); + } + + function test_GivenProposalIsExecuted( + address _voter, + uint256 _totalPower, + uint256 _votesCast, + uint256 _yeas + ) external givenProposalIsStable { + // it revert + _stateExecutable("empty", _voter, _totalPower, _votesCast, _yeas); + assertTrue(apella.execute(proposalId)); + assertEq(apella.getProposal(proposalId).state, DataStructures.ProposalState.Executed); + + vm.expectRevert(abi.encodeWithSelector(Errors.Apella__ProposalCannotBeDropped.selector)); + apella.dropProposal(proposalId); + } + + modifier givenProposalIsUnstable() { + _; + } + + modifier whenGetProposalStateIsNotDropped() { + _; + vm.expectRevert(abi.encodeWithSelector(Errors.Apella__ProposalCannotBeDropped.selector)); + apella.dropProposal(proposalId); + } + + function test_WhenGetProposalStateIsPending() + external + givenProposalIsUnstable + whenGetProposalStateIsNotDropped + { + // it revert + _statePending("empty"); + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Pending); + } + + function test_WhenGetProposalStateIsActive() + external + givenProposalIsUnstable + whenGetProposalStateIsNotDropped + { + // it revert + _stateActive("empty"); + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Active); + } + + function test_WhenGetProposalStateIsQueued( + address _voter, + uint256 _totalPower, + uint256 _votesCast, + uint256 _yeas + ) external givenProposalIsUnstable whenGetProposalStateIsNotDropped { + // it revert + _stateQueued("empty", _voter, _totalPower, _votesCast, _yeas); + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Queued); + } + + function test_WhenGetProposalStateIsExecutable( + address _voter, + uint256 _totalPower, + uint256 _votesCast, + uint256 _yeas + ) external givenProposalIsUnstable whenGetProposalStateIsNotDropped { + // it revert + _stateExecutable("empty", _voter, _totalPower, _votesCast, _yeas); + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Executable); + } + + function test_WhenGetProposalStateIsRejected() + external + givenProposalIsUnstable + whenGetProposalStateIsNotDropped + { + // it revert + _stateRejected("empty"); + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Rejected); + } + + function test_WhenGetProposalStateIsExecuted( + address _voter, + uint256 _totalPower, + uint256 _votesCast, + uint256 _yeas + ) external givenProposalIsUnstable whenGetProposalStateIsNotDropped { + // it revert + _stateExecutable("empty", _voter, _totalPower, _votesCast, _yeas); + assertTrue(apella.execute(proposalId)); + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Executed); + } + + function test_WhenGetProposalStateIsExpired( + address _voter, + uint256 _totalPower, + uint256 _votesCast, + uint256 _yeas + ) external givenProposalIsUnstable whenGetProposalStateIsNotDropped { + // it revert + _stateExpired("empty", _voter, _totalPower, _votesCast, _yeas); + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Expired); + } + + function test_WhenGetProposalStateIsDropped(address _gerousia) external givenProposalIsUnstable { + // it updates state to Dropped + // it return true + + _stateDropped("empty", _gerousia); + assertEq(apella.getProposal(proposalId).state, DataStructures.ProposalState.Pending); + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Dropped); + assertTrue(apella.dropProposal(proposalId)); + assertEq(apella.getProposal(proposalId).state, DataStructures.ProposalState.Dropped); + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Dropped); + } +} diff --git a/l1-contracts/test/governance/apella/dropProposal.tree b/l1-contracts/test/governance/apella/dropProposal.tree new file mode 100644 index 00000000000..156db4a948a --- /dev/null +++ b/l1-contracts/test/governance/apella/dropProposal.tree @@ -0,0 +1,25 @@ +DropProposalTest +├── given proposal is stable +│ ├── given proposal is Dropped +│ │ └── it revert +│ └── given proposal is Executed +│ └── it revert +└── given proposal is unstable + ├── when getProposalState is not Dropped + │ ├── when getProposalState is Pending + │ │ └── it revert + │ ├── when getProposalState is Active + │ │ └── it revert + │ ├── when getProposalState is Queued + │ │ └── it revert + │ ├── when getProposalState is Executable + │ │ └── it revert + │ ├── when getProposalState is Rejected + │ │ └── it revert + │ ├── when getProposalState is Executed + │ │ └── it revert + │ └── when getProposalState is Expired + │ └── it revert + └── when getProposalState is Dropped + ├── it updates state to Dropped + └── it return true \ No newline at end of file diff --git a/l1-contracts/test/governance/apella/execute.t.sol b/l1-contracts/test/governance/apella/execute.t.sol new file mode 100644 index 00000000000..10fe58a4e74 --- /dev/null +++ b/l1-contracts/test/governance/apella/execute.t.sol @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {ApellaBase} from "./base.t.sol"; +import {Errors} from "@aztec/governance/libraries/Errors.sol"; +import {DataStructures} from "@aztec/governance/libraries/DataStructures.sol"; +import {IApella} from "@aztec/governance/interfaces/IApella.sol"; +import { + ProposalLib, + VoteTabulationReturn, + VoteTabulationInfo +} from "@aztec/governance/libraries/ProposalLib.sol"; + +import {CallAssetPayload, UpgradePayload, CallRevertingPayload} from "./TestPayloads.sol"; + +contract ExecuteTest is ApellaBase { + using ProposalLib for DataStructures.Proposal; + + uint256 internal depositPower; + + modifier givenStateIsNotExecutable() { + _; + vm.expectRevert(abi.encodeWithSelector(Errors.Apella__ProposalNotExecutable.selector)); + apella.execute(proposalId); + } + + function test_GivenStateIsPending() external givenStateIsNotExecutable { + // it revert + _statePending("empty"); + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Pending); + } + + function test_GivenStateIsActive() external givenStateIsNotExecutable { + // it revert + _stateActive("empty"); + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Active); + } + + function test_GivenStateIsQueued( + address _voter, + uint256 _totalPower, + uint256 _votesCast, + uint256 _yeas + ) external givenStateIsNotExecutable { + // it revert + _stateQueued("empty", _voter, _totalPower, _votesCast, _yeas); + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Queued); + } + + function test_GivenStateIsRejected() external givenStateIsNotExecutable { + // it revert + _stateRejected("empty"); + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Rejected); + } + + function test_GivenStateIsDropped(address _gerousia) external givenStateIsNotExecutable { + // it revert + _stateDropped("empty", _gerousia); + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Dropped); + } + + function test_GivenStateIsExecuted( + address _voter, + uint256 _totalPower, + uint256 _votesCast, + uint256 _yeas + ) external givenStateIsNotExecutable { + // it revert + _stateExecutable("empty", _voter, _totalPower, _votesCast, _yeas); + assertTrue(apella.execute(proposalId)); + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Executed); + } + + function test_GivenStateIsExpired( + address _voter, + uint256 _totalPower, + uint256 _votesCast, + uint256 _yeas + ) external givenStateIsNotExecutable { + // it revert + _stateExpired("empty", _voter, _totalPower, _votesCast, _yeas); + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Expired); + } + + modifier givenStateIsExecutable( + address _voter, + uint256 _totalPower, + uint256 _votesCast, + uint256 _yeas, + bytes32 _proposalName + ) { + _stateExecutable(_proposalName, _voter, _totalPower, _votesCast, _yeas); + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Executable); + _; + } + + function test_GivenPayloadCallAsset( + address _voter, + uint256 _totalPower, + uint256 _votesCast, + uint256 _yeas + ) external givenStateIsExecutable(_voter, _totalPower, _votesCast, _yeas, "call_asset") { + // it revert + + vm.expectRevert(abi.encodeWithSelector(Errors.Apella__CannotCallAsset.selector)); + apella.execute(proposalId); + } + + modifier givenPayloadDontCallAsset() { + _; + } + + function test_GivenAPayloadCallFails( + address _voter, + uint256 _totalPower, + uint256 _votesCast, + uint256 _yeas + ) + external + givenStateIsExecutable(_voter, _totalPower, _votesCast, _yeas, "revert") + givenPayloadDontCallAsset + { + // it revert + + vm.expectRevert( + abi.encodeWithSelector( + Errors.Apella__CallFailed.selector, + address(CallRevertingPayload(address(proposal.payload)).TARGET()) + ) + ); + apella.execute(proposalId); + } + + function test_GivenAllPayloadCallSucceeds( + address _voter, + uint256 _totalPower, + uint256 _votesCast, + uint256 _yeas + ) + external + givenStateIsExecutable(_voter, _totalPower, _votesCast, _yeas, "upgrade") + givenPayloadDontCallAsset + { + // it updates state to Executed + // it executes the calls + // it emits {ProposalExecuted} event + // it return true + + vm.expectEmit(true, true, true, true, address(apella)); + emit IApella.ProposalExecuted(proposalId); + assertTrue(apella.execute(proposalId)); + + proposal = apella.getProposal(proposalId); + + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Executed); + assertEq(proposal.state, DataStructures.ProposalState.Executed); + address rollup = registry.getRollup(); + assertEq(rollup, UpgradePayload(address(proposal.payload)).NEW_ROLLUP()); + } +} diff --git a/l1-contracts/test/governance/apella/execute.tree b/l1-contracts/test/governance/apella/execute.tree new file mode 100644 index 00000000000..696d3c9fc05 --- /dev/null +++ b/l1-contracts/test/governance/apella/execute.tree @@ -0,0 +1,27 @@ +ExecuteTest +├── given state is not executable +│ ├── given state is pending +│ │ └── it revert +│ ├── given state is active +│ │ └── it revert +│ ├── given state is queued +│ │ └── it revert +│ ├── given state is rejected +│ │ └── it revert +│ ├── given state is dropped +│ │ └── it revert +│ ├── given state is executed +│ │ └── it revert +│ └── given state is expired +│ └── it revert +└── given state is executable + ├── given payload call asset + │ └── it revert + └── given payload don't call asset + ├── given a payload call fails + │ └── it revert + └── given all payload call succeeds + ├── it updates state to Executed + ├── it executes the calls + ├── it emits {ProposalExecuted} event + └── it return true \ No newline at end of file diff --git a/l1-contracts/test/governance/apella/finaliseWithdraw.t.sol b/l1-contracts/test/governance/apella/finaliseWithdraw.t.sol new file mode 100644 index 00000000000..f4ae280fee3 --- /dev/null +++ b/l1-contracts/test/governance/apella/finaliseWithdraw.t.sol @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {ApellaBase} from "./base.t.sol"; +import {IApella} from "@aztec/governance/interfaces/IApella.sol"; +import {IERC20Errors} from "@oz/interfaces/draft-IERC6093.sol"; +import {Timestamp} from "@aztec/core/libraries/TimeMath.sol"; +import {Errors} from "@aztec/governance/libraries/Errors.sol"; +import {DataStructures} from "@aztec/governance/libraries/DataStructures.sol"; +import {ConfigurationLib} from "@aztec/governance/libraries/ConfigurationLib.sol"; + +contract FinaliseWithdrawTest is ApellaBase { + using ConfigurationLib for DataStructures.Configuration; + + uint256 internal constant WITHDRAWAL_COUNT = 8; + mapping(address => uint256) internal sums; + + uint256 internal deposit; + + function test_WhenIdMatchNoPendingWithdrawal(uint256 _id) external { + // it revert + vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InvalidReceiver.selector, address(0))); + apella.finaliseWithdraw(_id); + } + + // Lot of this is similar to initiateWithdraw.t.sol::test_WhenCallerHaveSufficientDeposits + modifier whenItMatchPendingWithdrawal( + uint256 _depositAmount, + address[WITHDRAWAL_COUNT] memory _recipient, + uint256[WITHDRAWAL_COUNT] memory _withdrawals, + uint256[WITHDRAWAL_COUNT] memory _timejumps + ) { + deposit = _depositAmount; + uint256 sum = deposit; + + token.mint(address(this), deposit); + token.approve(address(apella), deposit); + apella.deposit(address(this), deposit); + + for (uint256 i = 0; i < WITHDRAWAL_COUNT; i++) { + address recipient = i % 2 == 0 ? _recipient[i] : address(0xdeadbeef); + vm.assume(recipient != address(apella) && recipient != address(0)); + uint256 amount = bound(_withdrawals[i], 0, sum); + // Note tiny time jumps so we do not need to jump back and forth in time. + uint256 timeJump = bound(_timejumps[i], 1, type(uint8).max); + + if (amount == 0) { + continue; + } + + sum -= amount; + vm.warp(block.timestamp + timeJump); + + apella.initiateWithdraw(recipient, amount); + } + + _; + } + + function test_GivenWithdrawanAlreadyClaimed( + uint256 _depositAmount, + address[WITHDRAWAL_COUNT] memory _recipient, + uint256[WITHDRAWAL_COUNT] memory _withdrawals, + uint256[WITHDRAWAL_COUNT] memory _timejumps + ) external whenItMatchPendingWithdrawal(_depositAmount, _recipient, _withdrawals, _timejumps) { + // it revert + + uint256 withdrawalCount = apella.withdrawalCount(); + for (uint256 i = 0; i < withdrawalCount; i++) { + DataStructures.Withdrawal memory withdrawal = apella.getWithdrawal(i); + vm.warp(Timestamp.unwrap(withdrawal.unlocksAt)); + apella.finaliseWithdraw(i); + } + + for (uint256 i = 0; i < withdrawalCount; i++) { + vm.expectRevert(abi.encodeWithSelector(Errors.Apella__WithdrawalAlreadyclaimed.selector)); + apella.finaliseWithdraw(i); + } + } + + modifier givenWithdrawanNotClaimed() { + _; + } + + function test_WhenTimeIsBeforeUnlock( + uint256 _depositAmount, + address[WITHDRAWAL_COUNT] memory _recipient, + uint256[WITHDRAWAL_COUNT] memory _withdrawals, + uint256[WITHDRAWAL_COUNT] memory _timejumps, + uint256[WITHDRAWAL_COUNT] memory _timejumps2 + ) + external + whenItMatchPendingWithdrawal(_depositAmount, _recipient, _withdrawals, _timejumps) + givenWithdrawanNotClaimed + { + // it revert + + uint256 withdrawalCount = apella.withdrawalCount(); + for (uint256 i = 0; i < withdrawalCount; i++) { + DataStructures.Withdrawal memory withdrawal = apella.getWithdrawal(i); + assertGt(withdrawal.unlocksAt, block.timestamp); + + uint256 time = + bound(_timejumps2[i], block.timestamp, Timestamp.unwrap(withdrawal.unlocksAt) - 1); + + vm.warp(time); + + vm.expectRevert( + abi.encodeWithSelector( + Errors.Apella__WithdrawalNotUnlockedYet.selector, + Timestamp.wrap(block.timestamp), + withdrawal.unlocksAt + ) + ); + apella.finaliseWithdraw(i); + } + } + + function test_WhenTimeIsAfterOrAtUnlock( + uint256 _depositAmount, + address[WITHDRAWAL_COUNT] memory _recipient, + uint256[WITHDRAWAL_COUNT] memory _withdrawals, + uint256[WITHDRAWAL_COUNT] memory _timejumps, + uint256[WITHDRAWAL_COUNT] memory _timejumps2 + ) + external + whenItMatchPendingWithdrawal(_depositAmount, _recipient, _withdrawals, _timejumps) + givenWithdrawanNotClaimed + { + // it mark withdrawal as claimed + // it transfer funds to account + // it emits {WithdrawalFinalised} event + + uint256 sum = token.balanceOf(address(apella)); + + uint256 withdrawalCount = apella.withdrawalCount(); + for (uint256 i = 0; i < withdrawalCount; i++) { + DataStructures.Withdrawal memory withdrawal = apella.getWithdrawal(i); + + uint256 upper = i + 1 == withdrawalCount + ? type(uint256).max + : Timestamp.unwrap(apella.getWithdrawal(i + 1).unlocksAt); + uint256 time = bound(_timejumps2[i], Timestamp.unwrap(withdrawal.unlocksAt), upper); + + vm.warp(time); + + vm.expectEmit(true, true, true, true, address(apella)); + emit IApella.WithdrawFinalised(i); + apella.finaliseWithdraw(i); + + DataStructures.Withdrawal memory withdrawal2 = apella.getWithdrawal(i); + assertTrue(withdrawal2.claimed); + + sum -= withdrawal.amount; + sums[withdrawal.recipient] += withdrawal.amount; + + assertEq(token.balanceOf(address(apella)), sum, "total balance"); + assertEq( + token.balanceOf(withdrawal.recipient), sums[withdrawal.recipient], "recipient balance" + ); + } + } +} diff --git a/l1-contracts/test/governance/apella/finaliseWithdraw.tree b/l1-contracts/test/governance/apella/finaliseWithdraw.tree new file mode 100644 index 00000000000..e33bd567127 --- /dev/null +++ b/l1-contracts/test/governance/apella/finaliseWithdraw.tree @@ -0,0 +1,13 @@ +FinaliseWithdrawTest +├── when id match no pending withdrawal +│ └── it revert +└── when it match pending withdrawal + ├── given withdrawan already claimed + │ └── it revert + └── given withdrawan not claimed + ├── when time is before unlock + │ └── it revert + └── when time is after or at unlock + ├── it mark withdrawal as claimed + ├── it transfer funds to account + └── it emits {WithdrawalFinalised} event \ No newline at end of file diff --git a/l1-contracts/test/governance/apella/getProposalState.t.sol b/l1-contracts/test/governance/apella/getProposalState.t.sol new file mode 100644 index 00000000000..75c15a63c38 --- /dev/null +++ b/l1-contracts/test/governance/apella/getProposalState.t.sol @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {ApellaBase} from "./base.t.sol"; +import {DataStructures} from "@aztec/governance/libraries/DataStructures.sol"; +import {Errors} from "@aztec/governance/libraries/Errors.sol"; +import {Timestamp} from "@aztec/core/libraries/TimeMath.sol"; +import {ProposalLib, VoteTabulationReturn} from "@aztec/governance/libraries/ProposalLib.sol"; + +contract GetProposalStateTest is ApellaBase { + using ProposalLib for DataStructures.Proposal; + + function test_WhenProposalIsOutOfBounds(uint256 _index) external { + // it revert + uint256 index = bound(_index, apella.proposalCount(), type(uint256).max); + vm.expectRevert(abi.encodeWithSelector(Errors.Apella__ProposalDoesNotExists.selector, index)); + apella.getProposalState(index); + } + + modifier whenValidProposalId() { + _; + } + + modifier givenStateIsStable() { + _; + } + + function test_GivenStateIsExecuted( + address _voter, + uint256 _totalPower, + uint256 _votesCast, + uint256 _yeas + ) external whenValidProposalId givenStateIsStable { + // it return Executed + _stateExecutable("empty", _voter, _totalPower, _votesCast, _yeas); + apella.execute(proposalId); + + assertEq(proposal.state, DataStructures.ProposalState.Pending); + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Executed); + } + + function test_GivenStateIsDropped(address _gerousia) + external + whenValidProposalId + givenStateIsStable + { + // it return Dropped + _stateDropped("empty", _gerousia); + + assertEq(proposal.state, DataStructures.ProposalState.Pending); + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Dropped); + + apella.dropProposal(proposalId); + + DataStructures.Proposal memory fresh = apella.getProposal(proposalId); + assertEq(fresh.state, DataStructures.ProposalState.Dropped); + } + + modifier givenStateIsUnstable() { + _; + + DataStructures.Proposal memory fresh = apella.getProposal(proposalId); + assertEq(fresh.state, DataStructures.ProposalState.Pending); + } + + function test_GivenGerousiaHaveChanged(address _gerousia) + external + whenValidProposalId + givenStateIsUnstable + { + // it return Dropped + _stateDropped("empty", _gerousia); + + assertEq(proposal.state, DataStructures.ProposalState.Pending); + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Dropped); + } + + modifier givenGerousiaIsUnchanged() { + _; + } + + function test_WhenVotingDelayHaveNotPassed(uint256 _timeJump) + external + whenValidProposalId + givenStateIsUnstable + givenGerousiaIsUnchanged + { + // it return Pending + _statePending("empty"); + + uint256 time = bound(_timeJump, block.timestamp, Timestamp.unwrap(proposal.pendingThrough())); + vm.warp(time); + + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Pending); + } + + modifier whenVotingDelayHavePassed() { + _; + } + + function test_WhenVotingDurationHaveNotPassed(uint256 _timeJump) + external + whenValidProposalId + givenStateIsUnstable + givenGerousiaIsUnchanged + whenVotingDelayHavePassed + { + // it return Active + _stateActive("empty"); + + uint256 time = bound(_timeJump, block.timestamp, Timestamp.unwrap(proposal.activeThrough())); + vm.warp(time); + + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Active); + } + + modifier whenVotingDurationHavePassed() { + _; + } + + function test_GivenVoteTabulationIsRejected() + external + whenValidProposalId + givenStateIsUnstable + givenGerousiaIsUnchanged + whenVotingDelayHavePassed + whenVotingDurationHavePassed + { + // it return Rejected + _stateRejected("empty"); + + uint256 totalPower = apella.totalPowerAt(Timestamp.wrap(block.timestamp)); + (VoteTabulationReturn vtr,) = proposal.voteTabulation(totalPower); + assertEq(vtr, VoteTabulationReturn.Rejected, "invalid return value"); + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Rejected); + } + + function test_GivenVoteTabulationIsInvalid( + address _voter, + uint256 _totalPower, + uint256 _votesCast, + uint256 _yeas + ) + external + whenValidProposalId + givenStateIsUnstable + givenGerousiaIsUnchanged + whenVotingDelayHavePassed + whenVotingDurationHavePassed + { + // it return Rejected + _stateQueued("empty", _voter, _totalPower, _votesCast, _yeas); + + // We can overwrite the quorum to be 0 to hit an invalid case + assertGt(apella.getProposal(proposalId).config.quorum, 0); + bytes32 slot = + bytes32(uint256(keccak256(abi.encodePacked(uint256(proposalId), uint256(1)))) + 4); + vm.store(address(apella), slot, 0); + assertEq(apella.getProposal(proposalId).config.quorum, 0); + + uint256 totalPower = apella.totalPowerAt(Timestamp.wrap(block.timestamp)); + + proposal = apella.getProposal(proposalId); + (VoteTabulationReturn vtr,) = proposal.voteTabulation(totalPower); + assertEq(vtr, VoteTabulationReturn.Invalid, "invalid return value"); + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Rejected); + } + + modifier givenVoteTabulationIsAccepted( + address _voter, + uint256 _totalPower, + uint256 _votesCast, + uint256 _yeas + ) { + _stateQueued("empty", _voter, _totalPower, _votesCast, _yeas); + _; + } + + function test_GivenExecutionDelayHaveNotPassed( + address _voter, + uint256 _totalPower, + uint256 _votesCast, + uint256 _yeas, + uint256 _timeJump + ) + external + whenValidProposalId + givenStateIsUnstable + givenGerousiaIsUnchanged + whenVotingDelayHavePassed + whenVotingDurationHavePassed + givenVoteTabulationIsAccepted(_voter, _totalPower, _votesCast, _yeas) + { + // it return Queued + uint256 time = bound(_timeJump, block.timestamp, Timestamp.unwrap(proposal.queuedThrough())); + vm.warp(time); + + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Queued); + } + + modifier givenExecutionDelayHavePassed() { + vm.warp(Timestamp.unwrap(proposal.queuedThrough()) + 1); + _; + } + + function test_GivenGracePeriodHaveNotPassed( + address _voter, + uint256 _totalPower, + uint256 _votesCast, + uint256 _yeas, + uint256 _timeJump + ) + external + whenValidProposalId + givenStateIsUnstable + givenGerousiaIsUnchanged + whenVotingDelayHavePassed + whenVotingDurationHavePassed + givenVoteTabulationIsAccepted(_voter, _totalPower, _votesCast, _yeas) + givenExecutionDelayHavePassed + { + // it return Executable + uint256 time = bound(_timeJump, block.timestamp, Timestamp.unwrap(proposal.executableThrough())); + vm.warp(time); + + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Executable); + } + + function test_GivenGracePeriodHavePassed( + address _voter, + uint256 _totalPower, + uint256 _votesCast, + uint256 _yeas + ) + external + whenValidProposalId + givenStateIsUnstable + givenGerousiaIsUnchanged + whenVotingDelayHavePassed + whenVotingDurationHavePassed + givenVoteTabulationIsAccepted(_voter, _totalPower, _votesCast, _yeas) + givenExecutionDelayHavePassed + { + // it return Expired + vm.warp(Timestamp.unwrap(proposal.executableThrough()) + 1); + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Expired); + } +} diff --git a/l1-contracts/test/governance/apella/getProposalState.tree b/l1-contracts/test/governance/apella/getProposalState.tree new file mode 100644 index 00000000000..2a80ccd79d8 --- /dev/null +++ b/l1-contracts/test/governance/apella/getProposalState.tree @@ -0,0 +1,31 @@ +GetProposalStateTest +├── when proposal is out of bounds +│ └── it revert +└── when valid proposal id + ├── given state is stable + │ ├── given state is Executed + │ │ └── it return Executed + │ └── given state is Dropped + │ └── it return Dropped + └── given state is unstable + ├── given gerousia have changed + │ └── it return Dropped + └── given gerousia is unchanged + ├── when voting delay have not passed + │ └── it return Pending + └── when voting delay have passed + ├── when voting duration have not passed + │ └── it return Active + └── when voting duration have passed + ├── given vote tabulation is Rejected + │ └── it return Rejected + ├── given vote tabulation is Invalid + │ └── it return Rejected + └── given vote tabulation is Accepted + ├── given execution delay have not passed + │ └── it return Queued + └── given execution delay have passed + ├── given grace period have not passed + │ └── it return Executable + └── given grace period have passed + └── it return Expired \ No newline at end of file diff --git a/l1-contracts/test/governance/apella/initiateWithdraw.t.sol b/l1-contracts/test/governance/apella/initiateWithdraw.t.sol new file mode 100644 index 00000000000..853f70d0eff --- /dev/null +++ b/l1-contracts/test/governance/apella/initiateWithdraw.t.sol @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {ApellaBase} from "./base.t.sol"; +import {IApella} from "@aztec/governance/interfaces/IApella.sol"; +import {IERC20Errors} from "@oz/interfaces/draft-IERC6093.sol"; +import {Timestamp} from "@aztec/core/libraries/TimeMath.sol"; +import {Errors} from "@aztec/governance/libraries/Errors.sol"; +import {DataStructures} from "@aztec/governance/libraries/DataStructures.sol"; +import {ConfigurationLib} from "@aztec/governance/libraries/ConfigurationLib.sol"; + +contract InitiateWithdrawTest is ApellaBase { + using ConfigurationLib for DataStructures.Configuration; + + uint256 internal constant WITHDRAWAL_COUNT = 8; + DataStructures.Configuration internal config; + + modifier whenCallerHaveInsufficientDeposits() { + _; + } + + function test_GivenNoCheckpoints(uint256 _amount) external whenCallerHaveInsufficientDeposits { + // it revert + uint256 amount = bound(_amount, 1, type(uint256).max); + vm.expectRevert(abi.encodeWithSelector(Errors.Apella__NoCheckpointsFound.selector)); + apella.initiateWithdraw(address(this), amount); + } + + function test_GivenCheckpoints(uint256 _depositAmount, uint256 _withdrawalAmount) + external + whenCallerHaveInsufficientDeposits + { + // it revert + uint256 depositAmount = bound(_depositAmount, 1, type(uint128).max); + uint256 withdrawalAmount = bound(_withdrawalAmount, depositAmount + 1, type(uint256).max); + + token.mint(address(this), depositAmount); + token.approve(address(apella), depositAmount); + apella.deposit(address(this), depositAmount); + + vm.expectRevert( + abi.encodeWithSelector( + Errors.Apella__InsufficientPower.selector, address(this), depositAmount, withdrawalAmount + ) + ); + apella.initiateWithdraw(address(this), withdrawalAmount); + } + + function test_WhenCallerHaveSufficientDeposits( + uint256 _depositAmount, + address[WITHDRAWAL_COUNT] memory _recipient, + uint256[WITHDRAWAL_COUNT] memory _withdrawals, + uint256[WITHDRAWAL_COUNT] memory _timejumps + ) external { + // it sub snapshot to user + // it sub snapshot to total + // it creates a pending withdrawal with time of unlock + // it emits {WithdrawalInitiated} event + + uint256 deposit = _depositAmount; + uint256 sum = deposit; + uint256 withdrawalId = 0; + + token.mint(address(this), deposit); + token.approve(address(apella), deposit); + apella.deposit(address(this), deposit); + assertEq(token.balanceOf(address(apella)), deposit); + + config = apella.getConfiguration(); + + for (uint256 i = 0; i < WITHDRAWAL_COUNT; i++) { + address recipient = i % 2 == 0 ? _recipient[i] : address(0xdeadbeef); + uint256 amount = bound(_withdrawals[i], 0, sum); + uint256 timeJump = bound(_timejumps[i], 1, type(uint32).max); + + if (amount == 0) { + continue; + } + + sum -= amount; + vm.warp(block.timestamp + timeJump); + + vm.expectEmit(true, true, true, true, address(apella)); + emit IApella.WithdrawInitiated(withdrawalId, recipient, amount); + apella.initiateWithdraw(recipient, amount); + + DataStructures.Withdrawal memory withdrawal = apella.getWithdrawal(withdrawalId); + assertEq(withdrawal.amount, amount, "invalid amount"); + assertEq( + withdrawal.unlocksAt, + Timestamp.wrap(block.timestamp) + config.lockDelay(), + "Invalid timestamp" + ); + assertEq(withdrawal.recipient, recipient, "invalid recipient"); + assertFalse(withdrawal.claimed, "already claimed"); + assertEq(apella.totalPowerAt(Timestamp.wrap(block.timestamp)), sum); + + withdrawalId++; + + assertEq(apella.withdrawalCount(), withdrawalId); + } + assertEq(token.balanceOf(address(apella)), deposit); + } +} diff --git a/l1-contracts/test/governance/apella/initiateWithdraw.tree b/l1-contracts/test/governance/apella/initiateWithdraw.tree new file mode 100644 index 00000000000..ad1d5f851b6 --- /dev/null +++ b/l1-contracts/test/governance/apella/initiateWithdraw.tree @@ -0,0 +1,11 @@ +InitiateWithdrawTest +├── when caller have insufficient deposits +│ ├── given no checkpoints +│ │ └── it revert +│ └── given checkpoints +│ └── it revert +└── when caller have sufficient deposits + ├── it sub snapshot to user + ├── it sub snapshot to total + ├── it creates a pending withdrawal with time of unlock + └── it emits {WithdrawalInitiated} event \ No newline at end of file diff --git a/l1-contracts/test/governance/apella/proposallib/isStableState.t.sol b/l1-contracts/test/governance/apella/proposallib/isStableState.t.sol new file mode 100644 index 00000000000..6f68e0e43ef --- /dev/null +++ b/l1-contracts/test/governance/apella/proposallib/isStableState.t.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {Test} from "forge-std/Test.sol"; +import {DataStructures} from "@aztec/governance/libraries/DataStructures.sol"; +import {ProposalLib} from "@aztec/governance/libraries/ProposalLib.sol"; + +contract IsStableStateTest is Test { + using ProposalLib for DataStructures.Proposal; + + DataStructures.Proposal internal proposal; + + function test_GivenStateIsExecuted() external { + // it return true + proposal.state = DataStructures.ProposalState.Executed; + assertTrue(proposal.isStable()); + } + + function test_GivenStateIsDropped() external { + // it return true + proposal.state = DataStructures.ProposalState.Dropped; + assertTrue(proposal.isStable()); + } + + function test_GivenStateNotInAbove(uint8 _state) external { + // it return false + DataStructures.ProposalState s = DataStructures.ProposalState(bound(_state, 0, 7)); + + vm.assume( + !(s == DataStructures.ProposalState.Executed || s == DataStructures.ProposalState.Dropped) + ); + + proposal.state = s; + + assertFalse(proposal.isStable()); + } +} diff --git a/l1-contracts/test/governance/apella/proposallib/isStableState.tree b/l1-contracts/test/governance/apella/proposallib/isStableState.tree new file mode 100644 index 00000000000..60e73f9ee76 --- /dev/null +++ b/l1-contracts/test/governance/apella/proposallib/isStableState.tree @@ -0,0 +1,7 @@ +IsStableStateTest +├── given state is Executed +│ └── it return true +├── given state is Dropped +│ └── it return true +└── given state not in above + └── it return false \ No newline at end of file diff --git a/l1-contracts/test/governance/apella/proposallib/static.t.sol b/l1-contracts/test/governance/apella/proposallib/static.t.sol new file mode 100644 index 00000000000..314db1519e1 --- /dev/null +++ b/l1-contracts/test/governance/apella/proposallib/static.t.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {TestBase} from "@test/base/Base.sol"; +import {DataStructures} from "@aztec/governance/libraries/DataStructures.sol"; +import {ProposalLib} from "@aztec/governance/libraries/ProposalLib.sol"; +import {Timestamp} from "@aztec/core/libraries/TimeMath.sol"; + +contract Static is TestBase { + using ProposalLib for DataStructures.Proposal; + + DataStructures.Proposal internal proposal; + + modifier limitConfig(DataStructures.Configuration memory _config) { + proposal.config.votingDelay = + Timestamp.wrap(bound(Timestamp.unwrap(_config.votingDelay), 0, type(uint32).max)); + proposal.config.votingDuration = + Timestamp.wrap(bound(Timestamp.unwrap(_config.votingDuration), 0, type(uint32).max)); + proposal.config.executionDelay = + Timestamp.wrap(bound(Timestamp.unwrap(_config.executionDelay), 0, type(uint32).max)); + proposal.config.gracePeriod = + Timestamp.wrap(bound(Timestamp.unwrap(_config.gracePeriod), 0, type(uint32).max)); + + _; + } + + function test_pendingThrough(DataStructures.Configuration memory _config, uint256 _creation) + external + limitConfig(_config) + { + proposal.creation = Timestamp.wrap(bound(_creation, 0, type(uint32).max)); + assertEq(proposal.pendingThrough(), proposal.creation + proposal.config.votingDelay); + } + + function test_activeThrough(DataStructures.Configuration memory _config, uint256 _creation) + external + limitConfig(_config) + { + proposal.creation = Timestamp.wrap(bound(_creation, 0, type(uint32).max)); + assertEq( + proposal.activeThrough(), + proposal.creation + proposal.config.votingDelay + proposal.config.votingDuration + ); + } + + function test_queuedThrough(DataStructures.Configuration memory _config, uint256 _creation) + external + limitConfig(_config) + { + proposal.creation = Timestamp.wrap(bound(_creation, 0, type(uint32).max)); + assertEq( + proposal.queuedThrough(), + proposal.creation + proposal.config.votingDelay + proposal.config.votingDuration + + proposal.config.executionDelay + ); + } + + function test_executableThrough(DataStructures.Configuration memory _config, uint256 _creation) + external + limitConfig(_config) + { + proposal.creation = Timestamp.wrap(bound(_creation, 0, type(uint32).max)); + assertEq( + proposal.executableThrough(), + proposal.creation + proposal.config.votingDelay + proposal.config.votingDuration + + proposal.config.executionDelay + proposal.config.gracePeriod + ); + } +} diff --git a/l1-contracts/test/governance/apella/proposallib/voteTabulation.t.sol b/l1-contracts/test/governance/apella/proposallib/voteTabulation.t.sol new file mode 100644 index 00000000000..0543ac6747e --- /dev/null +++ b/l1-contracts/test/governance/apella/proposallib/voteTabulation.t.sol @@ -0,0 +1,307 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {ApellaBase} from "../base.t.sol"; +import {DataStructures} from "@aztec/governance/libraries/DataStructures.sol"; +import { + ProposalLib, + VoteTabulationReturn, + VoteTabulationInfo +} from "@aztec/governance/libraries/ProposalLib.sol"; +import {ConfigurationLib} from "@aztec/governance/libraries/ConfigurationLib.sol"; + +import {Math} from "@oz/utils/math/Math.sol"; + +contract VoteTabulationTest is ApellaBase { + using ProposalLib for DataStructures.Proposal; + using ConfigurationLib for DataStructures.Configuration; + + uint256 internal totalPower; + uint256 internal votes; + uint256 internal votesNeeded; + uint256 internal yeaLimit; + + function test_WhenMinimumConfigEq0() external { + // it return (Invalid, MinimumEqZero) + (VoteTabulationReturn vtr, VoteTabulationInfo vti) = proposal.voteTabulation(0); + assertEq(vtr, VoteTabulationReturn.Invalid, "invalid return value"); + assertEq(vti, VoteTabulationInfo.MinimumEqZero, "invalid info value"); + } + + modifier whenMinimumGt0(DataStructures.Configuration memory _config) { + proposal.config.minimumVotes = + bound(_config.minimumVotes, ConfigurationLib.VOTES_LOWER, type(uint256).max); + _; + } + + function test_WhenTotalPowerLtMinimum( + DataStructures.Configuration memory _config, + uint256 _totalPower + ) external whenMinimumGt0(_config) { + // it return (Rejected, TotalPowerLtMinimum) + totalPower = bound(_totalPower, 0, proposal.config.minimumVotes - 1); + (VoteTabulationReturn vtr, VoteTabulationInfo vti) = proposal.voteTabulation(totalPower); + assertEq(vtr, VoteTabulationReturn.Rejected, "invalid return value"); + assertEq(vti, VoteTabulationInfo.TotalPowerLtMinimum, "invalid info value"); + } + + modifier whenTotalPowerGteMinimum(uint256 _totalPower) { + totalPower = bound(_totalPower, proposal.config.minimumVotes, type(uint256).max); + _; + } + + modifier whenQuorumConfigInvalid() { + _; + } + + function test_WhenVotesNeededEq0(DataStructures.Configuration memory _config, uint256 _totalPower) + external + whenMinimumGt0(_config) + whenTotalPowerGteMinimum(_totalPower) + whenQuorumConfigInvalid + { + // it return (Invalid, VotesNeededEqZero) + (VoteTabulationReturn vtr, VoteTabulationInfo vti) = proposal.voteTabulation(totalPower); + assertEq(vtr, VoteTabulationReturn.Invalid, "invalid return value"); + assertEq(vti, VoteTabulationInfo.VotesNeededEqZero, "invalid info value"); + } + + function test_WhenVotesNeededGtTotal( + DataStructures.Configuration memory _config, + uint256 _totalPower + ) external whenMinimumGt0(_config) whenTotalPowerGteMinimum(_totalPower) whenQuorumConfigInvalid { + // it return (Invalid, VotesNeededGtTotalPower) + + proposal.config.quorum = 1e18 + 1; + + // Overwriting some limits such that we do not overflow + uint256 upperLimit = Math.mulDiv(type(uint256).max, 1e18, proposal.config.quorum); + proposal.config.minimumVotes = + bound(_config.minimumVotes, ConfigurationLib.VOTES_LOWER, upperLimit); + totalPower = bound(_totalPower, proposal.config.minimumVotes, upperLimit); + + (VoteTabulationReturn vtr, VoteTabulationInfo vti) = proposal.voteTabulation(totalPower); + assertEq(vtr, VoteTabulationReturn.Invalid, "invalid return value"); + assertEq(vti, VoteTabulationInfo.VotesNeededGtTotalPower, "invalid info value"); + } + + function test_WhenVotesNeededGtUint256Max( + DataStructures.Configuration memory _config, + uint256 _totalPower + ) external whenMinimumGt0(_config) whenTotalPowerGteMinimum(_totalPower) whenQuorumConfigInvalid { + // it revert + totalPower = type(uint256).max; + proposal.config.quorum = 1e18 + 1; + vm.expectRevert(abi.encodeWithSelector(Math.MathOverflowedMulDiv.selector)); + proposal.voteTabulation(totalPower); + } + + modifier whenQuorumConfigValid(DataStructures.Configuration memory _config) { + proposal.config.quorum = + bound(_config.quorum, ConfigurationLib.QUORUM_LOWER, ConfigurationLib.QUORUM_UPPER); + votesNeeded = Math.mulDiv(totalPower, proposal.config.quorum, 1e18, Math.Rounding.Ceil); + + _; + } + + function test_WhenVotesCastLtVotesNeeded( + DataStructures.Configuration memory _config, + uint256 _totalPower, + uint256 _votes, + uint256 _yea + ) + external + whenMinimumGt0(_config) + whenTotalPowerGteMinimum(_totalPower) + whenQuorumConfigValid(_config) + { + // it return (Rejected, VotesCastLtVotesNeeded) + + uint256 maxVotes = votesNeeded > 0 ? votesNeeded - 1 : votesNeeded; + + votes = bound(_votes, 0, maxVotes); + + uint256 yea = bound(_yea, 0, votes); + uint256 nea = votes - yea; + + proposal.summedBallot.yea = yea; + proposal.summedBallot.nea = nea; + + (VoteTabulationReturn vtr, VoteTabulationInfo vti) = proposal.voteTabulation(totalPower); + assertEq(vtr, VoteTabulationReturn.Rejected, "invalid return value"); + assertEq(vti, VoteTabulationInfo.VotesCastLtVotesNeeded, "invalid info value"); + } + + modifier whenVotesCastGteVotesNeeded(uint256 _votes) { + votes = bound(_votes, votesNeeded, totalPower); + _; + } + + modifier whenDifferentialConfigInvalid() { + _; + } + + function test_WhenYeaLimitEq0( + DataStructures.Configuration memory _config, + uint256 _totalPower, + uint256 _votes + ) + external + whenMinimumGt0(_config) + whenTotalPowerGteMinimum(_totalPower) + whenQuorumConfigValid(_config) + whenVotesCastGteVotesNeeded(_votes) + whenDifferentialConfigInvalid + { + // it return (Invalid, YeaLimitEqZero) + // It should be impossible to hit this case as `yeaLimitFraction` cannot be 0, + // and due to rounding, only way to hit this would be if `votesCast = 0`, + // which is already handled as `votesCast >= votesNeeded` and `votesNeeded > 0`. + } + + function test_WhenYeaLimitGtUint256Max( + DataStructures.Configuration memory _config, + uint256 _totalPower, + uint256 _votes + ) + external + whenMinimumGt0(_config) + whenTotalPowerGteMinimum(_totalPower) + whenQuorumConfigValid(_config) + whenVotesCastGteVotesNeeded(_votes) + whenDifferentialConfigInvalid + { + // it revert + proposal.config.voteDifferential = 1e18 + 1; + totalPower = type(uint256).max; + proposal.summedBallot.nea = totalPower; + + vm.expectRevert(abi.encodeWithSelector(Math.MathOverflowedMulDiv.selector)); + proposal.voteTabulation(totalPower); + } + + function test_WhenYeaLimitGtVotesCast( + DataStructures.Configuration memory _config, + uint256 _totalPower, + uint256 _votes + ) + external + whenMinimumGt0(_config) + whenTotalPowerGteMinimum(_totalPower) + whenQuorumConfigValid(_config) + whenVotesCastGteVotesNeeded(_votes) + whenDifferentialConfigInvalid + { + // it return (Invalid, YeaLimitGtVotesCast) + proposal.config.voteDifferential = 1e18 + 1; + + // Overwriting some limits such that we do not overflow + uint256 upperLimit = Math.mulDiv(type(uint256).max, 1e18, proposal.config.voteDifferential); + proposal.config.minimumVotes = + bound(_config.minimumVotes, ConfigurationLib.VOTES_LOWER, upperLimit); + totalPower = bound(_totalPower, proposal.config.minimumVotes, upperLimit); + votesNeeded = Math.mulDiv(totalPower, proposal.config.quorum, 1e18, Math.Rounding.Ceil); + votes = bound(_votes, votesNeeded, totalPower); + proposal.summedBallot.nea = votes; + + (VoteTabulationReturn vtr, VoteTabulationInfo vti) = proposal.voteTabulation(totalPower); + assertEq(vtr, VoteTabulationReturn.Invalid, "invalid return value"); + assertEq(vti, VoteTabulationInfo.YeaLimitGtVotesCast, "invalid info value"); + } + + modifier whenDifferentialConfigValid(DataStructures.Configuration memory _config) { + proposal.config.voteDifferential = + bound(_config.voteDifferential, 0, ConfigurationLib.DIFFERENTIAL_UPPER); + uint256 yeaFraction = Math.ceilDiv(1e18 + proposal.config.voteDifferential, 2); + yeaLimit = Math.mulDiv(votes, yeaFraction, 1e18, Math.Rounding.Ceil); + + _; + } + + function test_WhenYeaVotesEqVotesCast( + DataStructures.Configuration memory _config, + uint256 _totalPower, + uint256 _votes + ) + external + whenMinimumGt0(_config) + whenTotalPowerGteMinimum(_totalPower) + whenQuorumConfigValid(_config) + whenVotesCastGteVotesNeeded(_votes) + whenDifferentialConfigValid(_config) + { + // it return (Accepted, YeaVotesEqVotesCast) + proposal.summedBallot.yea = votes; + + (VoteTabulationReturn vtr, VoteTabulationInfo vti) = proposal.voteTabulation(totalPower); + assertEq(vtr, VoteTabulationReturn.Accepted, "invalid return value"); + assertEq(vti, VoteTabulationInfo.YeaVotesEqVotesCast, "invalid info value"); + } + + function test_WhenYeaVotesLteYeaLimit( + DataStructures.Configuration memory _config, + uint256 _totalPower, + uint256 _votes, + uint256 _yea + ) + external + whenMinimumGt0(_config) + whenTotalPowerGteMinimum(_totalPower) + whenQuorumConfigValid(_config) + whenVotesCastGteVotesNeeded(_votes) + whenDifferentialConfigValid(_config) + { + // it return (Rejected, YeaVotesLeYeaLimit) + + // Likely not the best way to do it, but we just need to avoid that one case. + vm.assume(yeaLimit != votes); + + // Avoid the edge case where all votes YEA, which should pass + uint256 maxYea = yeaLimit == votes ? yeaLimit - 1 : yeaLimit; + + uint256 yea = bound(_yea, 0, maxYea); + uint256 nea = votes - yea; + + proposal.summedBallot.yea = yea; + proposal.summedBallot.nea = nea; + + (VoteTabulationReturn vtr, VoteTabulationInfo vti) = proposal.voteTabulation(totalPower); + assertEq(vtr, VoteTabulationReturn.Rejected, "invalid return value"); + assertEq(vti, VoteTabulationInfo.YeaVotesLeYeaLimit, "invalid info value"); + } + + function test_WhenYeaVotesGtYeaLimit( + DataStructures.Configuration memory _config, + uint256 _totalPower, + uint256 _votes, + uint256 _yea + ) + external + whenMinimumGt0(_config) + whenTotalPowerGteMinimum(_totalPower) + whenQuorumConfigValid(_config) + whenVotesCastGteVotesNeeded(_votes) + whenDifferentialConfigValid(_config) + { + // it return (Accepted, YeaVotesGtYeaLimit) + + // If we are not in the case where all votes are YEA, we should add 1 to ensure + // that we actually have sufficient YEA votes. + uint256 minYea = yeaLimit < votes ? yeaLimit + 1 : yeaLimit; + uint256 yea = bound(_yea, minYea, votes); + + // Likely not the best way to do it, but we just need to avoid that one case. + vm.assume(yea != votes); + + uint256 nea = votes - yea; + + proposal.summedBallot.yea = yea; + proposal.summedBallot.nea = nea; + + assertGt(yea, nea, "yea <= nea"); + + (VoteTabulationReturn vtr, VoteTabulationInfo vti) = proposal.voteTabulation(totalPower); + assertEq(vtr, VoteTabulationReturn.Accepted, "invalid return value"); + assertEq(vti, VoteTabulationInfo.YeaVotesGtYeaLimit, "invalid info value"); + } +} diff --git a/l1-contracts/test/governance/apella/proposallib/voteTabulation.tree b/l1-contracts/test/governance/apella/proposallib/voteTabulation.tree new file mode 100644 index 00000000000..bfd4a5ce91a --- /dev/null +++ b/l1-contracts/test/governance/apella/proposallib/voteTabulation.tree @@ -0,0 +1,32 @@ +VoteTabulationTest +├── when minimum config eq 0 +│ └── it return (Invalid, MinimumEqZero) +└── when minimum gt 0 + ├── when total power lt minimum + │ └── it return (Rejected, TotalPowerLtMinimum) + └── when total power gte minimum + ├── when quorum config invalid + │ ├── when votes needed eq 0 + │ │ └── it return (Invalid, VotesNeededEqZero) + │ ├── when votes needed gt total + │ │ └── it return (Invalid, VotesNeededGtTotalPower) + │ └── when votes needed gt uint256 max + │ └── it revert + └── when quorum config valid + ├── when votes cast lt votes needed + │ └── it return (Rejected, VotesCastLtVotesNeeded) + └── when votes cast gte votes needed + ├── when differential config invalid + │ ├── when yea limit eq 0 + │ │ └── it return (Invalid, YeaLimitEqZero) + │ ├── when yea limit gt uint256 max + │ │ └── it revert + │ └── when yea limit gt votes cast + │ └── it return (Invalid, YeaLimitGtVotesCast) + └── when differential config valid + ├── when yea votes eq votes cast + │ └── it return (Accepted, YeaVotesEqVotesCast) + ├── when yea votes lte yea limit + │ └── it return (Rejected, YeaVotesLeYeaLimit) + └── when yea votes gt yea limit + └── it return (Accepted, YeaVotesGtYeaLimit) \ No newline at end of file diff --git a/l1-contracts/test/governance/apella/propose.t.sol b/l1-contracts/test/governance/apella/propose.t.sol new file mode 100644 index 00000000000..2b1eaf56958 --- /dev/null +++ b/l1-contracts/test/governance/apella/propose.t.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {IPayload} from "@aztec/governance/interfaces/IPayload.sol"; +import {ApellaBase} from "./base.t.sol"; +import {IApella} from "@aztec/governance/interfaces/IApella.sol"; +import {IERC20Errors} from "@oz/interfaces/draft-IERC6093.sol"; +import {Timestamp} from "@aztec/core/libraries/TimeMath.sol"; +import {Errors} from "@aztec/governance/libraries/Errors.sol"; +import {DataStructures} from "@aztec/governance/libraries/DataStructures.sol"; +import {ConfigurationLib} from "@aztec/governance/libraries/ConfigurationLib.sol"; + +contract ProposeTest is ApellaBase { + function test_WhenCallerIsNotGerousia() external { + // it revert + vm.expectRevert( + abi.encodeWithSelector( + Errors.Apella__CallerNotGerousia.selector, address(this), address(gerousia) + ) + ); + apella.propose(IPayload(address(0))); + } + + function test_WhenCallerIsGerousia(address _proposal) external { + // it creates a new proposal with current config + // it emits a {ProposalCreated} event + // it returns true + + DataStructures.Configuration memory config = apella.getConfiguration(); + + proposalId = apella.proposalCount(); + + vm.expectEmit(true, true, true, true, address(apella)); + emit IApella.Proposed(proposalId, _proposal); + + vm.prank(address(gerousia)); + assertTrue(apella.propose(IPayload(_proposal))); + + DataStructures.Proposal memory proposal = apella.getProposal(proposalId); + assertEq(proposal.config.executionDelay, config.executionDelay); + assertEq(proposal.config.gracePeriod, config.gracePeriod); + assertEq(proposal.config.minimumVotes, config.minimumVotes); + assertEq(proposal.config.quorum, config.quorum); + assertEq(proposal.config.voteDifferential, config.voteDifferential); + assertEq(proposal.config.votingDelay, config.votingDelay); + assertEq(proposal.config.votingDuration, config.votingDuration); + assertEq(proposal.creation, Timestamp.wrap(block.timestamp)); + assertEq(proposal.creator, address(gerousia)); + assertEq(proposal.summedBallot.nea, 0); + assertEq(proposal.summedBallot.yea, 0); + assertTrue(proposal.state == DataStructures.ProposalState.Pending); + } +} diff --git a/l1-contracts/test/governance/apella/propose.tree b/l1-contracts/test/governance/apella/propose.tree new file mode 100644 index 00000000000..566af1434db --- /dev/null +++ b/l1-contracts/test/governance/apella/propose.tree @@ -0,0 +1,7 @@ +ProposeTest +├── when caller is not gerousia +│ └── it revert +└── when caller is gerousia + ├── it creates a new proposal with current config + ├── it emits a {ProposalCreated} event + └── it returns true \ No newline at end of file diff --git a/l1-contracts/test/governance/apella/scenarios/noVoteAndExit.t.sol b/l1-contracts/test/governance/apella/scenarios/noVoteAndExit.t.sol new file mode 100644 index 00000000000..ecdbdb6f34c --- /dev/null +++ b/l1-contracts/test/governance/apella/scenarios/noVoteAndExit.t.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {ApellaBase} from "../base.t.sol"; +import {DataStructures} from "@aztec/governance/libraries/DataStructures.sol"; +import {Errors} from "@aztec/governance/libraries/Errors.sol"; +import {Math} from "@oz/utils/math/Math.sol"; +import {Timestamp} from "@aztec/core/libraries/TimeMath.sol"; +import {ProposalLib} from "@aztec/governance/libraries/ProposalLib.sol"; + +contract NoVoteAndExitTest is ApellaBase { + using ProposalLib for DataStructures.Proposal; + // Ensure that it is not possible to BOTH vote on proposal AND withdraw the funds before + // it can be executed + + function test_CannotVoteAndExit( + address _voter, + uint256 _totalPower, + uint256 _votesCast, + uint256 _yeas + ) external { + bytes32 _proposalName = "empty"; + + vm.assume(_voter != address(0)); + proposal = proposals[_proposalName]; + proposalId = proposalIds[_proposalName]; + + uint256 totalPower = bound(_totalPower, proposal.config.minimumVotes, type(uint128).max); + uint256 votesNeeded = Math.mulDiv(totalPower, proposal.config.quorum, 1e18, Math.Rounding.Ceil); + uint256 votesCast = bound(_votesCast, votesNeeded, totalPower); + + uint256 yeaLimitFraction = Math.ceilDiv(1e18 + proposal.config.voteDifferential, 2); + uint256 yeaLimit = Math.mulDiv(votesCast, yeaLimitFraction, 1e18, Math.Rounding.Ceil); + + uint256 yeas = yeaLimit == votesCast ? votesCast : bound(_yeas, yeaLimit + 1, votesCast); + + token.mint(_voter, totalPower); + vm.startPrank(_voter); + token.approve(address(apella), totalPower); + apella.deposit(_voter, totalPower); + vm.stopPrank(); + + // Jump up to the point where the proposal becomes active + vm.warp(Timestamp.unwrap(proposal.pendingThrough()) + 1); + + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Active); + + vm.prank(_voter); + apella.vote(proposalId, yeas, true); + vm.prank(_voter); + apella.vote(proposalId, votesCast - yeas, false); + + vm.prank(_voter); + uint256 withdrawalId = apella.initiateWithdraw(_voter, totalPower); + + // Jump to the block just before we become executable. + vm.warp(Timestamp.unwrap(proposal.queuedThrough())); + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Queued); + + vm.warp(Timestamp.unwrap(proposal.queuedThrough()) + 1); + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Executable); + + DataStructures.Withdrawal memory withdrawal = apella.getWithdrawal(withdrawalId); + + vm.expectRevert( + abi.encodeWithSelector( + Errors.Apella__WithdrawalNotUnlockedYet.selector, + Timestamp.wrap(block.timestamp), + withdrawal.unlocksAt + ) + ); + apella.finaliseWithdraw(withdrawalId); + + apella.execute(proposalId); + } +} diff --git a/l1-contracts/test/governance/apella/updateConfiguration.t.sol b/l1-contracts/test/governance/apella/updateConfiguration.t.sol new file mode 100644 index 00000000000..b45a5e916dc --- /dev/null +++ b/l1-contracts/test/governance/apella/updateConfiguration.t.sol @@ -0,0 +1,354 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {ApellaBase} from "./base.t.sol"; +import {IApella} from "@aztec/governance/interfaces/IApella.sol"; +import {Timestamp} from "@aztec/core/libraries/TimeMath.sol"; +import {Errors} from "@aztec/governance/libraries/Errors.sol"; +import {DataStructures} from "@aztec/governance/libraries/DataStructures.sol"; +import {ConfigurationLib} from "@aztec/governance/libraries/ConfigurationLib.sol"; +import {Timestamp} from "@aztec/core/libraries/TimeMath.sol"; + +contract UpdateConfigurationTest is ApellaBase { + using ConfigurationLib for DataStructures.Configuration; + + DataStructures.Configuration internal config; + + // Doing this as we are using a lib that works on storage + DataStructures.Configuration internal fresh; + + function setUp() public override(ApellaBase) { + super.setUp(); + config = apella.getConfiguration(); + } + + function test_WhenCallerIsNotSelf() external { + // it revert + vm.expectRevert( + abi.encodeWithSelector(Errors.Apella__CallerNotSelf.selector, address(this), address(apella)) + ); + apella.updateConfiguration(config); + } + + modifier whenCallerIsSelf() { + _; + } + + modifier whenConfigurationIsInvalid() { + _; + } + + function test_WhenQuorumLtMinOrGtMax(uint256 _val) + external + whenCallerIsSelf + whenConfigurationIsInvalid + { + // it revert + config.quorum = bound(_val, 0, ConfigurationLib.QUORUM_LOWER - 1); + vm.expectRevert( + abi.encodeWithSelector(Errors.Apella__ConfigurationLib__QuorumTooSmall.selector) + ); + + vm.prank(address(apella)); + apella.updateConfiguration(config); + + config.quorum = bound(_val, ConfigurationLib.QUORUM_UPPER + 1, type(uint256).max); + vm.expectRevert(abi.encodeWithSelector(Errors.Apella__ConfigurationLib__QuorumTooBig.selector)); + + vm.prank(address(apella)); + apella.updateConfiguration(config); + } + + function test_WhenDifferentialLtMinOrGtMax(uint256 _val) + external + whenCallerIsSelf + whenConfigurationIsInvalid + { + // it revert + config.voteDifferential = + bound(_val, ConfigurationLib.DIFFERENTIAL_UPPER + 1, type(uint256).max); + vm.expectRevert( + abi.encodeWithSelector(Errors.Apella__ConfigurationLib__DifferentialTooBig.selector) + ); + + vm.prank(address(apella)); + apella.updateConfiguration(config); + } + + function test_WhenMinimumVotesLtMin(uint256 _val) + external + whenCallerIsSelf + whenConfigurationIsInvalid + { + // it revert + config.minimumVotes = bound(_val, 0, ConfigurationLib.VOTES_LOWER - 1); + vm.expectRevert( + abi.encodeWithSelector(Errors.Apella__ConfigurationLib__InvalidMinimumVotes.selector) + ); + + vm.prank(address(apella)); + apella.updateConfiguration(config); + } + + function test_WhenVotingDelayLtMinOrGtMax(uint256 _val) + external + whenCallerIsSelf + whenConfigurationIsInvalid + { + // it revert + + config.votingDelay = + Timestamp.wrap(bound(_val, 0, Timestamp.unwrap(ConfigurationLib.TIME_LOWER) - 1)); + vm.expectRevert( + abi.encodeWithSelector(Errors.Apella__ConfigurationLib__TimeTooSmall.selector, "VotingDelay") + ); + vm.prank(address(apella)); + apella.updateConfiguration(config); + + config.votingDelay = Timestamp.wrap( + bound(_val, Timestamp.unwrap(ConfigurationLib.TIME_UPPER) + 1, type(uint256).max) + ); + vm.expectRevert( + abi.encodeWithSelector(Errors.Apella__ConfigurationLib__TimeTooBig.selector, "VotingDelay") + ); + vm.prank(address(apella)); + apella.updateConfiguration(config); + } + + function test_WhenVotingDurationLtMinOrGtMax(uint256 _val) + external + whenCallerIsSelf + whenConfigurationIsInvalid + { + // it revert + + config.votingDuration = + Timestamp.wrap(bound(_val, 0, Timestamp.unwrap(ConfigurationLib.TIME_LOWER) - 1)); + vm.expectRevert( + abi.encodeWithSelector( + Errors.Apella__ConfigurationLib__TimeTooSmall.selector, "VotingDuration" + ) + ); + vm.prank(address(apella)); + apella.updateConfiguration(config); + + config.votingDuration = Timestamp.wrap( + bound(_val, Timestamp.unwrap(ConfigurationLib.TIME_UPPER) + 1, type(uint256).max) + ); + vm.expectRevert( + abi.encodeWithSelector(Errors.Apella__ConfigurationLib__TimeTooBig.selector, "VotingDuration") + ); + vm.prank(address(apella)); + apella.updateConfiguration(config); + } + + function test_WhenExecutionDelayLtMinOrGtMax(uint256 _val) + external + whenCallerIsSelf + whenConfigurationIsInvalid + { + // it revert + + config.executionDelay = + Timestamp.wrap(bound(_val, 0, Timestamp.unwrap(ConfigurationLib.TIME_LOWER) - 1)); + vm.expectRevert( + abi.encodeWithSelector( + Errors.Apella__ConfigurationLib__TimeTooSmall.selector, "ExecutionDelay" + ) + ); + vm.prank(address(apella)); + apella.updateConfiguration(config); + + config.executionDelay = Timestamp.wrap( + bound(_val, Timestamp.unwrap(ConfigurationLib.TIME_UPPER) + 1, type(uint256).max) + ); + vm.expectRevert( + abi.encodeWithSelector(Errors.Apella__ConfigurationLib__TimeTooBig.selector, "ExecutionDelay") + ); + vm.prank(address(apella)); + apella.updateConfiguration(config); + } + + function test_WhenGracePeriodLtMinOrGtMax(uint256 _val) + external + whenCallerIsSelf + whenConfigurationIsInvalid + { + // it revert + + config.gracePeriod = + Timestamp.wrap(bound(_val, 0, Timestamp.unwrap(ConfigurationLib.TIME_LOWER) - 1)); + vm.expectRevert( + abi.encodeWithSelector(Errors.Apella__ConfigurationLib__TimeTooSmall.selector, "GracePeriod") + ); + vm.prank(address(apella)); + apella.updateConfiguration(config); + + config.gracePeriod = Timestamp.wrap( + bound(_val, Timestamp.unwrap(ConfigurationLib.TIME_UPPER) + 1, type(uint256).max) + ); + vm.expectRevert( + abi.encodeWithSelector(Errors.Apella__ConfigurationLib__TimeTooBig.selector, "GracePeriod") + ); + vm.prank(address(apella)); + apella.updateConfiguration(config); + } + + modifier whenConfigurationIsValid() { + // the local `config` will be modified throughout the execution + // We check that it matches the what is seen on chain afterwards + DataStructures.Configuration memory old = apella.getConfiguration(); + + _; + + vm.expectEmit(true, true, true, true, address(apella)); + emit IApella.ConfigurationUpdated(Timestamp.wrap(block.timestamp)); + vm.prank(address(apella)); + apella.updateConfiguration(config); + + fresh = apella.getConfiguration(); + + assertEq(config.executionDelay, fresh.executionDelay); + assertEq(config.gracePeriod, fresh.gracePeriod); + assertEq(config.minimumVotes, fresh.minimumVotes); + assertEq(config.quorum, fresh.quorum); + assertEq(config.voteDifferential, fresh.voteDifferential); + assertEq(config.votingDelay, fresh.votingDelay); + assertEq(config.votingDuration, fresh.votingDuration); + + assertEq(config.lockDelay(), fresh.lockDelay()); + assertEq( + config.lockDelay(), + Timestamp.wrap(Timestamp.unwrap(fresh.votingDelay) / 5) + fresh.votingDuration + + fresh.executionDelay + ); + + // Ensure that there is a difference between the two + assertFalse( + old.executionDelay == fresh.executionDelay && old.gracePeriod == fresh.gracePeriod + && old.minimumVotes == fresh.minimumVotes && old.quorum == fresh.quorum + && old.voteDifferential == fresh.voteDifferential && old.votingDelay == fresh.votingDelay + && old.votingDuration == fresh.votingDuration + ); + } + + function test_WhenQuorumGeMinAndLeMax(uint256 _val) + external + whenCallerIsSelf + whenConfigurationIsValid + { + // it updates the configuration + // it emits {ConfigurationUpdated} event + + uint256 val = bound(_val, ConfigurationLib.QUORUM_LOWER, ConfigurationLib.QUORUM_UPPER); + + vm.assume(val != config.quorum); + config.quorum = val; + } + + function test_WhenDifferentialGeMinAndLeMax(uint256 _val) + external + whenCallerIsSelf + whenConfigurationIsValid + { + // it updates the configuration + // it emits {ConfigurationUpdated} event + + uint256 val = bound(_val, 0, ConfigurationLib.DIFFERENTIAL_UPPER); + + vm.assume(val != config.voteDifferential); + config.voteDifferential = val; + } + + function test_WhenMinimumVotesGeMin(uint256 _val) + external + whenCallerIsSelf + whenConfigurationIsValid + { + // it updates the configuration + // it emits {ConfigurationUpdated} event + + uint256 val = bound(_val, ConfigurationLib.VOTES_LOWER, type(uint256).max); + + vm.assume(val != config.minimumVotes); + config.minimumVotes = val; + } + + function test_WhenVotingDelayGeMinAndLeMax(uint256 _val) + external + whenCallerIsSelf + whenConfigurationIsValid + { + // it updates the configuration + // it emits {ConfigurationUpdated} event + Timestamp val = Timestamp.wrap( + bound( + _val, + Timestamp.unwrap(ConfigurationLib.TIME_LOWER), + Timestamp.unwrap(ConfigurationLib.TIME_UPPER) + ) + ); + + vm.assume(val != config.votingDelay); + config.votingDelay = val; + } + + function test_WhenVotingDurationGeMinAndLeMax(uint256 _val) + external + whenCallerIsSelf + whenConfigurationIsValid + { + // it updates the configuration + // it emits {ConfigurationUpdated} event + Timestamp val = Timestamp.wrap( + bound( + _val, + Timestamp.unwrap(ConfigurationLib.TIME_LOWER), + Timestamp.unwrap(ConfigurationLib.TIME_UPPER) + ) + ); + + vm.assume(val != config.votingDuration); + config.votingDuration = val; + } + + function test_WhenExecutionDelayGeMinAndLeMax(uint256 _val) + external + whenCallerIsSelf + whenConfigurationIsValid + { + // it updates the configuration + // it emits {ConfigurationUpdated} event + + Timestamp val = Timestamp.wrap( + bound( + _val, + Timestamp.unwrap(ConfigurationLib.TIME_LOWER), + Timestamp.unwrap(ConfigurationLib.TIME_UPPER) + ) + ); + + vm.assume(val != config.executionDelay); + config.executionDelay = val; + } + + function test_WhenGracePeriodGeMinAndLeMax(uint256 _val) + external + whenCallerIsSelf + whenConfigurationIsValid + { + // it updates the configuration + // it emits {ConfigurationUpdated} event + + Timestamp val = Timestamp.wrap( + bound( + _val, + Timestamp.unwrap(ConfigurationLib.TIME_LOWER), + Timestamp.unwrap(ConfigurationLib.TIME_UPPER) + ) + ); + + vm.assume(val != config.gracePeriod); + config.gracePeriod = val; + } +} diff --git a/l1-contracts/test/governance/apella/updateConfiguration.tree b/l1-contracts/test/governance/apella/updateConfiguration.tree new file mode 100644 index 00000000000..c453d22712c --- /dev/null +++ b/l1-contracts/test/governance/apella/updateConfiguration.tree @@ -0,0 +1,41 @@ +UpdateConfigurationTest +├── when caller is not self +│ └── it revert +└── when caller is self + ├── when configuration is invalid + │ ├── when quorum lt min or gt max + │ │ └── it revert + │ ├── when differential lt min or gt max + │ │ └── it revert + │ ├── when minimumVotes lt min + │ │ └── it revert + │ ├── when votingDelay lt min or gt max + │ │ └── it revert + │ ├── when votingDuration lt min or gt max + │ │ └── it revert + │ ├── when executionDelay lt min or gt max + │ │ └── it revert + │ └── when gracePeriod lt min or gt max + │ └── it revert + └── when configuration is valid + ├── when quorum ge min and le max + │ ├── it updates the configuration + │ └── it emits {ConfigurationUpdated} event + ├── when differential ge min and le max + │ ├── it updates the configuration + │ └── it emits {ConfigurationUpdated} event + ├── when minimumVotes ge min + │ ├── it updates the configuration + │ └── it emits {ConfigurationUpdated} event + ├── when votingDelay ge min and le max + │ ├── it updates the configuration + │ └── it emits {ConfigurationUpdated} event + ├── when votingDuration ge min and le max + │ ├── it updates the configuration + │ └── it emits {ConfigurationUpdated} event + ├── when executionDelay ge min and le max + │ ├── it updates the configuration + │ └── it emits {ConfigurationUpdated} event + └── when gracePeriod ge min and le max + ├── it updates the configuration + └── it emits {ConfigurationUpdated} event \ No newline at end of file diff --git a/l1-contracts/test/governance/apella/updateGerousia.t.sol b/l1-contracts/test/governance/apella/updateGerousia.t.sol new file mode 100644 index 00000000000..1322eb15fbd --- /dev/null +++ b/l1-contracts/test/governance/apella/updateGerousia.t.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {ApellaBase} from "./base.t.sol"; +import {Errors} from "@aztec/governance/libraries/Errors.sol"; +import {IApella} from "@aztec/governance/interfaces/IApella.sol"; + +contract UpdateGerousiaTest is ApellaBase { + function test_WhenCallerIsNotApella(address _caller, address _gerousia) external { + // it revert + vm.assume(_caller != address(apella)); + vm.expectRevert( + abi.encodeWithSelector(Errors.Apella__CallerNotSelf.selector, _caller, address(apella)) + ); + vm.prank(_caller); + apella.updateGerousia(_gerousia); + } + + function test_WhenCallerIsApella(address _gerousia) external { + // it updates the gerousia + // it emit the {GerousiaUpdated} event + + vm.assume(_gerousia != address(apella.gerousia())); + + vm.expectEmit(true, true, true, true, address(apella)); + emit IApella.GerousiaUpdated(_gerousia); + + vm.prank(address(apella)); + apella.updateGerousia(_gerousia); + + assertEq(_gerousia, apella.gerousia()); + } +} diff --git a/l1-contracts/test/governance/apella/updateGerousia.tree b/l1-contracts/test/governance/apella/updateGerousia.tree new file mode 100644 index 00000000000..d2fd3dac622 --- /dev/null +++ b/l1-contracts/test/governance/apella/updateGerousia.tree @@ -0,0 +1,6 @@ +UpdateGerousiaTest +├── when caller is not apella +│ └── it revert +└── when caller is apella + ├── it updates the gerousia + └── it emit the {GerousiaUpdated} event \ No newline at end of file diff --git a/l1-contracts/test/governance/apella/userlib/add.t.sol b/l1-contracts/test/governance/apella/userlib/add.t.sol new file mode 100644 index 00000000000..f824a9855c4 --- /dev/null +++ b/l1-contracts/test/governance/apella/userlib/add.t.sol @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {UserLibBase} from "./base.t.sol"; +import {DataStructures} from "@aztec/governance/libraries/DataStructures.sol"; +import {UserLib} from "@aztec/governance/libraries/UserLib.sol"; +import {Timestamp} from "@aztec/core/libraries/TimeMath.sol"; + +contract AddTest is UserLibBase { + using UserLib for DataStructures.User; + + function test_WhenAmountEq0() external { + // it return instantly with no changes + + assertEq(user.numCheckPoints, 0); + + vm.record(); + user.add(0); + (bytes32[] memory reads, bytes32[] memory writes) = vm.accesses(address(this)); + + assertEq(user.numCheckPoints, 0); + assertEq(reads.length, 0); + assertEq(writes.length, 0); + } + + function test_GivenUserHaveNoCheckpoints(uint256 _amount, uint256 _time) + external + whenAmountGt0(_amount) + { + // it adds checkpoint with amount + // it increases num checkpoints + + assertEq(user.numCheckPoints, 0); + + vm.warp(_time); + user.add(amount); + + assertEq(user.numCheckPoints, 1); + assertEq(user.checkpoints[0].time, Timestamp.wrap(_time)); + assertEq(user.checkpoints[0].power, amount); + } + + function test_WhenLastCheckpointIsNow( + uint256 _amount, + bool[CHECKPOINT_COUNT] memory _insert, + uint8[CHECKPOINT_COUNT] memory _timeBetween, + uint16[CHECKPOINT_COUNT] memory _amounts + ) external whenAmountGt0(_amount) givenUserHaveCheckpoints(_insert, _timeBetween, _amounts) { + // it increases power by amount + + assertEq(user.numCheckPoints, insertions, "num checkpoints"); + // Cache in memory + DataStructures.CheckPoint memory last = user.checkpoints[user.numCheckPoints - 1]; + + user.add(amount); + + assertEq(user.numCheckPoints, insertions, "num checkpoints"); + DataStructures.CheckPoint memory last2 = user.checkpoints[user.numCheckPoints - 1]; + + assertEq(last2.time, last.time, "time"); + assertEq(last2.power, last.power + amount, "power"); + } + + function test_WhenLastCheckpointInPast( + uint256 _amount, + bool[CHECKPOINT_COUNT] memory _insert, + uint8[CHECKPOINT_COUNT] memory _timeBetween, + uint16[CHECKPOINT_COUNT] memory _amounts, + uint256 _time + ) external whenAmountGt0(_amount) givenUserHaveCheckpoints(_insert, _timeBetween, _amounts) { + // it adds a checkpoint with power eq to last.power + amount + // it increases num checkpoints + + uint256 time = bound(_time, 1, type(uint32).max); + + assertEq(user.numCheckPoints, insertions); + // Cache in memory + DataStructures.CheckPoint memory last = user.checkpoints[user.numCheckPoints - 1]; + + vm.warp(block.timestamp + time); + user.add(amount); + + assertEq(user.numCheckPoints, insertions + 1); + DataStructures.CheckPoint memory last2 = user.checkpoints[user.numCheckPoints - 1]; + + assertEq(last2.time, last.time + Timestamp.wrap(time)); + assertEq(last2.power, last.power + amount); + } +} diff --git a/l1-contracts/test/governance/apella/userlib/add.tree b/l1-contracts/test/governance/apella/userlib/add.tree new file mode 100644 index 00000000000..fb0c5facf28 --- /dev/null +++ b/l1-contracts/test/governance/apella/userlib/add.tree @@ -0,0 +1,13 @@ +AddTest +├── when amount eq 0 +│ └── it return instantly with no changes +└── when amount gt 0 + ├── given user have no checkpoints + │ ├── it adds checkpoint with amount + │ └── it increases num checkpoints + └── given user have checkpoints + ├── when last checkpoint is "now" + │ └── it increases power by amount + └── when last checkpoint in past + ├── it adds a checkpoint with power eq to last.power + amount + └── it increases num checkpoints \ No newline at end of file diff --git a/l1-contracts/test/governance/apella/userlib/base.t.sol b/l1-contracts/test/governance/apella/userlib/base.t.sol new file mode 100644 index 00000000000..f607fd52075 --- /dev/null +++ b/l1-contracts/test/governance/apella/userlib/base.t.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {TestBase} from "@test/base/Base.sol"; +import {DataStructures} from "@aztec/governance/libraries/DataStructures.sol"; +import {UserLib} from "@aztec/governance/libraries/UserLib.sol"; +import {Timestamp} from "@aztec/core/libraries/TimeMath.sol"; + +contract UserLibBase is TestBase { + using UserLib for DataStructures.User; + + uint256 internal constant CHECKPOINT_COUNT = 8; + + DataStructures.User internal user; + + uint256 internal amount; + uint256 internal sumBefore; + uint256 internal insertions; + + modifier whenAmountGt0(uint256 _amount) { + amount = bound(_amount, 1, type(uint128).max); + _; + } + + modifier givenUserHaveCheckpoints( + bool[CHECKPOINT_COUNT] memory _insert, + uint8[CHECKPOINT_COUNT] memory _timeBetween, + uint16[CHECKPOINT_COUNT] memory _amounts + ) { + for (uint256 i = 0; i < CHECKPOINT_COUNT; i++) { + if (_insert[i] || (i > CHECKPOINT_COUNT / 2 && insertions == 0)) { + vm.warp(block.timestamp + bound(_timeBetween[i], 1, type(uint16).max)); + uint256 p = bound(_amounts[i], 1, type(uint16).max); + user.add(p); + sumBefore += p; + insertions += 1; + } + } + + _; + } +} diff --git a/l1-contracts/test/governance/apella/userlib/powerAt.t.sol b/l1-contracts/test/governance/apella/userlib/powerAt.t.sol new file mode 100644 index 00000000000..64eacf8cfd3 --- /dev/null +++ b/l1-contracts/test/governance/apella/userlib/powerAt.t.sol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {UserLibBase} from "./base.t.sol"; +import {DataStructures} from "@aztec/governance/libraries/DataStructures.sol"; +import {UserLib} from "@aztec/governance/libraries/UserLib.sol"; +import {Timestamp} from "@aztec/core/libraries/TimeMath.sol"; +import {Errors} from "@aztec/governance/libraries/Errors.sol"; + +contract PowerAtTest is UserLibBase { + using UserLib for DataStructures.User; + + Timestamp internal time; + + function test_WhenTimeNotInPast() external { + // it revert + vm.expectRevert(abi.encodeWithSelector(Errors.Apella__UserLib__NotInPast.selector)); + user.powerAt(Timestamp.wrap(block.timestamp)); + } + + modifier whenTimeInPast() { + time = Timestamp.wrap(1e6); + vm.warp(Timestamp.unwrap(time) + 12); + + _; + } + + function test_GivenNoCheckpoints() external whenTimeInPast { + // it return 0 + assertEq(user.powerAt(time), 0); + } + + function test_WhenTimeLtFirstCheckpoint( + bool[CHECKPOINT_COUNT] memory _insert, + uint8[CHECKPOINT_COUNT] memory _timeBetween, + uint16[CHECKPOINT_COUNT] memory _amounts + ) external whenTimeInPast givenUserHaveCheckpoints(_insert, _timeBetween, _amounts) { + // it return 0 + + assertEq(user.powerAt(time), 0, "non-zero power"); + assertGt(user.powerNow(), 0, "insufficient power"); + } + + function test_WhenTimeGeFirstCheckpoint( + bool[CHECKPOINT_COUNT] memory _insert, + uint8[CHECKPOINT_COUNT] memory _timeBetween, + uint16[CHECKPOINT_COUNT] memory _amounts, + uint256 _index, + bool _between + ) external whenTimeInPast givenUserHaveCheckpoints(_insert, _timeBetween, _amounts) { + // it return power at last checkpoint with time < time + + if (insertions == 1) { + vm.warp(block.timestamp + 12); + user.add(1); + } + + vm.warp(block.timestamp + 12); + + uint256 index = bound(_index, 0, user.numCheckPoints - (_between ? 2 : 1)); + + DataStructures.CheckPoint memory first = user.checkpoints[index]; + + if (_between) { + DataStructures.CheckPoint memory second = user.checkpoints[index + 1]; + time = first.time + Timestamp.wrap(Timestamp.unwrap(second.time - first.time) / 2); + } else { + if (index == user.numCheckPoints && _index % 2 == 0) { + time = Timestamp.wrap(block.timestamp - 12); + } else { + time = first.time; + } + } + + assertEq(user.powerAt(time), first.power); + assertGt(first.power, 0); + } +} diff --git a/l1-contracts/test/governance/apella/userlib/powerAt.tree b/l1-contracts/test/governance/apella/userlib/powerAt.tree new file mode 100644 index 00000000000..42276c6dac6 --- /dev/null +++ b/l1-contracts/test/governance/apella/userlib/powerAt.tree @@ -0,0 +1,11 @@ +PowerAtTest +├── when time not in past +│ └── it revert +└── when time in past + ├── given no checkpoints + │ └── it return 0 + └── given user have checkpoints + ├── when time lt first checkpoint + │ └── it return 0 + └── when time ge first checkpoint + └── it return power at last checkpoint with time < time \ No newline at end of file diff --git a/l1-contracts/test/governance/apella/userlib/powerNow.t.sol b/l1-contracts/test/governance/apella/userlib/powerNow.t.sol new file mode 100644 index 00000000000..8ac7ca278ee --- /dev/null +++ b/l1-contracts/test/governance/apella/userlib/powerNow.t.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {UserLibBase} from "./base.t.sol"; +import {DataStructures} from "@aztec/governance/libraries/DataStructures.sol"; +import {UserLib} from "@aztec/governance/libraries/UserLib.sol"; +import {Timestamp} from "@aztec/core/libraries/TimeMath.sol"; +import {Errors} from "@aztec/governance/libraries/Errors.sol"; + +contract PowerNowTest is UserLibBase { + using UserLib for DataStructures.User; + + Timestamp internal time; + + function test_GivenNoCheckpoints(uint256 _time) external { + // it return 0 + vm.warp(_time); + assertEq(user.powerNow(), 0); + } + + function test_GivenCheckpoints( + bool[CHECKPOINT_COUNT] memory _insert, + uint8[CHECKPOINT_COUNT] memory _timeBetween, + uint16[CHECKPOINT_COUNT] memory _amounts, + uint32 _timeJump + ) external givenUserHaveCheckpoints(_insert, _timeBetween, _amounts) { + // it return power at last checkpoint + + vm.warp(block.timestamp + _timeJump); + + assertEq(user.powerNow(), user.checkpoints[user.numCheckPoints - 1].power); + } +} diff --git a/l1-contracts/test/governance/apella/userlib/powerNow.tree b/l1-contracts/test/governance/apella/userlib/powerNow.tree new file mode 100644 index 00000000000..76af7f8761b --- /dev/null +++ b/l1-contracts/test/governance/apella/userlib/powerNow.tree @@ -0,0 +1,5 @@ +PowerNowTest +├── given no checkpoints +│ └── it return 0 +└── given checkpoints + └── it return power at last checkpoint \ No newline at end of file diff --git a/l1-contracts/test/governance/apella/userlib/sub.t.sol b/l1-contracts/test/governance/apella/userlib/sub.t.sol new file mode 100644 index 00000000000..438edf91da9 --- /dev/null +++ b/l1-contracts/test/governance/apella/userlib/sub.t.sol @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {UserLibBase} from "./base.t.sol"; +import {DataStructures} from "@aztec/governance/libraries/DataStructures.sol"; +import {UserLib} from "@aztec/governance/libraries/UserLib.sol"; +import {Timestamp} from "@aztec/core/libraries/TimeMath.sol"; +import {Errors} from "@aztec/governance/libraries/Errors.sol"; + +contract SubTest is UserLibBase { + using UserLib for DataStructures.User; + + function test_WhenAmountEq0() external { + // it return instantly with no changes + + assertEq(user.numCheckPoints, 0); + + vm.record(); + user.sub(0); + (bytes32[] memory reads, bytes32[] memory writes) = vm.accesses(address(this)); + + assertEq(user.numCheckPoints, 0); + assertEq(reads.length, 0); + assertEq(writes.length, 0); + } + + function test_GivenUserHaveNoCheckpoints(uint256 _amount) external whenAmountGt0(_amount) { + // it revert + vm.expectRevert(abi.encodeWithSelector(Errors.Apella__NoCheckpointsFound.selector)); + user.sub(amount); + } + + function test_WhenAmountIsMoreThanLastCheckpoint( + uint256 _amount, + bool[CHECKPOINT_COUNT] memory _insert, + uint8[CHECKPOINT_COUNT] memory _timeBetween, + uint16[CHECKPOINT_COUNT] memory _amounts + ) external whenAmountGt0(_amount) givenUserHaveCheckpoints(_insert, _timeBetween, _amounts) { + // it reverts + + amount = bound(amount, sumBefore + 1, type(uint256).max); + + vm.expectRevert( + abi.encodeWithSelector( + Errors.Apella__InsufficientPower.selector, msg.sender, sumBefore, amount + ) + ); + user.sub(amount); + } + + modifier whenAmountIsLessOrEqualToLastCheckpoint(uint256 _amount) { + amount = bound(_amount, 1, sumBefore); + + _; + } + + function test_WhenLastCheckpointIsNow( + uint256 _amount, + bool[CHECKPOINT_COUNT] memory _insert, + uint8[CHECKPOINT_COUNT] memory _timeBetween, + uint16[CHECKPOINT_COUNT] memory _amounts + ) + external + whenAmountGt0(_amount) + givenUserHaveCheckpoints(_insert, _timeBetween, _amounts) + whenAmountIsLessOrEqualToLastCheckpoint(_amount) + { + // it decreases power by amount + + assertEq(user.numCheckPoints, insertions, "num checkpoints"); + // Cache in memory + DataStructures.CheckPoint memory last = user.checkpoints[user.numCheckPoints - 1]; + + user.sub(amount); + + assertEq(user.numCheckPoints, insertions, "num checkpoints"); + DataStructures.CheckPoint memory last2 = user.checkpoints[user.numCheckPoints - 1]; + + assertEq(last2.time, last.time, "time"); + assertEq(last2.power, last.power - amount, "power"); + } + + function test_WhenLastCheckpointInPast( + uint256 _amount, + bool[CHECKPOINT_COUNT] memory _insert, + uint8[CHECKPOINT_COUNT] memory _timeBetween, + uint16[CHECKPOINT_COUNT] memory _amounts, + uint256 _time + ) + external + whenAmountGt0(_amount) + givenUserHaveCheckpoints(_insert, _timeBetween, _amounts) + whenAmountIsLessOrEqualToLastCheckpoint(_amount) + { + // it adds a checkpoint with power equal to last.power - amount + // it increases num checkpoints + + uint256 time = bound(_time, 1, type(uint32).max); + + assertEq(user.numCheckPoints, insertions); + // Cache in memory + DataStructures.CheckPoint memory last = user.checkpoints[user.numCheckPoints - 1]; + + vm.warp(block.timestamp + time); + user.sub(amount); + + assertEq(user.numCheckPoints, insertions + 1); + DataStructures.CheckPoint memory last2 = user.checkpoints[user.numCheckPoints - 1]; + + assertEq(last2.time, last.time + Timestamp.wrap(time)); + assertEq(last2.power, last.power - amount); + } +} diff --git a/l1-contracts/test/governance/apella/userlib/sub.tree b/l1-contracts/test/governance/apella/userlib/sub.tree new file mode 100644 index 00000000000..f88ace6e40c --- /dev/null +++ b/l1-contracts/test/governance/apella/userlib/sub.tree @@ -0,0 +1,15 @@ +SubTest +├── when amount eq 0 +│ └── it return instantly with no changes +└── when amount gt 0 + ├── given user have no checkpoints + │ └── it revert + └── given user have checkpoints + ├── when amount is more than last checkpoint + │ └── it reverts + └── when amount is less or equal to last checkpoint + ├── when last checkpoint is "now" + │ └── it decreases power by amount + └── when last checkpoint in past + ├── it adds a checkpoint with power equal to last.power - amount + └── it increases num checkpoints \ No newline at end of file diff --git a/l1-contracts/test/governance/apella/vote.t.sol b/l1-contracts/test/governance/apella/vote.t.sol new file mode 100644 index 00000000000..fbc24cd53db --- /dev/null +++ b/l1-contracts/test/governance/apella/vote.t.sol @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {IPayload} from "@aztec/governance/interfaces/IPayload.sol"; +import {ApellaBase} from "./base.t.sol"; +import {Errors} from "@aztec/governance/libraries/Errors.sol"; +import {DataStructures} from "@aztec/governance/libraries/DataStructures.sol"; +import {ProposalLib} from "@aztec/governance/libraries/ProposalLib.sol"; +import {Timestamp} from "@aztec/core/libraries/TimeMath.sol"; +import {IApella} from "@aztec/governance/interfaces/IApella.sol"; + +contract VoteTest is ApellaBase { + using ProposalLib for DataStructures.Proposal; + + uint256 internal depositPower; + + function setUp() public override(ApellaBase) { + super.setUp(); + + proposal = proposals["empty"]; + proposalId = proposalIds["empty"]; + } + + modifier givenStateIsNotActive(address _voter, uint256 _amount, bool _support) { + _; + vm.prank(_voter); + vm.expectRevert(abi.encodeWithSelector(Errors.Apella__ProposalNotActive.selector)); + apella.vote(proposalId, _amount, _support); + } + + function test_GivenStateIsPending(address _voter, uint256 _amount, bool _support) + external + givenStateIsNotActive(_voter, _amount, _support) + { + // it revert + _statePending("empty"); + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Pending); + } + + function test_GivenStateIsQueued( + address _voter, + uint256 _amount, + bool _support, + uint256 _totalPower, + uint256 _votesCast, + uint256 _yeas + ) external givenStateIsNotActive(_voter, _amount, _support) { + // it revert + _stateQueued("empty", _voter, _totalPower, _votesCast, _yeas); + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Queued); + } + + function test_GivenStateIsExecutable( + address _voter, + uint256 _amount, + bool _support, + uint256 _totalPower, + uint256 _votesCast, + uint256 _yeas + ) external givenStateIsNotActive(_voter, _amount, _support) { + // it revert + _stateExecutable("empty", _voter, _totalPower, _votesCast, _yeas); + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Executable); + } + + function test_GivenStateIsRejected(address _voter, uint256 _amount, bool _support) + external + givenStateIsNotActive(_voter, _amount, _support) + { + // it revert + _stateRejected("empty"); + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Rejected); + } + + function test_GivenStateIsDropped( + address _voter, + uint256 _amount, + bool _support, + address _gerousia + ) external givenStateIsNotActive(_voter, _amount, _support) { + // it revert + _stateDropped("empty", _gerousia); + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Dropped); + } + + function test_GivenStateIsExpired( + address _voter, + uint256 _amount, + bool _support, + uint256 _totalPower, + uint256 _votesCast, + uint256 _yeas + ) external givenStateIsNotActive(_voter, _amount, _support) { + // it revert + _stateExpired("empty", _voter, _totalPower, _votesCast, _yeas); + assertEq(apella.getProposalState(proposalId), DataStructures.ProposalState.Expired); + } + + modifier givenStateIsActive(address _voter, uint256 _depositPower) { + vm.assume(_voter != address(0)); + depositPower = bound(_depositPower, 1, type(uint128).max); + + token.mint(_voter, depositPower); + vm.startPrank(_voter); + token.approve(address(apella), depositPower); + apella.deposit(_voter, depositPower); + vm.stopPrank(); + + assertEq(token.balanceOf(address(apella)), depositPower); + assertEq(apella.powerAt(_voter, Timestamp.wrap(block.timestamp)), depositPower); + + _stateActive("empty"); + + _; + } + + function test_GivenAmountLargerThanAvailablePower( + address _voter, + uint256 _depositPower, + uint256 _votePower, + bool _support + ) external givenStateIsActive(_voter, _depositPower) { + // it revert + + uint256 power = bound(_votePower, depositPower + 1, type(uint256).max); + + vm.expectRevert( + abi.encodeWithSelector(Errors.Apella__InsufficientPower.selector, _voter, depositPower, power) + ); + vm.prank(_voter); + apella.vote(proposalId, power, _support); + } + + function test_GivenAmountSmallerOrEqAvailablePower( + address _voter, + uint256 _depositPower, + uint256 _votePower, + bool _support + ) external givenStateIsActive(_voter, _depositPower) { + // it increase yea or nea on user ballot by amount + // it increase yea or nea on total by amount + // it emits {VoteCast} event + // it returns true + + uint256 power = bound(_votePower, 1, depositPower); + + vm.expectEmit(true, true, true, true, address(apella)); + emit IApella.VoteCast(proposalId, _voter, _support, power); + vm.prank(_voter); + apella.vote(proposalId, power, _support); + + DataStructures.Proposal memory fresh = apella.getProposal(proposalId); + + assertEq(proposal.config.executionDelay, fresh.config.executionDelay, "executionDelay"); + assertEq(proposal.config.gracePeriod, fresh.config.gracePeriod, "gracePeriod"); + assertEq(proposal.config.minimumVotes, fresh.config.minimumVotes, "minimumVotes"); + assertEq(proposal.config.quorum, fresh.config.quorum, "quorum"); + assertEq(proposal.config.voteDifferential, fresh.config.voteDifferential, "voteDifferential"); + assertEq(proposal.config.votingDelay, fresh.config.votingDelay, "votingDelay"); + assertEq(proposal.config.votingDuration, fresh.config.votingDuration, "votingDuration"); + assertEq(proposal.creation, fresh.creation, "creation"); + assertEq(proposal.creator, fresh.creator, "creator"); + assertEq(proposal.summedBallot.nea + (_support ? 0 : power), fresh.summedBallot.nea, "nea"); + assertEq(proposal.summedBallot.yea + (_support ? power : 0), fresh.summedBallot.yea, "yea"); + // The "written" state is still the same. + assertTrue(proposal.state == fresh.state, "state"); + } +} diff --git a/l1-contracts/test/governance/apella/vote.tree b/l1-contracts/test/governance/apella/vote.tree new file mode 100644 index 00000000000..deecfed732d --- /dev/null +++ b/l1-contracts/test/governance/apella/vote.tree @@ -0,0 +1,22 @@ +VoteTest +├── given state is not active +│ ├── given state is pending +│ │ └── it revert +│ ├── given state is queued +│ │ └── it revert +│ ├── given state is executable +│ │ └── it revert +│ ├── given state is rejected +│ │ └── it revert +│ ├── given state is dropped +│ │ └── it revert +│ └── given state is expired +│ └── it revert +└── given state is active + ├── given amount larger than available power + │ └── it revert + └── given amount smaller or eq available power + ├── it increase yea or nea on user ballot by amount + ├── it increase yea or nea on total by amount + ├── it emits {VoteCast} event + └── it returns true \ No newline at end of file diff --git a/l1-contracts/test/governance/gerousia/Base.t.sol b/l1-contracts/test/governance/gerousia/Base.t.sol index a13484f1f8e..8b3db4eb546 100644 --- a/l1-contracts/test/governance/gerousia/Base.t.sol +++ b/l1-contracts/test/governance/gerousia/Base.t.sol @@ -6,18 +6,18 @@ 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"; +import {IPayload} from "@aztec/governance/interfaces/IPayload.sol"; -contract FakeApella is IApella { - address public gerousia; +contract FakeApella { + address immutable GEROUSIA; - mapping(address => bool) public proposals; + mapping(IPayload => bool) public proposals; - function setGerousia(address _gerousia) external { - gerousia = _gerousia; + constructor(address _gerousia) { + GEROUSIA = _gerousia; } - function propose(address _proposal) external override(IApella) returns (bool) { + function propose(IPayload _proposal) external returns (bool) { proposals[_proposal] = true; return true; } @@ -30,10 +30,10 @@ contract GerousiaBase is Test { function setUp() public virtual { registry = new Registry(address(this)); - apella = new FakeApella(); - gerousia = new Gerousia(apella, registry, 667, 1000); + gerousia = new Gerousia(registry, 667, 1000); + apella = new FakeApella(address(gerousia)); - apella.setGerousia(address(gerousia)); + registry.transferOwnership(address(apella)); } } diff --git a/l1-contracts/test/governance/gerousia/constructor.t.sol b/l1-contracts/test/governance/gerousia/constructor.t.sol index e3fb6c87b01..47746bde059 100644 --- a/l1-contracts/test/governance/gerousia/constructor.t.sol +++ b/l1-contracts/test/governance/gerousia/constructor.t.sol @@ -5,10 +5,8 @@ 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 { @@ -17,7 +15,7 @@ contract ConstructorTest is Test { uint256 n = bound(_n, 0, _m / 2); vm.expectRevert(abi.encodeWithSelector(Errors.Gerousia__InvalidNAndMValues.selector, n, _m)); - new Gerousia(APELLA, REGISTRY, n, _m); + new Gerousia(REGISTRY, n, _m); } function test_WhenNLargerThanM(uint256 _n, uint256 _m) external { @@ -26,7 +24,7 @@ contract ConstructorTest is Test { 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); + new Gerousia(REGISTRY, n, m); } function test_WhenNIsGreatherThanHalfOfM(uint256 _n, uint256 _m) external { @@ -35,9 +33,8 @@ contract ConstructorTest is Test { uint256 m = bound(_m, 1, type(uint256).max); uint256 n = bound(_n, m / 2 + 1, m); - Gerousia g = new Gerousia(APELLA, REGISTRY, n, m); + Gerousia g = new Gerousia(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/mocks/FalsyApella.sol b/l1-contracts/test/governance/gerousia/mocks/FalsyApella.sol index 65fb4c201e7..2dd591896e6 100644 --- a/l1-contracts/test/governance/gerousia/mocks/FalsyApella.sol +++ b/l1-contracts/test/governance/gerousia/mocks/FalsyApella.sol @@ -1,10 +1,8 @@ // 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) { +contract FalsyApella { + function propose(address) external pure returns (bool) { return false; } } diff --git a/l1-contracts/test/governance/gerousia/mocks/FaultyApella.sol b/l1-contracts/test/governance/gerousia/mocks/FaultyApella.sol index 69e65231917..26cb736a8f2 100644 --- a/l1-contracts/test/governance/gerousia/mocks/FaultyApella.sol +++ b/l1-contracts/test/governance/gerousia/mocks/FaultyApella.sol @@ -1,12 +1,10 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.27; -import {IApella} from "@aztec/governance/interfaces/IApella.sol"; - -contract FaultyApella is IApella { +contract FaultyApella { error Faulty(); - function propose(address) external pure override(IApella) returns (bool) { + function propose(address) external pure 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 index ee4d86d75ad..5936b688dc9 100644 --- a/l1-contracts/test/governance/gerousia/pushProposal.t.sol +++ b/l1-contracts/test/governance/gerousia/pushProposal.t.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.27; +import {IPayload} from "@aztec/governance/interfaces/IPayload.sol"; import {IGerousia} from "@aztec/governance/interfaces/IGerousia.sol"; import {GerousiaBase} from "./Base.t.sol"; import {Leonidas} from "@aztec/core/Leonidas.sol"; @@ -15,7 +16,7 @@ contract PushProposalTest is GerousiaBase { Leonidas internal leonidas; - address internal proposal = address(this); + IPayload internal proposal = IPayload(address(this)); address internal proposer = address(0); function test_GivenCanonicalInstanceHoldNoCode(uint256 _roundNumber) external { @@ -28,6 +29,7 @@ contract PushProposalTest is GerousiaBase { modifier givenCanonicalInstanceHoldCode() { leonidas = new Leonidas(address(this)); + vm.prank(registry.getApella()); registry.upgrade(address(leonidas)); // We jump into the future since slot 0, will behave as if already voted in @@ -46,16 +48,31 @@ contract PushProposalTest is GerousiaBase { _; } - function test_WhenRoundTooFarInPast() external givenCanonicalInstanceHoldCode whenRoundInPast { + function test_WhenRoundTooFarInPast(uint256 _slotsToJump) + external + givenCanonicalInstanceHoldCode + whenRoundInPast + { // it revert - vm.warp( - Timestamp.unwrap( - leonidas.getTimestampForSlot(Slot.wrap((gerousia.LIFETIME_IN_ROUNDS() + 1) * gerousia.M())) + uint256 lower = Timestamp.unwrap( + leonidas.getTimestampForSlot( + leonidas.getCurrentSlot() + Slot.wrap(gerousia.M() * gerousia.LIFETIME_IN_ROUNDS() + 1) ) ); + uint256 upper = + (type(uint256).max - Timestamp.unwrap(leonidas.GENESIS_TIME())) / leonidas.SLOT_DURATION(); + uint256 time = bound(_slotsToJump, lower, upper); - vm.expectRevert(abi.encodeWithSelector(Errors.Gerousia__ProposalTooOld.selector, 0)); + vm.warp(time); + + vm.expectRevert( + abi.encodeWithSelector( + Errors.Gerousia__ProposalTooOld.selector, + 0, + gerousia.computeRound(leonidas.getCurrentSlot()) + ) + ); gerousia.pushProposal(0); } @@ -96,7 +113,7 @@ contract PushProposalTest is GerousiaBase { _; } - function test_GivenLeaderIsAddress0() + function test_GivenLeaderIsAddress0(uint256 _slotsToJump) external givenCanonicalInstanceHoldCode whenRoundInPast @@ -104,6 +121,20 @@ contract PushProposalTest is GerousiaBase { givenRoundNotExecutedBefore { // it revert + + // The first slot in the next round (round 1) + Slot lowerSlot = Slot.wrap(gerousia.M()); + uint256 lower = Timestamp.unwrap(leonidas.getTimestampForSlot(lowerSlot)); + // the last slot in the LIFETIME_IN_ROUNDS next round + uint256 upper = Timestamp.unwrap( + leonidas.getTimestampForSlot( + lowerSlot + Slot.wrap(gerousia.M() * (gerousia.LIFETIME_IN_ROUNDS() - 1)) + ) + ); + uint256 time = bound(_slotsToJump, lower, upper); + + vm.warp(time); + vm.expectRevert(abi.encodeWithSelector(Errors.Gerousia__ProposalCannotBeAddressZero.selector)); gerousia.pushProposal(0); } @@ -134,8 +165,10 @@ contract PushProposalTest is GerousiaBase { gerousia.pushProposal(1); } - modifier givenSufficientYea() { - for (uint256 i = 0; i < gerousia.N(); i++) { + modifier givenSufficientYea(uint256 _yeas) { + uint256 limit = bound(_yeas, gerousia.N(), gerousia.M()); + + for (uint256 i = 0; i < limit; i++) { vm.prank(proposer); assertTrue(gerousia.vote(proposal)); vm.warp( @@ -151,25 +184,26 @@ contract PushProposalTest is GerousiaBase { _; } - function test_GivenNewCanonicalInstance() + function test_GivenNewCanonicalInstance(uint256 _yeas) external givenCanonicalInstanceHoldCode whenRoundInPast whenRoundInRecentPast givenRoundNotExecutedBefore givenLeaderIsNotAddress0 - givenSufficientYea + givenSufficientYea(_yeas) { // it revert // When using a new registry we change the gerousia's interpetation of time :O Leonidas freshInstance = new Leonidas(address(this)); + vm.prank(registry.getApella()); registry.upgrade(address(freshInstance)); // The old is still there, just not executable. - (, address leader, bool executed) = gerousia.rounds(address(leonidas), 1); + (, IPayload leader, bool executed) = gerousia.rounds(address(leonidas), 1); assertFalse(executed); - assertEq(leader, proposal); + assertEq(address(leader), address(proposal)); // As time is perceived differently, round 1 is currently in the future vm.expectRevert(abi.encodeWithSelector(Errors.Gerousia__CanOnlyPushProposalInPast.selector)); @@ -187,14 +221,14 @@ contract PushProposalTest is GerousiaBase { gerousia.pushProposal(1); } - function test_GivenApellaCallReturnFalse() + function test_GivenApellaCallReturnFalse(uint256 _yeas) external givenCanonicalInstanceHoldCode whenRoundInPast whenRoundInRecentPast givenRoundNotExecutedBefore givenLeaderIsNotAddress0 - givenSufficientYea + givenSufficientYea(_yeas) { // it revert FalsyApella falsy = new FalsyApella(); @@ -204,14 +238,14 @@ contract PushProposalTest is GerousiaBase { gerousia.pushProposal(1); } - function test_GivenApellaCallFails() + function test_GivenApellaCallFails(uint256 _yeas) external givenCanonicalInstanceHoldCode whenRoundInPast whenRoundInRecentPast givenRoundNotExecutedBefore givenLeaderIsNotAddress0 - givenSufficientYea + givenSufficientYea(_yeas) { // it revert FaultyApella faulty = new FaultyApella(); @@ -221,14 +255,14 @@ contract PushProposalTest is GerousiaBase { gerousia.pushProposal(1); } - function test_GivenApellaCallSucceeds() + function test_GivenApellaCallSucceeds(uint256 _yeas) external givenCanonicalInstanceHoldCode whenRoundInPast whenRoundInRecentPast givenRoundNotExecutedBefore givenLeaderIsNotAddress0 - givenSufficientYea + givenSufficientYea(_yeas) { // it update executed to true // it emits {ProposalPushed} event @@ -236,8 +270,8 @@ contract PushProposalTest is GerousiaBase { 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); + (, IPayload leader, bool executed) = gerousia.rounds(address(leonidas), 1); assertTrue(executed); - assertEq(leader, proposal); + assertEq(address(leader), address(proposal)); } } diff --git a/l1-contracts/test/governance/gerousia/vote.t.sol b/l1-contracts/test/governance/gerousia/vote.t.sol index 2999d3459d4..023d58e734c 100644 --- a/l1-contracts/test/governance/gerousia/vote.t.sol +++ b/l1-contracts/test/governance/gerousia/vote.t.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.27; +import {IPayload} from "@aztec/governance/interfaces/IPayload.sol"; import {IGerousia} from "@aztec/governance/interfaces/IGerousia.sol"; import {GerousiaBase} from "./Base.t.sol"; import {Leonidas} from "@aztec/core/Leonidas.sol"; @@ -10,7 +11,7 @@ import {Slot, SlotLib, Timestamp} from "@aztec/core/libraries/TimeMath.sol"; contract VoteTest is GerousiaBase { using SlotLib for Slot; - address internal proposal = address(0xdeadbeef); + IPayload internal proposal = IPayload(address(0xdeadbeef)); address internal proposer = address(0); Leonidas internal leonidas; @@ -21,7 +22,7 @@ contract VoteTest is GerousiaBase { } modifier whenProposalHoldCode() { - proposal = address(this); + proposal = IPayload(address(this)); _; } @@ -35,6 +36,7 @@ contract VoteTest is GerousiaBase { modifier givenCanonicalRollupHoldCode() { leonidas = new Leonidas(address(this)); + vm.prank(registry.getApella()); registry.upgrade(address(leonidas)); // We jump into the future since slot 0, will behave as if already voted in @@ -93,14 +95,14 @@ contract VoteTest is GerousiaBase { Slot currentSlot = leonidas.getCurrentSlot(); uint256 round = gerousia.computeRound(currentSlot); - (Slot lastVote, address leader, bool executed) = gerousia.rounds(address(leonidas), round); + (Slot lastVote, IPayload 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(address(leader), address(proposal)); assertEq(currentSlot.unwrap(), lastVote.unwrap()); vm.warp( @@ -128,6 +130,7 @@ contract VoteTest is GerousiaBase { uint256 yeaBefore = gerousia.yeaCount(address(leonidas), leonidasRound, proposal); Leonidas freshInstance = new Leonidas(address(this)); + vm.prank(registry.getApella()); registry.upgrade(address(freshInstance)); vm.warp(Timestamp.unwrap(freshInstance.getTimestampForSlot(Slot.wrap(1)))); @@ -142,19 +145,19 @@ contract VoteTest is GerousiaBase { // Check the new instance { - (Slot lastVote, address leader, bool executed) = + (Slot lastVote, IPayload 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(address(leader), address(proposal)); assertEq(freshSlot.unwrap(), lastVote.unwrap(), "invalid slot [FRESH]"); } // The old instance { - (Slot lastVote, address leader, bool executed) = + (Slot lastVote, IPayload leader, bool executed) = gerousia.rounds(address(leonidas), leonidasRound); assertEq( gerousia.yeaCount(address(leonidas), leonidasRound, proposal), @@ -162,7 +165,7 @@ contract VoteTest is GerousiaBase { "invalid number of votes" ); assertFalse(executed); - assertEq(leader, proposal); + assertEq(address(leader), address(proposal)); assertEq(leonidasSlot.unwrap(), lastVote.unwrap() + 1, "invalid slot [LEONIDAS]"); } } @@ -207,12 +210,12 @@ contract VoteTest is GerousiaBase { emit IGerousia.VoteCast(proposal, round, proposer); assertTrue(gerousia.vote(proposal)); - (Slot lastVote, address leader, bool executed) = gerousia.rounds(address(leonidas), round); + (Slot lastVote, IPayload 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(address(leader), address(proposal)); assertEq(currentSlot.unwrap(), lastVote.unwrap()); } @@ -235,20 +238,22 @@ contract VoteTest is GerousiaBase { vm.prank(proposer); vm.expectEmit(true, true, true, true, address(gerousia)); - emit IGerousia.VoteCast(address(leonidas), round, proposer); - assertTrue(gerousia.vote(address(leonidas))); + emit IGerousia.VoteCast(IPayload(address(leonidas)), round, proposer); + assertTrue(gerousia.vote(IPayload(address(leonidas)))); - (Slot lastVote, address leader, bool executed) = gerousia.rounds(address(leonidas), round); + (Slot lastVote, IPayload 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" + gerousia.yeaCount(address(leonidas), round, IPayload(address(leonidas))), + 1, + "invalid number of votes" ); assertFalse(executed); - assertEq(leader, proposal); + assertEq(address(leader), address(proposal)); assertEq(currentSlot.unwrap(), lastVote.unwrap()); } @@ -273,8 +278,8 @@ contract VoteTest is GerousiaBase { 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))); + emit IGerousia.VoteCast(IPayload(address(leonidas)), round, proposer); + assertTrue(gerousia.vote(IPayload(address(leonidas)))); vm.warp( Timestamp.unwrap(leonidas.getTimestampForSlot(leonidas.getCurrentSlot() + Slot.wrap(1))) @@ -282,14 +287,14 @@ contract VoteTest is GerousiaBase { } { - (Slot lastVote, address leader, bool executed) = gerousia.rounds(address(leonidas), round); + (Slot lastVote, IPayload leader, bool executed) = gerousia.rounds(address(leonidas), round); assertEq( - gerousia.yeaCount(address(leonidas), round, address(leonidas)), + gerousia.yeaCount(address(leonidas), round, IPayload(address(leonidas))), leaderYeaBefore + 1, "invalid number of votes" ); assertFalse(executed); - assertEq(leader, address(leonidas)); + assertEq(address(leader), address(leonidas)); assertEq( gerousia.yeaCount(address(leonidas), round, proposal), leaderYeaBefore, diff --git a/l1-contracts/test/governance/registry/getCurentSnapshotTest.t.sol b/l1-contracts/test/governance/registry/getCurrentSnapshotTest.t.sol similarity index 100% rename from l1-contracts/test/governance/registry/getCurentSnapshotTest.t.sol rename to l1-contracts/test/governance/registry/getCurrentSnapshotTest.t.sol diff --git a/l1-contracts/test/governance/registry/getCurentSnapshotTest.tree b/l1-contracts/test/governance/registry/getCurrentSnapshotTest.tree similarity index 100% rename from l1-contracts/test/governance/registry/getCurentSnapshotTest.tree rename to l1-contracts/test/governance/registry/getCurrentSnapshotTest.tree