diff --git a/l1-contracts/src/governance/Governance.sol b/l1-contracts/src/governance/Governance.sol index 58f62df57860..d76046cff9e1 100644 --- a/l1-contracts/src/governance/Governance.sol +++ b/l1-contracts/src/governance/Governance.sol @@ -21,6 +21,7 @@ import {CompressedProposal, CompressedProposalLib} from "@aztec/governance/libra 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 {CompressedTimeMath, CompressedTimestamp} from "@aztec/shared/libraries/CompressedTimeMath.sol"; import {Timestamp} from "@aztec/shared/libraries/TimeMath.sol"; import {IERC20} from "@oz/token/ERC20/IERC20.sol"; import {SafeERC20} from "@oz/token/ERC20/utils/SafeERC20.sol"; @@ -141,6 +142,7 @@ contract Governance is IGovernance { using ConfigurationLib for CompressedConfiguration; using CompressedConfigurationLib for CompressedConfiguration; using CompressedProposalLib for CompressedProposal; + using CompressedTimeMath for CompressedTimestamp; using BallotLib for CompressedBallot; IERC20 public immutable ASSET; @@ -219,6 +221,14 @@ contract Governance is IGovernance { */ uint256 public withdrawalCount; + /** + * @dev The earliest timestamp at which a user may complete a withdrawal after casting votes. + * + * Voting locks all of the voter's power until the latest proposal they voted on is executable, so a later + * configuration update cannot shorten the withdrawal delay for votes already counted under frozen proposal timing. + */ + mapping(address user => Timestamp exitTimestamp) internal earliestVoteExit; + /** * @dev Modifier to ensure that the caller is the governance contract itself. * @@ -469,6 +479,13 @@ contract Governance is IGovernance { ballots[_proposalId][msg.sender] = userBallot.addNay(_amount); proposal.addNay(_amount); } + if (_amount > 0) { + Timestamp executionBuffer = Timestamp.wrap(Timestamp.unwrap(proposal.votingDelay.decompress()) / 5 + 1); + Timestamp executableAt = proposal.queuedThrough() + executionBuffer; + if (executableAt > earliestVoteExit[msg.sender]) { + earliestVoteExit[msg.sender] = executableAt; + } + } emit VoteCast(_proposalId, msg.sender, _support, _amount); } @@ -738,9 +755,12 @@ contract Governance is IGovernance { uint256 withdrawalId = withdrawalCount++; - withdrawals[withdrawalId] = Withdrawal({ - amount: _amount, unlocksAt: Timestamp.wrap(block.timestamp) + _delay, recipient: _to, claimed: false - }); + Timestamp unlocksAt = Timestamp.wrap(block.timestamp) + _delay; + if (earliestVoteExit[_from] > unlocksAt) { + unlocksAt = earliestVoteExit[_from]; + } + + withdrawals[withdrawalId] = Withdrawal({amount: _amount, unlocksAt: unlocksAt, recipient: _to, claimed: false}); emit WithdrawInitiated(withdrawalId, _to, _amount); diff --git a/l1-contracts/test/governance/governance/scenarios/voteAndExitAfterConfigChange.t.sol b/l1-contracts/test/governance/governance/scenarios/voteAndExitAfterConfigChange.t.sol new file mode 100644 index 000000000000..07cbd2d49e7c --- /dev/null +++ b/l1-contracts/test/governance/governance/scenarios/voteAndExitAfterConfigChange.t.sol @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {GovernanceBase} from "../base.t.sol"; +import {EmptyPayload} from "../TestPayloads.sol"; +import { + IGovernance, + Configuration, + Proposal, + ProposalState, + Withdrawal +} from "@aztec/governance/interfaces/IGovernance.sol"; +import {IPayload} from "@aztec/governance/interfaces/IPayload.sol"; +import {ConfigurationLib} from "@aztec/governance/libraries/ConfigurationLib.sol"; +import {Errors} from "@aztec/governance/libraries/Errors.sol"; +import {Timestamp} from "@aztec/core/libraries/TimeLib.sol"; + +contract UpdateConfigurationPayload is IPayload { + IGovernance internal immutable GOVERNANCE; + Configuration internal configuration; + + constructor(IGovernance _governance, Configuration memory _configuration) { + GOVERNANCE = _governance; + configuration = _configuration; + } + + function getActions() external view override(IPayload) returns (IPayload.Action[] memory) { + IPayload.Action[] memory res = new IPayload.Action[](1); + Configuration memory updatedConfiguration = configuration; + + res[0] = Action({ + target: address(GOVERNANCE), + data: abi.encodeWithSelector(IGovernance.updateConfiguration.selector, updatedConfiguration) + }); + + return res; + } + + function getURI() external pure override(IPayload) returns (string memory) { + return "UpdateConfigurationPayload"; + } +} + +contract VoteAndExitAfterConfigChangeTest is GovernanceBase { + address internal constant VOTER = address(0xb0b); + uint256 internal constant VOTER_POWER = 400e18; + + function test_CannotFinalizeWithdrawBeforeVotedProposalIsExecutableAfterConfigReduction() external { + token.mint(VOTER, VOTER_POWER); + vm.startPrank(VOTER); + token.approve(address(governance), VOTER_POWER); + governance.deposit(VOTER, VOTER_POWER); + vm.stopPrank(); + + Configuration memory shortConfig = governance.getConfiguration(); + shortConfig.votingDelay = ConfigurationLib.TIME_LOWER; + shortConfig.votingDuration = ConfigurationLib.TIME_LOWER; + shortConfig.executionDelay = ConfigurationLib.TIME_LOWER; + + UpdateConfigurationPayload configPayload = + new UpdateConfigurationPayload(IGovernance(address(governance)), shortConfig); + + vm.prank(address(governanceProposer)); + governance.propose(configPayload); + uint256 configProposalId = governance.proposalCount() - 1; + Proposal memory configProposal = governance.getProposal(configProposalId); + + vm.warp(block.timestamp + 200); + + EmptyPayload laterPayload = new EmptyPayload(); + vm.prank(address(governanceProposer)); + governance.propose(laterPayload); + uint256 laterProposalId = governance.proposalCount() - 1; + Proposal memory laterProposal = governance.getProposal(laterProposalId); + + vm.warp(Timestamp.unwrap(upw.pendingThrough(laterProposal)) + 1); + + vm.startPrank(VOTER); + governance.vote(configProposalId, VOTER_POWER, true); + governance.vote(laterProposalId, VOTER_POWER, true); + vm.stopPrank(); + + Timestamp laterVoteExit = Timestamp.wrap( + Timestamp.unwrap(upw.queuedThrough(laterProposal)) + Timestamp.unwrap(laterProposal.config.votingDelay) / 5 + 1 + ); + + vm.warp(Timestamp.unwrap(upw.queuedThrough(configProposal)) + 1); + assertEq(governance.getProposalState(configProposalId), ProposalState.Executable); + governance.execute(configProposalId); + + Timestamp liveConfigUnlock = Timestamp.wrap(block.timestamp) + upw.getWithdrawalDelay(shortConfig); + assertLt(Timestamp.unwrap(liveConfigUnlock), Timestamp.unwrap(laterVoteExit)); + + vm.prank(VOTER); + uint256 withdrawalId = governance.initiateWithdraw(VOTER, VOTER_POWER); + + Withdrawal memory withdrawal = governance.getWithdrawal(withdrawalId); + assertEq(withdrawal.unlocksAt, laterVoteExit); + + vm.warp(Timestamp.unwrap(liveConfigUnlock)); + vm.expectRevert( + abi.encodeWithSelector( + Errors.Governance__WithdrawalNotUnlockedYet.selector, Timestamp.wrap(block.timestamp), withdrawal.unlocksAt + ) + ); + governance.finalizeWithdraw(withdrawalId); + + vm.warp(Timestamp.unwrap(withdrawal.unlocksAt)); + assertEq(governance.getProposalState(laterProposalId), ProposalState.Executable); + governance.finalizeWithdraw(withdrawalId); + } +}