diff --git a/packages/contracts-bedrock/src/governance/GovernanceToken.sol b/packages/contracts-bedrock/src/governance/GovernanceToken.sol index fe1073f4ce45..860c071dc40b 100644 --- a/packages/contracts-bedrock/src/governance/GovernanceToken.sol +++ b/packages/contracts-bedrock/src/governance/GovernanceToken.sol @@ -5,30 +5,153 @@ import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol"; import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; +import "src/libraries/Predeploys.sol"; +import "src/governance/Alligator.sol"; /// @custom:predeploy 0x4200000000000000000000000000000000000042 /// @title GovernanceToken /// @notice The Optimism token used in governance and supporting voting and delegation. Implements /// EIP 2612 allowing signed approvals. Contract is "owned" by a `MintManager` instance with /// permission to the `mint` function only, for the purposes of enforcing the token -/// inflation schedule. +/// inflation schedule. If an account has already been migrated to the Alligator contract, +/// the GovernanceToken contract uses the state from the Alligator by calling the corresponding +/// functions in the Alligator contract. If the account has not been migrated, the +/// GovernanceToken contract uses its own state. contract GovernanceToken is ERC20Burnable, ERC20Votes, Ownable { + /// @notice Thrown when a function undefined post migration is called. + error UndefinedPostMigration(); + + /// @notice The typehash for the delegation struct used by the contract. + bytes32 private constant _DELEGATION_TYPEHASH = + keccak256("Delegation(address delegatee,uint256 nonce,uint256 expiry)"); + /// @notice Constructs the GovernanceToken contract. constructor() ERC20("Optimism", "OP") ERC20Permit("Optimism") { } - /// @notice Allows the owner to mint tokens. + /// @notice Allows owner to mint tokens. /// @param _account The account receiving minted tokens. /// @param _amount The amount of tokens to mint. function mint(address _account, uint256 _amount) public onlyOwner { _mint(_account, _amount); } - /// @notice Callback called after a token transfer. + /// @notice Returns the checkpoint for a given account at a given position. + /// @param _account The account to get the checkpoints for. + /// @param _pos The psition to get the checkpoints at. + /// @return The checkpoint at the given position. + function checkpoints(address _account, uint32 _pos) public view override(ERC20Votes) returns (Checkpoint memory) { + if (_migrated(_account)) { + return Alligator(Predeploys.ALLIGATOR).checkpoints(address(this), _account)[_pos]; + } else { + return super.checkpoints(_account, _pos); + } + } + + /// @notice Returns the number of checkpoints for a given account. + /// @param _account The account to get the number of checkpoints for. + /// @return The number of checkpoints for the given account. + function numCheckpoints(address _account) public view override(ERC20Votes) returns (uint32) { + if (_migrated(_account)) { + return Alligator(Predeploys.ALLIGATOR).numCheckpoints(msg.sender, _account); + } else { + return super.numCheckpoints(_account); + } + } + + /// @notice Returns the delegatee of an account. This function is unavailable post migration, + /// because the Alligator may hold more than one delegatee for an account, conflicting + /// the return type of this function. + /// @param _account The account to get the delegatee of. + /// @return The delegatee of the given account. + function delegates(address _account) public view override(ERC20Votes) returns (address) { + if (_migrated(_account)) { + revert UndefinedPostMigration(); + } else { + return super.delegates(_account); + } + } + + /// @notice Delegates votes from the sender to `delegatee`. + /// @param _delegatee The account to delegate votes to. + function delegate(address _delegatee) public override { + if (!_migrated(msg.sender)) _migrate(msg.sender); + + Alligator(Predeploys.ALLIGATOR).subdelegate( + msg.sender, + _delegatee, + // Create rule equivalent to basic delegation. + SubdelegationRules({ + maxRedelegations: 0, + blocksBeforeVoteCloses: 0, + notValidBefore: 0, + notValidAfter: 0, + allowanceType: AllowanceType.Relative, + allowance: 10e4 // 100% + }) + ); + } + + /// @notice Delegates votes from the sender to `delegatee`. + /// @param _delegatee The account to delegate votes to. + /// @param _nonce The nonce of the transaction. + /// @param _expiry The expiry of the signature. + /// @param _v v of the signature. + /// @param _r r of the signature. + /// @param _s s of the signature. + function delegateBySig( + address _delegatee, + uint256 _nonce, + uint256 _expiry, + uint8 _v, + bytes32 _r, + bytes32 _s + ) + public + override + { + if (!_migrated(msg.sender)) _migrate(msg.sender); + + // TODO: custom errors. use revert instead of require + require(block.timestamp <= _expiry, "GovernanceToken: signature expired"); + address signer = ECDSA.recover( + _hashTypedDataV4(keccak256(abi.encode(_DELEGATION_TYPEHASH, _delegatee, _nonce, _expiry))), _v, _r, _s + ); + require(_nonce == _useNonce(signer), "GovernanceToken: invalid nonce"); + Alligator(Predeploys.ALLIGATOR).subdelegate( + msg.sender, + _delegatee, + // Create rule equivalent to basic delegation. + SubdelegationRules({ + maxRedelegations: 0, + blocksBeforeVoteCloses: 0, + notValidBefore: 0, + notValidAfter: 0, + allowanceType: AllowanceType.Relative, + allowance: 10e4 // 100% + }) + ); + } + + /// @notice Callback called after a token transfer. Forwards to the Alligator contract, + /// independently of whether the account has been migrated. /// @param from The account sending tokens. /// @param to The account receiving tokens. /// @param amount The amount of tokens being transfered. function _afterTokenTransfer(address from, address to, uint256 amount) internal override(ERC20, ERC20Votes) { - super._afterTokenTransfer(from, to, amount); + Alligator(Predeploys.ALLIGATOR).afterTokenTransfer(from, to, amount); + } + + /// @notice Determines whether an account has been migrated. + /// @param _account The account to check if it has been migrated. + /// @return True if the given account has been migrated, and false otherwise. + function _migrated(address _account) internal view returns (bool) { + return Alligator(Predeploys.ALLIGATOR).migrated(address(this), _account); + } + + /// @notice Internal migrate function. + /// @param _account The account to migrate. + function _migrate(address _account) public onlyOwner { + Alligator(Predeploys.ALLIGATOR).migrate(_account); } /// @notice Internal mint function. diff --git a/packages/contracts-bedrock/test/governance/GovernanceToken.t.sol b/packages/contracts-bedrock/test/governance/GovernanceToken.t.sol index 4bd13a231d49..2427af20184d 100644 --- a/packages/contracts-bedrock/test/governance/GovernanceToken.t.sol +++ b/packages/contracts-bedrock/test/governance/GovernanceToken.t.sol @@ -3,6 +3,9 @@ pragma solidity 0.8.15; // Testing utilities import { CommonTest } from "test/setup/CommonTest.sol"; +import "src/libraries/Predeploys.sol"; +import "src/governance/Alligator.sol"; +import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol"; contract GovernanceToken_Test is CommonTest { address owner; @@ -166,4 +169,82 @@ contract GovernanceToken_Test is CommonTest { // Allowances have updated. assertEq(governanceToken.allowance(rando, owner), 50); } + + /// @dev Test that `checkpoints` returns the correct value when the account is migrated. + function testFuzz_checkpoints_migrated_succeeds( + address _account, + uint32 _pos, + ERC20Votes.Checkpoint calldata _checkpoint + ) + public + { + vm.mockCall(Predeploys.ALLIGATOR, abi.encodeWithSignature("migrated(address)", _account), abi.encode(true)); + vm.mockCall( + Predeploys.ALLIGATOR, + abi.encodeWithSelector(Alligator.checkpoints.selector, _account, _pos), + abi.encode(_checkpoint) + ); + + ERC20Votes.Checkpoint memory actualCheckpoint = governanceToken.checkpoints(_account, _pos); + assertEq(actualCheckpoint.fromBlock, _checkpoint.fromBlock); + assertEq(actualCheckpoint.votes, _checkpoint.votes); + } + + /// @dev Test that `checkpoints` returns the correct value when the account is not migrated. + function testFuzz_checkpoints_notMigrated_succeeds( + address _account, + uint32 _pos, + ERC20Votes.Checkpoint memory _checkpoint + ) + public + { + vm.mockCall(Predeploys.ALLIGATOR, abi.encodeWithSignature("migrated(address)", _account), abi.encode(false)); + + // Store _pos + 1 (because _pos starts as zero) as length for _checkpoints in slot 8, which stores _checkpoints + vm.store(Predeploys.GOVERNANCE_TOKEN, keccak256(abi.encode(_account, uint256(8))), bytes32(uint256(_pos) + 1)); + vm.store( + Predeploys.GOVERNANCE_TOKEN, + bytes32(uint256(keccak256(abi.encode(keccak256(abi.encode(_account, uint256(8)))))) + _pos), + bytes32(abi.encodePacked(_checkpoint.votes, _checkpoint.fromBlock)) + ); + + ERC20Votes.Checkpoint memory actualCheckpoint = governanceToken.checkpoints(_account, _pos); + assertEq(actualCheckpoint.fromBlock, _checkpoint.fromBlock); + assertEq(actualCheckpoint.votes, _checkpoint.votes); + } + + function testFuzz_numCheckpoints_migrated_succeeds(address _account, uint32 _numCheckpoints) public { + vm.mockCall(Predeploys.ALLIGATOR, abi.encodeWithSignature("migrated(address)", _account), abi.encode(true)); + vm.mockCall( + Predeploys.ALLIGATOR, + abi.encodeWithSelector(Alligator.numCheckpoints.selector, _account), + abi.encode(_numCheckpoints) + ); + + uint32 actualNumCheckpoints = governanceToken.numCheckpoints(_account); + assertEq(actualNumCheckpoints, _numCheckpoints); + } + + function testFuzz_numCheckpoints_notMigrated_succeeds(address _account, uint32 _numCheckpoints) public { + vm.mockCall(Predeploys.ALLIGATOR, abi.encodeWithSignature("migrated(address)", _account), abi.encode(false)); + + // Store _numCheckpoints as length for _checkpoints in slot 8, which stores _checkpoints + vm.store( + Predeploys.GOVERNANCE_TOKEN, keccak256(abi.encode(_account, uint256(8))), bytes32(uint256(_numCheckpoints)) + ); + + uint32 actualNumCheckpoints = governanceToken.numCheckpoints(_account); + assertEq(actualNumCheckpoints, _numCheckpoints); + } + + function testFuzz_delegates_migrated_succeeds(address _account, address _delegatee) public { + vm.mockCall(Predeploys.ALLIGATOR, abi.encodeWithSignature("migrated(address)", _account), abi.encode(true)); + vm.mockCall( + Predeploys.ALLIGATOR, + abi.encodeWithSelector(Alligator.numCheckpoints.selector, _account), + abi.encode(_delegatee) + ); + + assertEq(_delegatee, governanceToken.delegates(_account)); + } }