├── img └── l2_flexible_voting_diagram.png ├── env.sample ├── .gitignore ├── src ├── interfaces │ ├── IL1GovernorMetadataBridge.sol │ ├── IL1ERC20Bridge.sol │ ├── IERC20Mint.sol │ └── IL1Block.sol ├── WormholeBase.sol ├── FakeERC20.sol ├── optimized │ ├── WormholeL2GovernorMetadataOptimized.sol │ └── WormholeL2VoteAggregatorCalldataCompressor.sol ├── L1Block.sol ├── WormholeL1VotePool.sol ├── WormholeL2GovernorMetadata.sol ├── WormholeSender.sol ├── WormholeL2VoteAggregator.sol ├── L1VotePool.sol ├── WormholeReceiver.sol ├── WormholeL1GovernorMetadataBridge.sol ├── WormholeL2ERC20.sol ├── L2GovernorMetadata.sol ├── WormholeL1ERC20Bridge.sol ├── L2CountingFractional.sol └── L2VoteAggregator.sol ├── .gitmodules ├── test ├── WormholeBase.t.sol ├── harness │ ├── optimized │ │ └── WormholeL2GovernorMetadataOptimizedHarness.sol │ ├── L2GovernorMetadataHarness.sol │ ├── L1ERC20BridgeHarness.sol │ ├── L2VoteAggregatorHarness.sol │ ├── L1VotePoolHarness.sol │ ├── WormholeL2VoteAggregatorHarness.sol │ ├── L2CountingFractionalHarness.sol │ └── WormholeL1VotePoolHarness.sol ├── mock │ ├── ERC20VotesCompMock.sol │ ├── L1BlockMock.sol │ ├── GovernorMock.sol │ └── GovernorMetadataMock.sol ├── L1VotePool.t.sol ├── WormholeSender.t.sol ├── L2GovernorMetadata.t.sol ├── Constants.sol ├── WormholeReceiver.t.sol ├── WormholeL2VoteAggregator.t.sol ├── optimized │ ├── WormholeL2GovernorMetadataOptimized.t.sol │ └── WormholeL2VoteAggregatorCalldataCompressor.t.sol ├── WormholeL2ERC20.t.sol ├── WormholeL1GovernorMetadataBridge.t.sol ├── WormholeL2GovernorMetadata.t.sol ├── WormholeL1ERC20Bridge.t.sol ├── L2CountingFractional.t.sol └── WormholeL1VotePool.t.sol ├── LICENSE.txt ├── foundry.toml ├── script ├── WormholeMintOnL2.s.sol ├── WormholeSendProposalToL2.s.sol ├── WormholeL2FlexibleVotingDeploy.s.sol └── helpers │ └── Governors.sol ├── .github └── workflows │ └── ci.yml └── README.md /img/l2_flexible_voting_diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScopeLift/l2-flexible-voting/HEAD/img/l2_flexible_voting_diagram.png -------------------------------------------------------------------------------- /env.sample: -------------------------------------------------------------------------------- 1 | POLYGON_MUMBAI_RPC_URL= 2 | AVALANCHE_FUJI_RPC_URL= 3 | OPTIMISM_RPC_URL= 4 | ETHEREUM_RPC_URL= 5 | OPTIMISM_GOERLI_RPC_URL= 6 | GOERLI_RPC_URL= 7 | L1_CHAIN_ID= # defaults to 43113 8 | L2_CHAIN_ID= # defaults to 80001 9 | TESTNET= # defaults to true 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiler files 2 | cache/ 3 | out/ 4 | 5 | # Ignores development broadcast logs 6 | !/broadcast 7 | /broadcast/*/31337/ 8 | /broadcast/**/dry-run/ 9 | 10 | # Dotenv file 11 | .env 12 | 13 | # Coverage 14 | lcov.info 15 | notes.txt 16 | 17 | remappings.txt 18 | -------------------------------------------------------------------------------- /src/interfaces/IL1GovernorMetadataBridge.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | interface IL1GovernorMetadataBridge { 5 | function bridgeProposalMetadata(uint256 proposalId) external payable returns (uint16); 6 | function quoteDeliveryCost(uint16 targetChain) external returns (uint256); 7 | } 8 | -------------------------------------------------------------------------------- /src/interfaces/IL1ERC20Bridge.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | interface IL1ERC20Bridge { 5 | function deposit(address account, uint256 amount) external payable returns (uint16); 6 | function quoteDeliveryCost(uint16 targetChain) external returns (uint256); 7 | function initialize(address _l1Token) external; 8 | } 9 | -------------------------------------------------------------------------------- /src/interfaces/IERC20Mint.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import {IERC20} from "openzeppelin/interfaces/IERC20.sol"; 5 | 6 | interface IERC20Mint is IERC20 { 7 | function mint(address account, uint256 amount) external; 8 | } 9 | 10 | interface IERC20Receive is IERC20Mint { 11 | function registerApplicationContracts(uint16 chainId, bytes32 applicationAddr) external; 12 | } 13 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lib/forge-std"] 2 | path = lib/forge-std 3 | url = https://github.com/foundry-rs/forge-std 4 | [submodule "lib/openzeppelin-contracts"] 5 | path = lib/openzeppelin-contracts 6 | url = https://github.com/OpenZeppelin/openzeppelin-contracts 7 | [submodule "lib/wormhole"] 8 | path = lib/wormhole 9 | url = https://github.com/wormhole-foundation/wormhole 10 | [submodule "lib/wormhole-solidity-sdk"] 11 | path = lib/wormhole-solidity-sdk 12 | url = https://github.com/alexkeating/wormhole-solidity-sdk 13 | [submodule "lib/flexible-voting"] 14 | path = lib/flexible-voting 15 | url = https://github.com/ScopeLift/flexible-voting 16 | -------------------------------------------------------------------------------- /src/WormholeBase.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {Ownable} from "openzeppelin/access/Ownable.sol"; 5 | import {IWormholeRelayer} from "wormhole/interfaces/relayer/IWormholeRelayer.sol"; 6 | 7 | contract WormholeBase is Ownable { 8 | /// @notice The wormhole relayer used to trustlessly send messages. 9 | IWormholeRelayer public immutable WORMHOLE_RELAYER; 10 | 11 | /// @param _relayer The address of the Wormhole relayer contract. 12 | /// @param _owner The address of the owner. 13 | constructor(address _relayer, address _owner) { 14 | WORMHOLE_RELAYER = IWormholeRelayer(_relayer); 15 | transferOwnership(_owner); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/interfaces/IL1Block.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | // interface is for contract defined at 5 | // https://github.com/ethereum-optimism/optimism/blob/develop/packages/contracts-bedrock/contracts/L2/L1Block.sol 6 | interface IL1Block { 7 | /// @notice The latest L1 block number known by the L2 system. 8 | function number() external view returns (uint64); 9 | 10 | /// @notice The latest L1 timestamp known by the L2 system. 11 | function timestamp() external view returns (uint64); 12 | 13 | /// @notice The latest L1 basefee. 14 | function basefee() external view returns (uint256); 15 | 16 | /// @notice The latest L1 blockhash. 17 | function hash() external view returns (bytes32); 18 | } 19 | -------------------------------------------------------------------------------- /test/WormholeBase.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {Test} from "forge-std/Test.sol"; 5 | 6 | import {WormholeBase} from "src/WormholeBase.sol"; 7 | import {TestConstants} from "test/Constants.sol"; 8 | 9 | contract Constructor is Test, TestConstants { 10 | function testForkFuzz_CorrectlySetsAllArgs(address _wormholeRelayer, address _owner) public { 11 | vm.assume(_owner != address(0)); 12 | WormholeBase base = new WormholeBase(_wormholeRelayer, _owner); 13 | 14 | assertEq( 15 | address(base.WORMHOLE_RELAYER()), _wormholeRelayer, "Wormhole relayer is not set correctly" 16 | ); 17 | assertEq(address(base.owner()), _owner, "Contract owner is incorrect"); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /test/harness/optimized/WormholeL2GovernorMetadataOptimizedHarness.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {WormholeL2GovernorMetadataOptimized} from 5 | "src/optimized/WormholeL2GovernorMetadataOptimized.sol"; 6 | 7 | contract WormholeL2GovernorMetadataOptimizedHarness is WormholeL2GovernorMetadataOptimized { 8 | constructor(address _core, address _owner, address _l1BlockAddress) 9 | WormholeL2GovernorMetadataOptimized(_core, _owner, _l1BlockAddress, 1200) 10 | {} 11 | 12 | function exposed_addProposal( 13 | uint256 proposalId, 14 | uint256 voteStart, 15 | uint256 voteEnd, 16 | bool isCanceled 17 | ) external { 18 | _addProposal(proposalId, voteStart, voteEnd, isCanceled); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /test/harness/L2GovernorMetadataHarness.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {L2GovernorMetadata} from "src/L2GovernorMetadata.sol"; 5 | import {TestConstants} from "test/Constants.sol"; 6 | 7 | contract L2GovernorMetadataHarness is L2GovernorMetadata { 8 | constructor(address _l1BlockAddress) L2GovernorMetadata(_l1BlockAddress, 1200) {} 9 | 10 | function exposed_proposals(uint256 proposalId) public view returns (Proposal memory) { 11 | return _proposals[proposalId]; 12 | } 13 | 14 | function exposed_addProposal( 15 | uint256 proposalId, 16 | uint256 voteStart, 17 | uint256 voteEnd, 18 | bool isCanceled 19 | ) public { 20 | _addProposal(proposalId, voteStart, voteEnd, isCanceled); 21 | } 22 | 23 | function exposed_l2BlockForFutureL1Block(uint256 _l1BlockNumber) public view returns (uint256) { 24 | return _l2BlockForFutureL1Block(_l1BlockNumber); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /test/mock/ERC20VotesCompMock.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {ERC20VotesComp} from "openzeppelin/token/ERC20/extensions/ERC20VotesComp.sol"; 5 | 6 | import {ERC20Permit} from "openzeppelin/token/ERC20/extensions/ERC20Permit.sol"; 7 | import {ERC20} from "openzeppelin/token/ERC20/ERC20.sol"; 8 | import {IERC20Mint} from "src/interfaces/IERC20Mint.sol"; 9 | 10 | /// @notice An ERC20Votes token to help test the L2 voting system 11 | contract ERC20VotesCompMock is ERC20VotesComp, IERC20Mint { 12 | constructor(string memory _name, string memory _symbol) ERC20(_name, _symbol) ERC20Permit(_name) {} 13 | 14 | /// @dev Mints tokens to an address to help test bridging and voting. 15 | /// @param account The address of where to mint the tokens. 16 | /// @param amount The amount of tokens to mint. 17 | function mint(address account, uint256 amount) public { 18 | _mint(account, amount); 19 | delegate(account); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /test/harness/L1ERC20BridgeHarness.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {WormholeL1ERC20Bridge} from "src/WormholeL1ERC20Bridge.sol"; 5 | 6 | contract L1ERC20BridgeHarness is WormholeL1ERC20Bridge { 7 | constructor( 8 | address _l1Token, 9 | address _l1Relayer, 10 | address _l1Governor, 11 | uint16 _sourceId, 12 | uint16 _targetId, 13 | address _owner 14 | ) WormholeL1ERC20Bridge(_l1Token, _l1Relayer, _l1Governor, _sourceId, _targetId, _owner) {} 15 | 16 | function withdraw(address account, uint256 amount) public { 17 | _withdraw(account, amount); 18 | } 19 | 20 | function exposed_receiveWithdrawalWormholeMessages( 21 | bytes calldata payload, 22 | bytes[] memory additionalVaas, 23 | bytes32 callerAddr, 24 | uint16 sourceChain, 25 | bytes32 deliveryHash 26 | ) public { 27 | _receiveWithdrawalWormholeMessages( 28 | payload, additionalVaas, callerAddr, sourceChain, deliveryHash 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 ScopeLift 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/harness/L2VoteAggregatorHarness.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {L2VoteAggregator} from "src/L2VoteAggregator.sol"; 5 | import {WormholeL2VoteAggregator} from "src/WormholeL2VoteAggregator.sol"; 6 | import {GovernorMetadataMockBase, L2GovernorMetadata} from "test/mock/GovernorMetadataMock.sol"; 7 | 8 | contract L2VoteAggregatorHarness is L2VoteAggregator, GovernorMetadataMockBase { 9 | constructor(address _votingToken, address _l1BlockAddress, uint32 _castVoteWindow) 10 | L2VoteAggregator(_votingToken) 11 | L2GovernorMetadata(_l1BlockAddress, _castVoteWindow) 12 | {} 13 | 14 | function _bridgeVote(bytes memory) internal override {} 15 | 16 | function exposed_bridgeVote(bytes memory proposalCalldata) public { 17 | _bridgeVote(proposalCalldata); 18 | } 19 | 20 | function exposed_castVote( 21 | uint256 proposalId, 22 | address voter, 23 | VoteType support, 24 | string memory reason 25 | ) public returns (uint256) { 26 | return _castVote(proposalId, voter, uint8(support), reason); 27 | } 28 | 29 | function exposed_domainSeparatorV4() public view returns (bytes32) { 30 | return _domainSeparatorV4(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /foundry.toml: -------------------------------------------------------------------------------- 1 | [profile.default] 2 | evm_version = "paris" 3 | fs_permissions = [{ access = "read", path = "./broadcast" }] 4 | optimizer = true 5 | optimizer_runs = 10_000_000 6 | remappings = [ 7 | "wormhole/=lib/wormhole/ethereum/contracts", 8 | "openzeppelin/=lib/openzeppelin-contracts/contracts", 9 | "openzeppelin-flexible-voting/=lib/flexible-voting/lib/openzeppelin-contracts/contracts/", 10 | ] 11 | solc_version = "0.8.20" 12 | verbosity = 3 13 | 14 | [profile.ci] 15 | fuzz = { runs = 1000 } 16 | invariant = { runs = 1000 } 17 | 18 | [rpc_endpoints] 19 | avalanche_fuji = "${FUJI_RPC_URL}" 20 | mainnet = "${ETHEREUM_RPC_URL}" 21 | optimism = "${OPTIMISM_RPC_URL}" 22 | polygon_mumbai = "${MUMBAI_RPC_URL}" 23 | 24 | [profile.lite] 25 | fuzz = { runs = 50 } 26 | invariant = { runs = 10 } 27 | # Speed up compilation and tests during development. 28 | optimizer = false 29 | 30 | [fmt] 31 | bracket_spacing = false 32 | int_types = "long" 33 | line_length = 100 34 | multiline_func_header = "attributes_first" 35 | number_underscore = "thousands" 36 | quote_style = "double" 37 | single_line_statement_blocks = "single" 38 | tab_width = 2 39 | wrap_comments = true 40 | -------------------------------------------------------------------------------- /src/FakeERC20.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {ERC20Votes} from "openzeppelin/token/ERC20/extensions/ERC20Votes.sol"; 5 | import {ERC20Permit} from "openzeppelin/token/ERC20/extensions/ERC20Permit.sol"; 6 | import {ERC20} from "openzeppelin/token/ERC20/ERC20.sol"; 7 | import {IERC20Mint} from "src/interfaces/IERC20Mint.sol"; 8 | 9 | /// @notice An ERC20Votes token to help test the L2 voting system 10 | contract FakeERC20 is ERC20Votes, IERC20Mint { 11 | constructor(string memory _name, string memory _symbol) ERC20(_name, _symbol) ERC20Permit(_name) {} 12 | 13 | /// @dev Mints tokens to an address to help test bridging and voting. 14 | /// @param account The address of where to mint the tokens. 15 | /// @param amount The amount of tokens to mint. 16 | function mint(address account, uint256 amount) public { 17 | _mint(account, amount); 18 | delegate(account); 19 | } 20 | 21 | /** 22 | * @dev Not really needed, except as compatibility shim for our GovernorMock (which is 23 | * ERC20VotesComp) 24 | */ 25 | function getPriorVotes(address account, uint256 blockNumber) 26 | external 27 | view 28 | virtual 29 | returns (uint256) 30 | { 31 | return getPastVotes(account, blockNumber); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/optimized/WormholeL2GovernorMetadataOptimized.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {L2GovernorMetadata} from "src/L2GovernorMetadata.sol"; 5 | import {WormholeL2GovernorMetadata} from "src/WormholeL2GovernorMetadata.sol"; 6 | 7 | contract WormholeL2GovernorMetadataOptimized is WormholeL2GovernorMetadata { 8 | /// @notice The internal proposal ID which is used by calldata optimized cast methods. 9 | uint16 internal nextInternalProposalId = 1; 10 | 11 | /// @notice The ID of the proposal mapped to an internal proposal ID. 12 | mapping(uint256 governorProposalId => uint16) public optimizedProposalIds; 13 | 14 | constructor(address _relayer, address _owner, address _l1BlockAddress, uint32 _castWindow) 15 | WormholeL2GovernorMetadata(_relayer, _owner, _l1BlockAddress, _castWindow) 16 | {} 17 | 18 | /// @inheritdoc L2GovernorMetadata 19 | function _addProposal(uint256 proposalId, uint256 voteStart, uint256 voteEnd, bool isCanceled) 20 | internal 21 | virtual 22 | override 23 | { 24 | super._addProposal(proposalId, voteStart, voteEnd, isCanceled); 25 | uint16 internalId = optimizedProposalIds[proposalId]; 26 | if (internalId == 0) { 27 | optimizedProposalIds[proposalId] = nextInternalProposalId; 28 | ++nextInternalProposalId; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/L1Block.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {SafeCast} from "openzeppelin/utils/math/SafeCast.sol"; 5 | import {IL1Block} from "src/interfaces/IL1Block.sol"; 6 | 7 | /// @dev Optimism has an L1 block contract that shares the same functions as this one. Since, we are 8 | /// testing on testnets without an L1 block contract we need to create our own implementation for 9 | /// testing purposes. We may also need this implementation for Arbitrum as well since they do not 10 | /// have an L1 block contract. 11 | /// 12 | /// Arbitrum L1 block reference: https://developer.arbitrum.io/time 13 | /// Optimism L1 block reference: 14 | /// https://github.com/ethereum-optimism/optimism/blob/65ec61dde94ffa93342728d324fecf474d228e1f/packages/contracts-bedrock/contracts/L2/L1Block.sol 15 | contract L1Block is IL1Block { 16 | // We could keep as uint64 17 | // https://github.com/sherlock-audit/2023-01-optimism-judging/issues/278 18 | function number() external view returns (uint64) { 19 | return SafeCast.toUint64(block.number); 20 | } 21 | 22 | function timestamp() external view returns (uint64) { 23 | return SafeCast.toUint64(block.timestamp); 24 | } 25 | 26 | function basefee() external view returns (uint256) { 27 | return block.basefee; 28 | } 29 | 30 | function hash() external view returns (bytes32) { 31 | return blockhash(block.number); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /test/harness/L1VotePoolHarness.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {CommonBase} from "forge-std/Base.sol"; 5 | 6 | import {L1VotePool} from "src/L1VotePool.sol"; 7 | import {FakeERC20} from "src/FakeERC20.sol"; 8 | 9 | contract L1VotePoolHarness is L1VotePool, CommonBase { 10 | constructor(address _governor) L1VotePool(_governor) {} 11 | 12 | function exposed_castVote(uint256 proposalId, ProposalVote memory vote) public { 13 | _castVote(proposalId, vote); 14 | } 15 | 16 | function _createExampleProposal(address l1Erc20) internal returns (uint256) { 17 | bytes memory proposalCalldata = abi.encode(FakeERC20.mint.selector, address(GOVERNOR), 100_000); 18 | 19 | address[] memory targets = new address[](1); 20 | bytes[] memory calldatas = new bytes[](1); 21 | uint256[] memory values = new uint256[](1); 22 | 23 | targets[0] = address(l1Erc20); 24 | calldatas[0] = proposalCalldata; 25 | values[0] = 0; 26 | 27 | return GOVERNOR.propose(targets, values, calldatas, "Proposal: To inflate token"); 28 | } 29 | 30 | function createProposalVote(address l1Erc20) public returns (uint256) { 31 | uint256 _proposalId = _createExampleProposal(l1Erc20); 32 | return _proposalId; 33 | } 34 | 35 | function _jumpToActiveProposal(uint256 proposalId) public { 36 | uint256 _deadline = GOVERNOR.proposalDeadline(proposalId); 37 | vm.roll(_deadline - 1); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /test/harness/WormholeL2VoteAggregatorHarness.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {L2VoteAggregator} from "src/L2VoteAggregator.sol"; 5 | import {WormholeL2VoteAggregator} from "src/WormholeL2VoteAggregator.sol"; 6 | import {GovernorMetadataMockBase} from "test/mock/GovernorMetadataMock.sol"; 7 | 8 | contract WormholeL2VoteAggregatorHarness is WormholeL2VoteAggregator, GovernorMetadataMockBase { 9 | constructor( 10 | address _votingToken, 11 | address _relayer, 12 | address _l1BlockAddress, 13 | uint16 _sourceChain, 14 | uint16 _targetChain, 15 | uint32 _castWindow 16 | ) 17 | WormholeL2VoteAggregator( 18 | _votingToken, 19 | _relayer, 20 | _l1BlockAddress, 21 | _sourceChain, 22 | _targetChain, 23 | msg.sender, 24 | _castWindow 25 | ) 26 | {} 27 | 28 | function createProposalVote(uint256 _proposalId, uint128 _against, uint128 _for, uint128 _abstain) 29 | public 30 | { 31 | _proposalVotes[_proposalId] = ProposalVote(_against, _for, _abstain); 32 | } 33 | 34 | function exposed_castVote( 35 | uint256 proposalId, 36 | address voter, 37 | VoteType support, 38 | string memory reason 39 | ) public returns (uint256) { 40 | return _castVote(proposalId, voter, uint8(support), reason); 41 | } 42 | 43 | function exposed_domainSeparatorV4() public view returns (bytes32) { 44 | return _domainSeparatorV4(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/WormholeL1VotePool.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import {L1VotePool} from "src/L1VotePool.sol"; 5 | 6 | contract WormholeL1VotePool is L1VotePool { 7 | /// @param _governor The address of the L1 Governor contract. 8 | constructor(address _governor) L1VotePool(_governor) {} 9 | 10 | /// @notice Receives a message from L2 and saves the proposal vote distribution. 11 | /// @param payload The payload that was sent to in the delivery request. 12 | function _receiveCastVoteWormholeMessages( 13 | bytes memory payload, 14 | bytes[] memory, 15 | bytes32, 16 | uint16, 17 | bytes32 18 | ) internal { 19 | (uint256 proposalId, uint128 againstVotes, uint128 forVotes, uint128 abstainVotes) = 20 | abi.decode(payload, (uint256, uint128, uint128, uint128)); 21 | 22 | ProposalVote memory existingProposalVote = proposalVotes[proposalId]; 23 | if ( 24 | existingProposalVote.againstVotes > againstVotes || existingProposalVote.forVotes > forVotes 25 | || existingProposalVote.abstainVotes > abstainVotes 26 | ) revert InvalidProposalVote(); 27 | 28 | // Save proposal vote 29 | proposalVotes[proposalId] = ProposalVote(againstVotes, forVotes, abstainVotes); 30 | 31 | _castVote( 32 | proposalId, 33 | ProposalVote( 34 | againstVotes - existingProposalVote.againstVotes, 35 | forVotes - existingProposalVote.forVotes, 36 | abstainVotes - existingProposalVote.abstainVotes 37 | ) 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/WormholeL2GovernorMetadata.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {WormholeReceiver} from "src/WormholeReceiver.sol"; 5 | import {WormholeBase} from "src/WormholeBase.sol"; 6 | import {L2GovernorMetadata} from "src/L2GovernorMetadata.sol"; 7 | 8 | /// @notice Use Wormhole to receive L1 proposal metadata. 9 | contract WormholeL2GovernorMetadata is L2GovernorMetadata, WormholeReceiver { 10 | /// @param _relayer The address of the WormholeL2GovernorMetadata contract. 11 | /// @param _owner The address that will become the contract owner. 12 | constructor(address _relayer, address _owner, address _l1BlockAddress, uint32 _castWindow) 13 | WormholeBase(_relayer, _owner) 14 | WormholeReceiver() 15 | L2GovernorMetadata(_l1BlockAddress, _castWindow) 16 | {} 17 | 18 | /// @notice Receives a message from L1 and saves the proposal metadata. 19 | /// @param payload The payload that was sent to in the delivery request. 20 | function receiveWormholeMessages( 21 | bytes calldata payload, 22 | bytes[] memory, 23 | bytes32 sourceAddress, 24 | uint16 sourceChain, 25 | bytes32 deliveryHash 26 | ) 27 | public 28 | override 29 | onlyRelayer 30 | isRegisteredSender(sourceChain, sourceAddress) 31 | replayProtect(deliveryHash) 32 | { 33 | (uint256 proposalId, uint256 voteStart, uint256 voteEnd, bool isCanceled) = 34 | abi.decode(payload, (uint256, uint256, uint256, bool)); 35 | 36 | _addProposal(proposalId, voteStart, voteEnd, isCanceled); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/WormholeSender.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {WormholeBase} from "src/WormholeBase.sol"; 5 | 6 | abstract contract WormholeSender is WormholeBase { 7 | /// @notice The chain id that is receiving the messages. 8 | uint16 public immutable TARGET_CHAIN; 9 | 10 | /// @notice The chain id where refunds will be sent. 11 | uint16 public immutable REFUND_CHAIN; 12 | 13 | /// @notice The gas limit for cross chain transactions. 14 | uint256 public gasLimit = 200_000; 15 | 16 | /// @notice Emitted when the gas limit has been updated 17 | /// @param oldValue The old gas limit value. 18 | /// @param newValue The new gas limit value. 19 | /// @param caller The address changing the gas limit. 20 | event GasLimitUpdate(uint256 oldValue, uint256 newValue, address caller); 21 | 22 | /// @param _refundChain The chain id of the chain sending the messages. 23 | /// @param _targetChain The chain id of the chain receiving the messages. 24 | constructor(uint16 _refundChain, uint16 _targetChain) { 25 | REFUND_CHAIN = _refundChain; 26 | TARGET_CHAIN = _targetChain; 27 | } 28 | 29 | /// @param targetChain The chain id of the chain receiving the messages. 30 | function quoteDeliveryCost(uint16 targetChain) public view virtual returns (uint256 cost) { 31 | (cost,) = WORMHOLE_RELAYER.quoteEVMDeliveryPrice(targetChain, 0, gasLimit); 32 | } 33 | 34 | /// @param _gasLimit The new gas limit value which is used to estimate the delivery cost for 35 | /// sending messages cross chain. 36 | function updateGasLimit(uint256 _gasLimit) public onlyOwner { 37 | emit GasLimitUpdate(gasLimit, _gasLimit, msg.sender); 38 | gasLimit = _gasLimit; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /script/WormholeMintOnL2.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {Script, stdJson} from "forge-std/Script.sol"; 5 | import {IERC20Mint} from "src/interfaces/IERC20Mint.sol"; 6 | import {IL1ERC20Bridge} from "src/interfaces/IL1ERC20Bridge.sol"; 7 | import {ScriptConstants} from "test/Constants.sol"; 8 | import {IWormholeRelayer} from "wormhole/interfaces/relayer/IWormholeRelayer.sol"; 9 | 10 | /// @dev A script to test that the L1 bridging functionality works. It will call the bridge on L1 11 | /// which will call the mint function on the L2 token. 12 | contract WormholeMintOnL2 is Script, ScriptConstants { 13 | using stdJson for string; 14 | 15 | function run() public { 16 | string memory deployFile = 17 | "broadcast/multi/WormholeL2FlexibleVotingDeploy.s.sol-latest/run.json"; // multi deployment 18 | string memory deployJson = vm.readFile(deployFile); 19 | 20 | // Get L1 bridge token address 21 | address deployedL1Token = 22 | deployJson.readAddress(".deployments[0].transactions[0].contractAddress"); 23 | 24 | // Get L1 bridge address 25 | address l1Bridge = deployJson.readAddress(".deployments[0].transactions[2].contractAddress"); 26 | 27 | setFallbackToDefaultRpcUrls(false); 28 | 29 | vm.createSelectFork(L1_CHAIN.rpcUrl); 30 | 31 | IL1ERC20Bridge bridge = IL1ERC20Bridge(address(l1Bridge)); 32 | IERC20Mint erc20 = IERC20Mint(address(deployedL1Token)); 33 | 34 | // Mint some L1 token 35 | vm.broadcast(); 36 | erc20.mint(msg.sender, 100_000e18); 37 | 38 | // Approve L1 token to be sent to the bridge 39 | vm.broadcast(); 40 | erc20.approve(address(bridge), 100_000e18); 41 | 42 | uint256 cost = bridge.quoteDeliveryCost(L2_CHAIN.wormholeChainId); 43 | 44 | vm.broadcast(); 45 | bridge.deposit{value: cost}(msg.sender, 100_000); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /test/harness/L2CountingFractionalHarness.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {L2CountingFractional} from "src/L2CountingFractional.sol"; 5 | 6 | contract L2CountingFractionalHarness is L2CountingFractional { 7 | function exposed_countVote( 8 | uint256 proposalId, 9 | address account, 10 | uint8 support, 11 | uint256 totalWeight, 12 | bytes memory voteData 13 | ) public { 14 | return _countVote(proposalId, account, support, totalWeight, voteData); 15 | } 16 | 17 | function exposed_countVoteNominal( 18 | uint256 proposalId, 19 | address account, 20 | uint128 totalWeight, 21 | uint8 support 22 | ) public { 23 | return _countVoteNominal(proposalId, account, totalWeight, support); 24 | } 25 | 26 | function exposed_countVoteFractional( 27 | uint256 proposalId, 28 | address account, 29 | uint128 totalWeight, 30 | bytes memory voteData 31 | ) public { 32 | return _countVoteFractional(proposalId, account, totalWeight, voteData); 33 | } 34 | 35 | function exposed_decodePackedVotes(bytes memory voteData) 36 | public 37 | pure 38 | returns (uint128 againstVotes, uint128 forVotes, uint128 abstainVotes) 39 | { 40 | return _decodePackedVotes(voteData); 41 | } 42 | 43 | function workaround_createProposalVote( 44 | uint256 proposalId, 45 | uint128 againstVotes, 46 | uint128 forVotes, 47 | uint128 abstainVotes 48 | ) public returns (ProposalVote memory) { 49 | _proposalVotes[proposalId] = ProposalVote(againstVotes, forVotes, abstainVotes); 50 | return _proposalVotes[proposalId]; 51 | } 52 | 53 | function workaround_createProposalVoterWeightCast( 54 | uint256 proposalId, 55 | address account, 56 | uint128 weight 57 | ) public { 58 | _proposalVotersWeightCast[proposalId][account] = weight; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /test/mock/L1BlockMock.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import {IL1Block} from "src/interfaces/IL1Block.sol"; 5 | import {TestConstants} from "test/Constants.sol"; 6 | 7 | /// @dev For use in proposal related tests that need access to the L1Block only for the purpose of 8 | /// calculating an L2 block to emit in the proposal created events, for the sake of compatibility 9 | /// with existing frontend clients. 10 | contract L1BlockMock is IL1Block, TestConstants { 11 | uint64 private TIMESTAMP = 0; 12 | uint256 private BASEFEE = 0; 13 | bytes32 private HASH = blockhash(MOCK_L1_BLOCK); 14 | 15 | function number() external pure returns (uint64) { 16 | return MOCK_L1_BLOCK; 17 | } 18 | 19 | function timestamp() external view returns (uint64) { 20 | return TIMESTAMP; 21 | } 22 | 23 | function basefee() external view returns (uint256) { 24 | return BASEFEE; 25 | } 26 | 27 | function hash() external view returns (bytes32) { 28 | return HASH; 29 | } 30 | 31 | /// @dev For use in tests to ensure a fuzzed L1 block vote end conforms to the internal invariant 32 | /// requirements inside the L2GovernorMetadata. 33 | function __boundL1VoteEnd(uint256 _l1VoteEnd) public view returns (uint256) { 34 | return bound(_l1VoteEnd, MOCK_L1_BLOCK + 3000, MOCK_L1_BLOCK + 2_628_000); 35 | } 36 | 37 | /// @dev Matches the implementation inside L2GovernorMetadata for the sake of test expectations. 38 | function __expectedL2BlockForFutureBlock(uint256 _l1BlockNumber) external view returns (uint256) { 39 | require( 40 | _l1BlockNumber > MOCK_L1_BLOCK + 1200, 41 | "L1BlockMock: Bad test parameters, _l1BlockNumber must be greater than mock current block number" 42 | ); 43 | 44 | uint256 _l1BlocksUntilEnd = _l1BlockNumber - MOCK_L1_BLOCK - 1200; 45 | return block.number + (_l1BlocksUntilEnd * 12) / 2; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/WormholeL2VoteAggregator.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {L2GovernorMetadata} from "src/WormholeL2GovernorMetadata.sol"; 5 | import {L2VoteAggregator} from "src/L2VoteAggregator.sol"; 6 | import {WormholeSender} from "src/WormholeSender.sol"; 7 | import {WormholeBase} from "src/WormholeBase.sol"; 8 | import {WormholeL2GovernorMetadata} from "src/WormholeL2GovernorMetadata.sol"; 9 | 10 | /// @notice A contract to collect votes on L2 to be bridged to L1. 11 | contract WormholeL2VoteAggregator is WormholeSender, L2VoteAggregator, WormholeL2GovernorMetadata { 12 | /// @param _votingToken The token used to vote on proposals. 13 | /// @param _relayer The Wormhole generic relayer contract. 14 | /// @param _l1BlockAddress The address of the L1Block contract. 15 | /// @param _sourceChain The chain sending the votes. 16 | /// @param _targetChain The target chain to bridge the votes to. 17 | constructor( 18 | address _votingToken, 19 | address _relayer, 20 | address _l1BlockAddress, 21 | uint16 _sourceChain, 22 | uint16 _targetChain, 23 | address _owner, 24 | uint32 _castWindow 25 | ) 26 | L2VoteAggregator(_votingToken) 27 | WormholeSender(_sourceChain, _targetChain) 28 | WormholeL2GovernorMetadata(_relayer, _owner, _l1BlockAddress, _castWindow) 29 | {} 30 | 31 | /// @notice Wormhole-specific implementation of `_bridgeVote`. 32 | /// @param proposalCalldata The calldata for the proposal. 33 | function _bridgeVote(bytes memory proposalCalldata) internal override { 34 | uint256 cost = quoteDeliveryCost(TARGET_CHAIN); 35 | WORMHOLE_RELAYER.sendPayloadToEvm{value: cost}( 36 | TARGET_CHAIN, 37 | L1_BRIDGE_ADDRESS, 38 | proposalCalldata, 39 | 0, // no receiver value needed since we're just passing a message 40 | gasLimit, 41 | REFUND_CHAIN, 42 | msg.sender 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/L1VotePool.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import {IGovernor} from "openzeppelin/governance/Governor.sol"; 5 | import {ERC20Votes} from "openzeppelin/token/ERC20/extensions/ERC20Votes.sol"; 6 | 7 | import {IFractionalGovernor} from "flexible-voting/src/interfaces/IFractionalGovernor.sol"; 8 | 9 | abstract contract L1VotePool { 10 | /// @notice The address of the L1 Governor contract. 11 | IGovernor public immutable GOVERNOR; 12 | 13 | // This param is ignored by the governor when voting with fractional 14 | // weights. It makes no difference what vote type this is. 15 | uint8 constant UNUSED_SUPPORT_PARAM = uint8(1); 16 | 17 | event VoteCast( 18 | address indexed voter, 19 | uint256 proposalId, 20 | uint256 voteAgainst, 21 | uint256 voteFor, 22 | uint256 voteAbstain 23 | ); 24 | 25 | /// @dev Thrown when proposal does not exist. 26 | error MissingProposal(); 27 | 28 | /// @dev Thrown when a proposal vote is invalid. 29 | error InvalidProposalVote(); 30 | 31 | /// @dev Contains the distribution of a proposal vote. 32 | struct ProposalVote { 33 | uint128 againstVotes; 34 | uint128 forVotes; 35 | uint128 abstainVotes; 36 | } 37 | 38 | /// @notice A mapping of proposal id to the proposal vote distribution. 39 | mapping(uint256 => ProposalVote) public proposalVotes; 40 | 41 | /// @param _governor The address of the L1 Governor contract. 42 | constructor(address _governor) { 43 | GOVERNOR = IGovernor(_governor); 44 | ERC20Votes(IFractionalGovernor(address(GOVERNOR)).token()).delegate(address(this)); 45 | } 46 | 47 | /// @notice Casts vote to the L1 Governor. 48 | /// @param proposalId The id of the proposal being cast. 49 | function _castVote(uint256 proposalId, ProposalVote memory vote) internal { 50 | bytes memory votes = abi.encodePacked(vote.againstVotes, vote.forVotes, vote.abstainVotes); 51 | 52 | GOVERNOR.castVoteWithReasonAndParams( 53 | proposalId, UNUSED_SUPPORT_PARAM, "rolled-up vote from governance L2 token holders", votes 54 | ); 55 | 56 | emit VoteCast(msg.sender, proposalId, vote.againstVotes, vote.forVotes, vote.abstainVotes); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /script/WormholeSendProposalToL2.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {Script, stdJson} from "forge-std/Script.sol"; 5 | import {IL1GovernorMetadataBridge} from "src/interfaces/IL1GovernorMetadataBridge.sol"; 6 | import {IGovernor} from "openzeppelin/governance/Governor.sol"; 7 | import {FakeERC20} from "src/FakeERC20.sol"; 8 | import {ScriptConstants} from "test/Constants.sol"; 9 | 10 | /// @dev This script will create an L1 and L2 governor metadata contract, and have the L1 contract 11 | /// pass a proposal to the L2 metadata contract. 12 | contract WormholeSendProposalToL2 is Script, ScriptConstants { 13 | using stdJson for string; 14 | 15 | function run() public { 16 | string memory deployFile = 17 | "broadcast/multi/WormholeL2FlexibleVotingDeploy.s.sol-latest/run.json"; // multi deployment 18 | string memory deployJson = vm.readFile(deployFile); 19 | 20 | address governorMock = deployJson.readAddress(".deployments[0].transactions[1].contractAddress"); 21 | 22 | address governorErc20 = 23 | deployJson.readAddress(".deployments[0].transactions[2].contractAddress"); 24 | address l1GovernorMetadataBridge = 25 | deployJson.readAddress(".deployments[0].transactions[3].contractAddress"); 26 | 27 | setFallbackToDefaultRpcUrls(false); 28 | vm.createSelectFork(L1_CHAIN.rpcUrl); 29 | bytes memory mintCalldata = abi.encode(FakeERC20.mint.selector, governorMock, 1 ether); 30 | 31 | address[] memory targets = new address[](1); 32 | bytes[] memory calldatas = new bytes[](1); 33 | uint256[] memory values = new uint256[](1); 34 | 35 | targets[0] = governorErc20; 36 | calldatas[0] = mintCalldata; 37 | values[0] = 0; 38 | 39 | // Create L2 Proposal 40 | vm.broadcast(); 41 | uint256 proposalId = IGovernor(governorMock).propose( 42 | targets, 43 | values, 44 | calldatas, 45 | string.concat("Proposal: To inflate governance token", string(abi.encode(block.number))) 46 | ); 47 | 48 | IL1GovernorMetadataBridge metadataBridge = IL1GovernorMetadataBridge(l1GovernorMetadataBridge); 49 | uint256 cost = metadataBridge.quoteDeliveryCost(L2_CHAIN.wormholeChainId); 50 | 51 | // Bridge proposal from the L1 to the L2 52 | vm.broadcast(); 53 | metadataBridge.bridgeProposalMetadata{value: cost}(proposalId); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /test/mock/GovernorMock.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import {ERC20Votes} from "openzeppelin/token/ERC20/extensions/ERC20Votes.sol"; 5 | import {GovernorVoteMocks} from "openzeppelin/mocks/governance/GovernorVoteMock.sol"; 6 | import {GovernorVotes} from "openzeppelin/governance/extensions/GovernorVotes.sol"; 7 | import {Governor} from "openzeppelin/governance/Governor.sol"; 8 | import {GovernorSettings} from "openzeppelin/governance/extensions/GovernorSettings.sol"; 9 | import {GovernorCountingFractional} from "flexible-voting/src/GovernorCountingFractional.sol"; 10 | import {Governor as FlexGovernor} from "openzeppelin-flexible-voting/governance/Governor.sol"; 11 | import { 12 | GovernorVotesComp, 13 | ERC20VotesComp 14 | } from "openzeppelin-flexible-voting/governance/extensions/GovernorVotesComp.sol"; 15 | import {IGovernor} from "openzeppelin-flexible-voting/governance/IGovernor.sol"; 16 | 17 | contract GovernorMock is GovernorVoteMocks { 18 | constructor(string memory _name, ERC20Votes _token) Governor(_name) GovernorVotes(_token) {} 19 | } 20 | 21 | contract GovernorFlexibleVotingMock is GovernorCountingFractional, GovernorVotesComp { 22 | constructor(string memory _name, ERC20VotesComp _token) 23 | FlexGovernor(_name) 24 | GovernorVotesComp(_token) 25 | {} 26 | 27 | function quorum(uint256) public pure override returns (uint256) { 28 | return 0; 29 | } 30 | 31 | function votingDelay() public pure override returns (uint256) { 32 | return 4; 33 | } 34 | 35 | function votingPeriod() public pure override returns (uint256) { 36 | return 16; 37 | } 38 | 39 | /// @dev We override this function to resolve ambiguity between inherited contracts. 40 | function castVoteWithReasonAndParamsBySig( 41 | uint256 proposalId, 42 | uint8 support, 43 | string calldata reason, 44 | bytes memory params, 45 | uint8 v, 46 | bytes32 r, 47 | bytes32 s 48 | ) public override(FlexGovernor, GovernorCountingFractional) returns (uint256) { 49 | return GovernorCountingFractional.castVoteWithReasonAndParamsBySig( 50 | proposalId, support, reason, params, v, r, s 51 | ); 52 | } 53 | 54 | function cancel( 55 | address[] memory targets, 56 | uint256[] memory values, 57 | bytes[] memory calldatas, 58 | bytes32 salt 59 | ) public returns (uint256 proposalId) { 60 | return _cancel(targets, values, calldatas, salt); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /test/mock/GovernorMetadataMock.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import {WormholeL2GovernorMetadata} from "src/WormholeL2GovernorMetadata.sol"; 5 | import {WormholeL2GovernorMetadataOptimized} from 6 | "src/optimized/WormholeL2GovernorMetadataOptimized.sol"; 7 | import {L2GovernorMetadata} from "src/L2GovernorMetadata.sol"; 8 | 9 | abstract contract GovernorMetadataMockBase is L2GovernorMetadata { 10 | function createProposal(uint256 proposalId, uint128 timeToProposalEnd) 11 | public 12 | returns (Proposal memory) 13 | { 14 | Proposal memory proposal = Proposal({ 15 | voteStart: block.number, 16 | voteEnd: block.number + timeToProposalEnd, 17 | isCanceled: false 18 | }); 19 | _proposals[proposalId] = proposal; 20 | return proposal; 21 | } 22 | 23 | function createProposal(uint256 proposalId, uint128 timeToProposalEnd, bool isCanceled) 24 | public 25 | returns (Proposal memory) 26 | { 27 | Proposal memory proposal = Proposal({ 28 | voteStart: block.number, 29 | voteEnd: block.number + timeToProposalEnd, 30 | isCanceled: isCanceled 31 | }); 32 | _proposals[proposalId] = proposal; 33 | return proposal; 34 | } 35 | 36 | function createProposal(uint256 proposalId, uint256 voteStart, uint256 voteEnd, bool isCanceled) 37 | public 38 | returns (Proposal memory) 39 | { 40 | Proposal memory proposal = 41 | Proposal({voteStart: voteStart, voteEnd: voteEnd, isCanceled: isCanceled}); 42 | _proposals[proposalId] = proposal; 43 | return proposal; 44 | } 45 | } 46 | 47 | contract GovernorMetadataMock is GovernorMetadataMockBase, WormholeL2GovernorMetadata { 48 | constructor(address _core) WormholeL2GovernorMetadata(_core, msg.sender, address(0x1b), 1200) { 49 | _proposals[1] = 50 | Proposal({voteStart: block.number, voteEnd: block.number + 3000, isCanceled: false}); 51 | } 52 | } 53 | 54 | contract GovernorMetadataOptimizedMock is 55 | GovernorMetadataMockBase, 56 | WormholeL2GovernorMetadataOptimized 57 | { 58 | constructor(address _core) 59 | WormholeL2GovernorMetadataOptimized(_core, msg.sender, address(0x1b), 1200) 60 | {} 61 | 62 | function _addProposal(uint256 proposalId, uint256 voteStart, uint256 voteEnd, bool isCanceled) 63 | internal 64 | virtual 65 | override(L2GovernorMetadata, WormholeL2GovernorMetadataOptimized) 66 | { 67 | WormholeL2GovernorMetadataOptimized._addProposal(proposalId, voteStart, voteEnd, isCanceled); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /test/L1VotePool.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {ERC20VotesComp} from 5 | "openzeppelin-flexible-voting/governance/extensions/GovernorVotesComp.sol"; 6 | 7 | import {FakeERC20} from "src/FakeERC20.sol"; 8 | import {L1VotePool} from "src/L1VotePool.sol"; 9 | import {L1VotePoolHarness} from "test/harness/L1VotePoolHarness.sol"; 10 | import {GovernorFlexibleVotingMock} from "test/mock/GovernorMock.sol"; 11 | import {TestConstants} from "test/Constants.sol"; 12 | 13 | contract L1VotePoolTest is TestConstants { 14 | L1VotePoolHarness l1VotePool; 15 | FakeERC20 l1Erc20; 16 | GovernorFlexibleVotingMock gov; 17 | 18 | event VoteCast( 19 | address indexed voter, 20 | uint256 proposalId, 21 | uint256 voteAgainst, 22 | uint256 voteFor, 23 | uint256 voteAbstain 24 | ); 25 | 26 | function setUp() public { 27 | l1Erc20 = new FakeERC20("Hello", "WRLD"); 28 | gov = new GovernorFlexibleVotingMock("Governor", ERC20VotesComp(address(l1Erc20))); 29 | l1VotePool = new L1VotePoolHarness(address(gov)); 30 | } 31 | } 32 | 33 | contract Constructor is L1VotePoolTest { 34 | function testFuzz_CorrectlySetConstructorArgs() public { 35 | L1VotePool pool = new L1VotePoolHarness(address(gov)); 36 | assertEq( 37 | address(pool.GOVERNOR()), address(gov), "The governor address has been set incorrectly." 38 | ); 39 | } 40 | } 41 | 42 | contract _castVote is L1VotePoolTest { 43 | function testFuzz_CorrectlyCastVoteToGovernor( 44 | uint32 _againstVotes, 45 | uint32 _forVotes, 46 | uint32 _abstainVotes, 47 | address _token 48 | ) public { 49 | vm.assume(uint128(_againstVotes) + _forVotes + _abstainVotes != 0); 50 | 51 | uint128 totalVotes = uint128(_againstVotes) + _forVotes + _abstainVotes; 52 | l1Erc20.mint(address(this), totalVotes); 53 | l1Erc20.approve(address(this), totalVotes); 54 | l1Erc20.transferFrom(address(this), address(l1VotePool), totalVotes); 55 | 56 | vm.roll(block.number + 1); // To checkpoint erc20 mint 57 | uint256 _proposalId = l1VotePool.createProposalVote(_token); 58 | l1VotePool._jumpToActiveProposal(_proposalId); 59 | 60 | vm.expectEmit(); 61 | emit VoteCast(address(this), _proposalId, _againstVotes, _forVotes, _abstainVotes); 62 | 63 | l1VotePool.exposed_castVote( 64 | _proposalId, 65 | L1VotePool.ProposalVote(uint128(_againstVotes), uint128(_forVotes), uint128(_abstainVotes)) 66 | ); 67 | 68 | // Governor votes 69 | (uint256 totalAgainstVotes, uint256 totalForVotes, uint256 totalAbstainVotes) = 70 | gov.proposalVotes(_proposalId); 71 | 72 | assertEq(totalAgainstVotes, _againstVotes, "Total Against value is incorrect"); 73 | assertEq(totalForVotes, _forVotes, "Total For value is incorrect"); 74 | assertEq(totalAbstainVotes, _abstainVotes, "Total Abstain value is incorrect"); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /test/WormholeSender.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {Test} from "forge-std/Test.sol"; 5 | import {IWormholeRelayer} from "wormhole/interfaces/relayer/IWormholeRelayer.sol"; 6 | import {WormholeRelayerBasicTest} from "wormhole-solidity-sdk/testing/WormholeRelayerTest.sol"; 7 | 8 | import {WormholeBase} from "src/WormholeBase.sol"; 9 | import {WormholeSender} from "src/WormholeSender.sol"; 10 | import {TestConstants} from "test/Constants.sol"; 11 | 12 | contract WormholeSenderHarness is WormholeSender { 13 | constructor(address _relayer, uint16 _sourceChain, uint16 _targetChain) 14 | WormholeBase(_relayer, msg.sender) 15 | WormholeSender(_sourceChain, _targetChain) 16 | {} 17 | 18 | function wormholeRelayer() public view returns (IWormholeRelayer) { 19 | return WORMHOLE_RELAYER; 20 | } 21 | } 22 | 23 | contract WormholeSenderTest is TestConstants, WormholeRelayerBasicTest { 24 | WormholeSender wormholeSender; 25 | 26 | event GasLimitUpdate(uint256 oldValue, uint256 newValue, address caller); 27 | 28 | constructor() { 29 | setForkChains(TESTNET, L1_CHAIN.wormholeChainId, L2_CHAIN.wormholeChainId); 30 | } 31 | 32 | function setUpSource() public override { 33 | wormholeSender = new WormholeSenderHarness( 34 | L1_CHAIN.wormholeRelayer, L1_CHAIN.wormholeChainId, L2_CHAIN.wormholeChainId 35 | ); 36 | } 37 | 38 | function setUpTarget() public override {} 39 | } 40 | 41 | contract Constructor is WormholeSenderTest { 42 | function testForkFuzz_CorrectlySetsAllArgs( 43 | address _wormholeRelayer, 44 | uint16 _sourceChain, 45 | uint16 _targetChain 46 | ) public { 47 | WormholeSenderHarness newSender = 48 | new WormholeSenderHarness(_wormholeRelayer, _sourceChain, _targetChain); 49 | 50 | assertEq( 51 | address(newSender.wormholeRelayer()), 52 | _wormholeRelayer, 53 | "Wormhole relayer is not set correctly" 54 | ); 55 | assertEq(newSender.REFUND_CHAIN(), _sourceChain, "Source chain is not correctly set"); 56 | assertEq(newSender.TARGET_CHAIN(), _targetChain, "Target chain is not correctly set"); 57 | } 58 | } 59 | 60 | contract QuoteDeliveryCost is WormholeSenderTest { 61 | function testFork_QuoteForDeliveryCostReturned() public { 62 | uint256 cost = wormholeSender.quoteDeliveryCost(L2_CHAIN.wormholeChainId); 63 | assertGt(cost, 0, "No cost was quoted"); 64 | } 65 | } 66 | 67 | contract UpdateGasLimit is WormholeSenderTest { 68 | function testFuzz_CorrectlyUpdateGasLimit(uint256 gasLimit) public { 69 | vm.expectEmit(); 70 | emit GasLimitUpdate(wormholeSender.gasLimit(), gasLimit, address(this)); 71 | 72 | wormholeSender.updateGasLimit(gasLimit); 73 | assertEq(wormholeSender.gasLimit(), gasLimit, "The gas limit is incorrect"); 74 | } 75 | 76 | function testFuzz_RevertIf_CalledByNonOwner(address owner, uint256 gasLimit) public { 77 | vm.expectRevert("Ownable: caller is not the owner"); 78 | vm.prank(owner); 79 | wormholeSender.updateGasLimit(gasLimit); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/WormholeReceiver.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {WormholeBase} from "src/WormholeBase.sol"; 5 | 6 | abstract contract WormholeReceiver is WormholeBase { 7 | /// @dev Function called with an address that isn't a relayer. 8 | error OnlyRelayerAllowed(); 9 | 10 | /// @dev Function was called with an unregistered sender address. 11 | error UnregisteredSender(bytes32 wormholeAddress); 12 | 13 | /// @dev Message was already delivered by Wormhole. 14 | error AlreadyProcessed(bytes32 deliveryHash); 15 | 16 | /// @dev A mapping of Wormhole chain ID to a mapping of wormhole serialized sender address to 17 | /// existence boolean. 18 | mapping(uint16 => mapping(bytes32 => bool)) public registeredSenders; 19 | 20 | /// @dev A mapping of message hash to a boolean indicating delivery. 21 | mapping(bytes32 => bool) public seenDeliveryVaaHashes; 22 | 23 | event RegisteredSenderSet( 24 | address indexed owner, uint16 indexed sourceChain, bytes32 indexed sourceAddress 25 | ); 26 | 27 | /// @notice The function the wormhole relayer calls when the DeliveryProvider competes a delivery. 28 | /// @dev Implementation should emit `WormholeMessageReceived`. 29 | /// @param payload The payload that was sent to in the delivery request. 30 | /// @param additionalVaas The additional VAAs that requested to be relayed. 31 | /// @param sourceAddress Address that requested this delivery. 32 | /// @param sourceChain Chain that the delivery was requested from. 33 | /// @param deliveryHash Unique identifier of this delivery request. 34 | function receiveWormholeMessages( 35 | bytes calldata payload, 36 | bytes[] memory additionalVaas, 37 | bytes32 sourceAddress, 38 | uint16 sourceChain, 39 | bytes32 deliveryHash 40 | ) public virtual; 41 | 42 | /// @dev Set a registered sender for a given chain. 43 | /// @param sourceChain The Wormhole ID of the source chain to set the registered sender. 44 | /// @param sourceAddress The source address for receiving a wormhole message. 45 | function setRegisteredSender(uint16 sourceChain, bytes32 sourceAddress) public onlyOwner { 46 | registeredSenders[sourceChain][sourceAddress] = true; 47 | emit RegisteredSenderSet(msg.sender, sourceChain, sourceAddress); 48 | } 49 | 50 | /// @dev Revert when the msg.sender is not the wormhole relayer. 51 | modifier onlyRelayer() { 52 | if (msg.sender != address(WORMHOLE_RELAYER)) revert OnlyRelayerAllowed(); 53 | _; 54 | } 55 | 56 | /// @dev Revert when a call is made by an unregistered address. 57 | modifier isRegisteredSender(uint16 sourceChain, bytes32 sourceAddress) { 58 | bool isRegistered = registeredSenders[sourceChain][sourceAddress]; 59 | if (!isRegistered || sourceAddress == bytes32(uint256(uint160(address(0))))) { 60 | revert UnregisteredSender(sourceAddress); 61 | } 62 | _; 63 | } 64 | 65 | modifier replayProtect(bytes32 deliveryHash) { 66 | if (seenDeliveryVaaHashes[deliveryHash]) revert AlreadyProcessed(deliveryHash); 67 | seenDeliveryVaaHashes[deliveryHash] = true; 68 | _; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/WormholeL1GovernorMetadataBridge.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {IGovernor} from "openzeppelin/governance/Governor.sol"; 5 | import {WormholeSender} from "src/WormholeSender.sol"; 6 | import {WormholeBase} from "src/WormholeBase.sol"; 7 | 8 | /// @notice Handles sending proposal metadata such as proposal id, start date and end date from L1 9 | /// to L2. 10 | contract WormholeL1GovernorMetadataBridge is WormholeSender { 11 | /// @notice The governor where proposals are fetched and bridged. 12 | IGovernor public immutable GOVERNOR; 13 | 14 | /// @notice The L2 governor metadata address where the message is sent on L2. 15 | address public L2_GOVERNOR_ADDRESS; 16 | 17 | /// @notice Indicates whether the contract has been initialized with the L2 governor metadata. It 18 | /// can only be called once. 19 | bool public INITIALIZED = false; 20 | 21 | /// @notice The proposal id is an invalid proposal id. 22 | error InvalidProposalId(); 23 | 24 | /// @dev Contract is already initialized with an L2 token. 25 | error AlreadyInitialized(); 26 | 27 | event ProposalMetadataBridged( 28 | uint16 indexed targetChain, 29 | address indexed targetGovernor, 30 | uint256 indexed proposalId, 31 | uint256 voteStart, 32 | uint256 voteEnd, 33 | bool isCanceled 34 | ); 35 | 36 | /// @param _governor The address of the L1 governor contract. 37 | /// @param _relayer The address of the L1 Wormhole relayer contract. 38 | /// @param _sourceChain The chain id sending the wormhole messages. 39 | /// @param _targetChain The chain id receiving the wormhole messages. 40 | constructor( 41 | address _governor, 42 | address _relayer, 43 | uint16 _sourceChain, 44 | uint16 _targetChain, 45 | address _owner 46 | ) WormholeBase(_relayer, _owner) WormholeSender(_sourceChain, _targetChain) { 47 | GOVERNOR = IGovernor(_governor); 48 | } 49 | 50 | /// @param l2GovernorMetadata The address of the L2 governor metadata contract. 51 | function initialize(address l2GovernorMetadata) public { 52 | if (INITIALIZED) revert AlreadyInitialized(); 53 | INITIALIZED = true; 54 | L2_GOVERNOR_ADDRESS = l2GovernorMetadata; 55 | } 56 | 57 | /// @notice Publishes a messages with the proposal id, start block and end block 58 | /// @param proposalId The id of the proposal to bridge. 59 | /// @return sequence An identifier for the message published to L2. 60 | function bridgeProposalMetadata(uint256 proposalId) public payable returns (uint256 sequence) { 61 | uint256 voteStart = GOVERNOR.proposalSnapshot(proposalId); 62 | if (voteStart == 0) revert InvalidProposalId(); 63 | uint256 voteEnd = GOVERNOR.proposalDeadline(proposalId); 64 | 65 | bool isCanceled = GOVERNOR.state(proposalId) == IGovernor.ProposalState.Canceled; 66 | 67 | bytes memory proposalCalldata = abi.encode(proposalId, voteStart, voteEnd, isCanceled); 68 | uint256 cost = quoteDeliveryCost(TARGET_CHAIN); 69 | 70 | sequence = WORMHOLE_RELAYER.sendPayloadToEvm{value: cost}( 71 | TARGET_CHAIN, 72 | L2_GOVERNOR_ADDRESS, 73 | proposalCalldata, 74 | 0, // no receiver value needed since we're just passing a message 75 | gasLimit, 76 | REFUND_CHAIN, 77 | msg.sender 78 | ); 79 | emit ProposalMetadataBridged( 80 | TARGET_CHAIN, L2_GOVERNOR_ADDRESS, proposalId, voteStart, voteEnd, isCanceled 81 | ); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /test/L2GovernorMetadata.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import "@openzeppelin/contracts/utils/Strings.sol"; 5 | 6 | import {L2GovernorMetadata} from "src/L2GovernorMetadata.sol"; 7 | import {L2GovernorMetadataHarness} from "test/harness/L2GovernorMetadataHarness.sol"; 8 | import {TestConstants} from "test/Constants.sol"; 9 | import {L1BlockMock} from "test/mock/L1BlockMock.sol"; 10 | 11 | contract L2GovernorMetadataTest is TestConstants { 12 | L2GovernorMetadataHarness l2GovernorMetadata; 13 | L1BlockMock mockL1Block; 14 | 15 | event ProposalCreated( 16 | uint256 proposalId, 17 | address proposer, 18 | address[] targets, 19 | uint256[] values, 20 | string[] signatures, 21 | bytes[] calldatas, 22 | uint256 startBlock, 23 | uint256 endBlock, 24 | string description 25 | ); 26 | 27 | event ProposalCanceled(uint256 proposalId); 28 | 29 | function setUp() public { 30 | mockL1Block = new L1BlockMock(); 31 | l2GovernorMetadata = new L2GovernorMetadataHarness(address(mockL1Block)); 32 | } 33 | } 34 | 35 | /// @dev This also tests `getProposals` so we will omit a test contract for that method. 36 | contract _addProposal is L2GovernorMetadataTest { 37 | function testFuzz_CorrectlyAddProposal(uint256 proposalId, uint256 voteStart, uint256 voteEnd) 38 | public 39 | { 40 | voteEnd = mockL1Block.__boundL1VoteEnd(voteEnd); 41 | 42 | vm.expectEmit(); 43 | emit ProposalCreated( 44 | proposalId, 45 | address(0), 46 | new address[](0), 47 | new uint256[](0), 48 | new string[](0), 49 | new bytes[](0), 50 | block.number, 51 | mockL1Block.__expectedL2BlockForFutureBlock(voteEnd), 52 | string.concat("Mainnet proposal ", Strings.toString(proposalId)) 53 | ); 54 | 55 | l2GovernorMetadata.exposed_addProposal(proposalId, voteStart, voteEnd, false); 56 | L2GovernorMetadata.Proposal memory proposal = l2GovernorMetadata.exposed_proposals(proposalId); 57 | 58 | assertEq(proposal.voteStart, voteStart, "The voteStart has been set incorrectly"); 59 | assertEq(proposal.voteEnd, voteEnd, "The voteEnd has been set incorrectly"); 60 | assertEq(proposal.isCanceled, false, "The isCanceled has been set incorrectly"); 61 | } 62 | 63 | function testFuzz_CorrectlyAddCanceledProposal( 64 | uint256 proposalId, 65 | uint256 voteStart, 66 | uint256 voteEnd 67 | ) public { 68 | vm.expectEmit(); 69 | emit ProposalCanceled(proposalId); 70 | 71 | l2GovernorMetadata.exposed_addProposal(proposalId, voteStart, voteEnd, true); 72 | L2GovernorMetadata.Proposal memory proposal = l2GovernorMetadata.exposed_proposals(proposalId); 73 | 74 | assertEq(proposal.voteStart, voteStart, "The voteStart has been set incorrectly"); 75 | assertEq(proposal.voteEnd, voteEnd, "The voteEnd has been set incorrectly"); 76 | assertEq(proposal.isCanceled, true, "The isCanceled has been set incorrectly"); 77 | } 78 | } 79 | 80 | contract _l2BlockForFutureL1Block is L2GovernorMetadataTest { 81 | function testFuzz_RevertIf_BlockNumberIsTooSmall(uint64 _blockNumber) public { 82 | _blockNumber = uint64(bound(_blockNumber, 0, mockL1Block.number() - 1)); 83 | vm.expectRevert(L2GovernorMetadata.PastBlockNumber.selector); 84 | l2GovernorMetadata.exposed_l2BlockForFutureL1Block(_blockNumber); 85 | } 86 | 87 | function testFuzz_BlockNumberGreaterThanCurrentBlock(uint256 _l1VoteEnd) public { 88 | _l1VoteEnd = mockL1Block.__boundL1VoteEnd(_l1VoteEnd); 89 | uint64 endBlock = uint64(l2GovernorMetadata.exposed_l2BlockForFutureL1Block(_l1VoteEnd)); 90 | assertGt(endBlock, block.number, "L2 vote end block is not greater than the current L1 block"); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | push: 7 | branches: 8 | - main 9 | 10 | env: 11 | FOUNDRY_PROFILE: ci 12 | POLYGON_MUMBAI_RPC_URL: ${{ secrets.POLYGON_MUMBAI_RPC_URL }} 13 | AVALANCHE_FUJI_RPC_URL: ${{ secrets.AVALANCHE_FUJI_RPC_URL }} 14 | OPTIMISM_RPC_URL: ${{ secrets.OPTIMISM_RPC_URL }} 15 | ETHEREUM_RPC_URL: ${{ secrets.ETHEREUM_RPC_URL }} 16 | L1_CHAIN_ID: 1 17 | L2_CHAIN_ID: 10 18 | TESTNET: false 19 | 20 | jobs: 21 | build: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v4 25 | 26 | - name: Install Foundry 27 | uses: foundry-rs/foundry-toolchain@v1 28 | 29 | - name: Build contracts 30 | run: | 31 | forge --version 32 | forge build --sizes 33 | 34 | test: 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v4 38 | 39 | - name: Install Foundry 40 | uses: foundry-rs/foundry-toolchain@v1 41 | 42 | - name: Run tests 43 | run: forge test 44 | 45 | coverage: 46 | runs-on: ubuntu-latest 47 | steps: 48 | - uses: actions/checkout@v4 49 | 50 | - name: Install Foundry 51 | uses: foundry-rs/foundry-toolchain@v1 52 | 53 | - name: Run coverage 54 | run: forge coverage --report summary --report lcov --ir-minimum 55 | 56 | # To ignore coverage for certain directories modify the paths in this step as needed. The 57 | # below default ignores coverage results for the test and script directories. Alternatively, 58 | # to include coverage in all directories, comment out this step. Note that because this 59 | # filtering applies to the lcov file, the summary table generated in the previous step will 60 | # still include all files and directories. 61 | # The `--rc lcov_branch_coverage=1` part keeps branch info in the filtered report, since lcov 62 | # defaults to removing branch info. 63 | - name: Filter directories 64 | run: | 65 | sudo apt update && sudo apt install -y lcov 66 | lcov --remove lcov.info 'test/*' 'script/*' 'src/FakeERC20.sol' 'src/L1Block.sol' --output-file lcov.info --rc lcov_branch_coverage=1 67 | 68 | # This step posts a detailed coverage report as a comment and deletes previous comments on 69 | # each push. The below step is used to fail coverage if the specified coverage threshold is 70 | # not met. The below step can post a comment (when it's `github-token` is specified) but it's 71 | # not as useful, and this action cannot fail CI based on a minimum coverage threshold, which 72 | # is why we use both in this way. 73 | - name: Post coverage report 74 | if: github.event_name == 'pull_request' # This action fails when ran outside of a pull request. 75 | uses: romeovs/lcov-reporter-action@v0.3.1 76 | with: 77 | delete-old-comments: true 78 | lcov-file: ./lcov.info 79 | github-token: ${{ secrets.GITHUB_TOKEN }} # Adds a coverage summary comment to the PR. 80 | 81 | - name: Verify minimum coverage 82 | uses: zgosalvez/github-actions-report-lcov@v2 83 | with: 84 | coverage-files: ./lcov.info 85 | minimum-coverage: 94 # Set coverage threshold. 86 | 87 | lint: 88 | runs-on: ubuntu-latest 89 | steps: 90 | - uses: actions/checkout@v4 91 | 92 | - name: Install Foundry 93 | uses: foundry-rs/foundry-toolchain@v1 94 | 95 | - name: Install scopelint 96 | uses: engineerd/configurator@v0.0.8 97 | with: 98 | name: scopelint 99 | repo: ScopeLift/scopelint 100 | fromGitHubReleases: true 101 | version: latest 102 | pathInArchive: scopelint-x86_64-linux/scopelint 103 | urlTemplate: https://github.com/ScopeLift/scopelint/releases/download/{{version}}/scopelint-x86_64-linux.tar.xz 104 | token: ${{ secrets.GITHUB_TOKEN }} 105 | 106 | - name: Check formatting 107 | run: | 108 | scopelint --version 109 | scopelint check 110 | -------------------------------------------------------------------------------- /test/harness/WormholeL1VotePoolHarness.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {CommonBase} from "forge-std/Base.sol"; 5 | 6 | import {FakeERC20} from "src/FakeERC20.sol"; 7 | import {WormholeBase} from "src/WormholeBase.sol"; 8 | import {WormholeL1VotePool} from "src/WormholeL1VotePool.sol"; 9 | import {WormholeReceiver} from "src/WormholeReceiver.sol"; 10 | 11 | contract WormholeL1VotePoolHarness is WormholeL1VotePool, WormholeReceiver, CommonBase { 12 | constructor(address _relayer, address _l1Governor) 13 | WormholeBase(_relayer, msg.sender) 14 | WormholeL1VotePool(_l1Governor) 15 | {} 16 | 17 | function receiveWormholeMessages( 18 | bytes calldata payload, 19 | bytes[] memory additionalVaas, 20 | bytes32 sourceAddress, 21 | uint16 sourceChain, 22 | bytes32 deliveryHash 23 | ) 24 | public 25 | override 26 | onlyRelayer 27 | isRegisteredSender(sourceChain, sourceAddress) 28 | replayProtect(deliveryHash) 29 | { 30 | (uint256 proposalId,,,) = abi.decode(payload, (uint256, uint128, uint128, uint128)); 31 | _jumpToActiveProposal(proposalId); 32 | _receiveCastVoteWormholeMessages( 33 | payload, additionalVaas, sourceAddress, sourceChain, deliveryHash 34 | ); 35 | } 36 | 37 | function receiveWormholeMessages( 38 | bytes memory payload, 39 | bytes[] memory additionalVaas, 40 | bytes32 sourceAddress, 41 | uint16 sourceChain, 42 | bytes32 deliveryHash, 43 | bool jump 44 | ) public onlyRelayer isRegisteredSender(sourceChain, sourceAddress) { 45 | (uint256 proposalId,,,) = abi.decode(payload, (uint256, uint128, uint128, uint128)); 46 | if (jump) _jumpToActiveProposal(proposalId); 47 | _receiveCastVoteWormholeMessages( 48 | payload, additionalVaas, sourceAddress, sourceChain, deliveryHash 49 | ); 50 | } 51 | 52 | function cancel(address l1Erc20) public returns (uint256) { 53 | bytes memory proposalCalldata = abi.encode(FakeERC20.mint.selector, address(GOVERNOR), 100_000); 54 | 55 | address[] memory targets = new address[](1); 56 | bytes[] memory calldatas = new bytes[](1); 57 | uint256[] memory values = new uint256[](1); 58 | 59 | targets[0] = address(l1Erc20); 60 | calldatas[0] = proposalCalldata; 61 | values[0] = 0; 62 | 63 | return 64 | GOVERNOR.cancel(targets, values, calldatas, keccak256(bytes("Proposal: To inflate token"))); 65 | } 66 | 67 | function _createExampleProposal(address l1Erc20) internal returns (uint256) { 68 | bytes memory proposalCalldata = abi.encode(FakeERC20.mint.selector, address(GOVERNOR), 100_000); 69 | 70 | address[] memory targets = new address[](1); 71 | bytes[] memory calldatas = new bytes[](1); 72 | uint256[] memory values = new uint256[](1); 73 | 74 | targets[0] = address(l1Erc20); 75 | calldatas[0] = proposalCalldata; 76 | values[0] = 0; 77 | 78 | return GOVERNOR.propose(targets, values, calldatas, "Proposal: To inflate token"); 79 | } 80 | 81 | function createProposalVote(address l1Erc20) public returns (uint256) { 82 | uint256 _proposalId = _createExampleProposal(l1Erc20); 83 | return _proposalId; 84 | } 85 | 86 | function createProposalVote(address l1Erc20, uint128 _against, uint128 _for, uint128 _abstain) 87 | public 88 | returns (uint256) 89 | { 90 | uint256 _proposalId = _createExampleProposal(l1Erc20); 91 | _jumpToActiveProposal(_proposalId); 92 | _receiveCastVoteWormholeMessages( 93 | abi.encode(_proposalId, _against, _for, _abstain), 94 | new bytes[](0), 95 | bytes32(""), 96 | uint16(0), 97 | bytes32("") 98 | ); 99 | return _proposalId; 100 | } 101 | 102 | function _jumpToActiveProposal(uint256 proposalId) public { 103 | uint256 _deadline = GOVERNOR.proposalDeadline(proposalId); 104 | vm.roll(_deadline - 1); 105 | } 106 | 107 | function _jumpToProposalEnd(uint256 proposalId) external { 108 | uint256 _deadline = GOVERNOR.proposalDeadline(proposalId); 109 | vm.roll(_deadline); 110 | } 111 | 112 | function _jumpToProposalEnd(uint256 proposalId, uint32 additionalBlocks) external { 113 | uint256 _deadline = GOVERNOR.proposalDeadline(proposalId); 114 | vm.roll(_deadline + additionalBlocks); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/WormholeL2ERC20.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {ERC20} from "openzeppelin/token/ERC20/ERC20.sol"; 5 | import {ERC20Votes} from "openzeppelin/token/ERC20/extensions/ERC20Votes.sol"; 6 | import {ERC20Permit} from "openzeppelin/token/ERC20/extensions/ERC20Permit.sol"; 7 | import {SafeCast} from "openzeppelin/utils/math/SafeCast.sol"; 8 | import {WormholeSender} from "src/WormholeSender.sol"; 9 | import {WormholeReceiver} from "src/WormholeReceiver.sol"; 10 | import {WormholeBase} from "src/WormholeBase.sol"; 11 | 12 | import {IL1Block} from "src/interfaces/IL1Block.sol"; 13 | 14 | contract WormholeL2ERC20 is ERC20Votes, WormholeReceiver, WormholeSender { 15 | /// @notice The contract that handles fetching the L1 block on the L2. 16 | IL1Block public immutable L1_BLOCK; 17 | 18 | /// @notice Used to indicate whether the contract has been initialized with the L2 token address. 19 | bool public INITIALIZED = false; 20 | 21 | /// @notice The L1 bridge address. 22 | address public L1_BRIDGE_ADDRESS; 23 | 24 | /// @dev Contract is already initialized with an L2 token. 25 | error AlreadyInitialized(); 26 | 27 | event TokenBridged( 28 | address indexed account, 29 | address indexed targetAddress, 30 | uint16 targetChain, 31 | uint256 amount, 32 | address targetToken 33 | ); 34 | 35 | /// @param _name The name of the ERC20 token. 36 | /// @param _symbol The symbol of the ERC20 token. 37 | /// @param _relayer The address of the Wormhole relayer. 38 | /// @param _l1Block The contract that manages the clock for the ERC20. 39 | /// @param _sourceChain The chain sending wormhole messages. 40 | /// @param _targetChain The chain to send wormhole messages. 41 | constructor( 42 | string memory _name, 43 | string memory _symbol, 44 | address _relayer, 45 | address _l1Block, 46 | uint16 _sourceChain, 47 | uint16 _targetChain, 48 | address _owner 49 | ) 50 | WormholeBase(_relayer, _owner) 51 | ERC20(_name, _symbol) 52 | ERC20Permit(_name) 53 | WormholeSender(_sourceChain, _targetChain) 54 | { 55 | L1_BLOCK = IL1Block(_l1Block); 56 | } 57 | 58 | /// @notice Must be called before bridging tokens to L2. 59 | /// @param l1BridgeAddress The address of the L1 token for this L2 token. 60 | function initialize(address l1BridgeAddress) public { 61 | if (INITIALIZED) revert AlreadyInitialized(); 62 | INITIALIZED = true; 63 | L1_BRIDGE_ADDRESS = l1BridgeAddress; 64 | } 65 | 66 | /// @notice Receives a message from L1 and mints L2 tokens. 67 | /// @param payload The payload that was sent to in the delivery request. 68 | function receiveWormholeMessages( 69 | bytes calldata payload, 70 | bytes[] memory, 71 | bytes32 sourceAddress, 72 | uint16 sourceChain, 73 | bytes32 deliveryHash 74 | ) 75 | public 76 | virtual 77 | override 78 | onlyRelayer 79 | isRegisteredSender(sourceChain, sourceAddress) 80 | replayProtect(deliveryHash) 81 | { 82 | address account = address(bytes20(payload[:20])); 83 | _mint(account, uint224(bytes28(payload[20:48]))); 84 | _delegate(account, account); 85 | } 86 | 87 | /// @dev Clock used for flagging checkpoints. 88 | function clock() public view override returns (uint48) { 89 | return SafeCast.toUint48(L1_BLOCK.number()); 90 | } 91 | 92 | /// @dev Description of the clock 93 | function CLOCK_MODE() public view virtual override returns (string memory) { 94 | return "mode=blocknumber&from=eip155:1"; 95 | } 96 | 97 | /// @notice Burn L2 tokens and unlock tokens on the L1. 98 | /// @param account The account where the tokens will be transferred. 99 | /// @param amount The amount of tokens to be unlocked. 100 | function l1Unlock(address account, uint256 amount) external payable returns (uint256 sequence) { 101 | _burn(msg.sender, amount); 102 | bytes memory withdrawCalldata = abi.encodePacked(account, amount); 103 | uint256 cost = quoteDeliveryCost(TARGET_CHAIN); 104 | sequence = WORMHOLE_RELAYER.sendPayloadToEvm{value: cost}( 105 | TARGET_CHAIN, 106 | L1_BRIDGE_ADDRESS, 107 | withdrawCalldata, 108 | 0, // no receiver value needed since we're just passing a message 109 | gasLimit, 110 | REFUND_CHAIN, 111 | msg.sender 112 | ); 113 | emit TokenBridged(msg.sender, account, TARGET_CHAIN, amount, L1_BRIDGE_ADDRESS); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /test/Constants.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import {Test} from "forge-std/Test.sol"; 5 | import {CommonBase} from "forge-std/Base.sol"; 6 | 7 | contract BaseConstants is CommonBase { 8 | BaseConstants.ChainConfig L1_CHAIN; 9 | BaseConstants.ChainConfig L2_CHAIN; 10 | uint256 immutable L1_CHAIN_ID = vm.envOr("L1_CHAIN_ID", uint256(43_113)); 11 | uint256 immutable L2_CHAIN_ID = vm.envOr("L2_CHAIN_ID", uint256(80_001)); 12 | bool immutable TESTNET = vm.envOr("TESTNET", true); 13 | 14 | struct ChainConfig { 15 | uint16 wormholeChainId; 16 | address wormholeRelayer; 17 | uint256 chainId; 18 | string rpcUrl; 19 | } 20 | 21 | mapping(uint256 chainId => ChainConfig) public chainInfos; 22 | 23 | constructor() { 24 | _initChains(); 25 | L1_CHAIN = chainInfos[L1_CHAIN_ID]; 26 | L2_CHAIN = chainInfos[L2_CHAIN_ID]; 27 | } 28 | 29 | function _initChains() internal { 30 | if (TESTNET) { 31 | chainInfos[5] = ChainConfig({ 32 | wormholeChainId: 2, 33 | wormholeRelayer: 0x28D8F1Be96f97C1387e94A53e00eCcFb4E75175a, 34 | chainId: 5, 35 | rpcUrl: vm.envOr("GOERLI_RPC_URL", string("https://ethereum-goerli.publicnode.com")) 36 | }); 37 | chainInfos[420] = ChainConfig({ 38 | wormholeChainId: 24, 39 | wormholeRelayer: 0x01A957A525a5b7A72808bA9D10c389674E459891, 40 | chainId: 420, 41 | rpcUrl: vm.envOr("OPTIMISM_GOERLI_RPC_URL", string("https://optimism.publicnode.com")) 42 | }); 43 | chainInfos[43_113] = ChainConfig({ 44 | wormholeChainId: 6, 45 | wormholeRelayer: 0xA3cF45939bD6260bcFe3D66bc73d60f19e49a8BB, 46 | chainId: 43_113, 47 | rpcUrl: vm.envOr( 48 | "AVALANCHE_FUJI_RPC_URL", string("https://api.avax-test.network/ext/bc/C/rpc") 49 | ) 50 | }); 51 | chainInfos[80_001] = ChainConfig({ 52 | wormholeChainId: 5, 53 | wormholeRelayer: 0x0591C25ebd0580E0d4F27A82Fc2e24E7489CB5e0, 54 | chainId: 80_001, 55 | rpcUrl: vm.envOr("POLYGON_MUMBAI_RPC_URL", string("https://rpc.ankr.com/polygon_mumbai")) 56 | }); 57 | chainInfos[11_155_111] = ChainConfig({ 58 | wormholeChainId: 10_002, 59 | wormholeRelayer: 0x7B1bD7a6b4E61c2a123AC6BC2cbfC614437D0470, 60 | chainId: 11_155_111, 61 | rpcUrl: vm.envOr("SEPOLIA_RPC_URL", string("https://sepolia.optimism.io")) 62 | }); 63 | chainInfos[11_155_420] = ChainConfig({ 64 | wormholeChainId: 10_005, 65 | wormholeRelayer: 0x93BAD53DDfB6132b0aC8E37f6029163E63372cEE, 66 | chainId: 11_155_420, 67 | rpcUrl: vm.envOr("OPTIMISM_SEPOLIA_RPC_URL", string("https://1rpc.io/sepolia")) 68 | }); 69 | return; 70 | } 71 | chainInfos[1] = ChainConfig({ 72 | wormholeChainId: 2, 73 | wormholeRelayer: 0x27428DD2d3DD32A4D7f7C497eAaa23130d894911, 74 | chainId: 1, 75 | rpcUrl: vm.envOr("ETHEREUM_RPC_URL", string("https://eth.llamarpc.com")) 76 | }); 77 | chainInfos[10] = ChainConfig({ 78 | wormholeChainId: 24, 79 | wormholeRelayer: 0x27428DD2d3DD32A4D7f7C497eAaa23130d894911, 80 | chainId: 10, 81 | rpcUrl: vm.envOr("OPTIMISM_RPC_URL", string("https://optimism.publicnode.com")) 82 | }); 83 | chainInfos[5] = ChainConfig({ 84 | wormholeChainId: 2, 85 | wormholeRelayer: 0x28D8F1Be96f97C1387e94A53e00eCcFb4E75175a, 86 | chainId: 5, 87 | rpcUrl: vm.envOr("GOERLI_RPC_URL", string("https://rpc.ankr.com/eth_goerli")) 88 | }); 89 | chainInfos[420] = ChainConfig({ 90 | wormholeChainId: 24, 91 | wormholeRelayer: 0x01A957A525a5b7A72808bA9D10c389674E459891, 92 | chainId: 420, 93 | rpcUrl: vm.envOr("OPTIMISM_GOERLI_RPC_URL", string("https://optimism-goerli.publicnode.com")) 94 | }); 95 | } 96 | 97 | function _toWormholeAddress(address addr) internal pure returns (bytes32) { 98 | return bytes32(uint256(uint160(addr))); 99 | } 100 | } 101 | 102 | contract ScriptConstants is BaseConstants {} 103 | 104 | contract TestConstants is BaseConstants, Test { 105 | address constant ARBITRARY_ADDRESS = 0xEAC5F0d4A9a45E1f9FdD0e7e2882e9f60E301156; 106 | bytes32 constant MOCK_WORMHOLE_SERIALIZED_ADDRESS = bytes32(uint256(uint160(ARBITRARY_ADDRESS))); 107 | 108 | // An arbitrary, large, mainnet-block-like number for use with the mock L1 Block 109 | uint64 constant MOCK_L1_BLOCK = 18_442_511; 110 | } 111 | -------------------------------------------------------------------------------- /test/WormholeReceiver.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {Test} from "forge-std/Test.sol"; 5 | 6 | import {WormholeBase} from "src/WormholeBase.sol"; 7 | import {WormholeReceiver} from "src/WormholeReceiver.sol"; 8 | import {TestConstants} from "test/Constants.sol"; 9 | 10 | contract WormholeReceiverTestHarness is WormholeReceiver { 11 | constructor(address _relayer, address _owner) WormholeBase(_relayer, _owner) {} 12 | function receiveWormholeMessages( 13 | bytes calldata payload, 14 | bytes[] memory additionalVaas, 15 | bytes32 sourceAddress, 16 | uint16 sourceChain, 17 | bytes32 deliveryHash 18 | ) public virtual override {} 19 | 20 | function onlyRelayerModifierFunc() public onlyRelayer {} 21 | 22 | function isRegisteredSenderModifierFunc(uint16 sourceChain, bytes32 senderAddress) 23 | public 24 | isRegisteredSender(sourceChain, senderAddress) 25 | {} 26 | 27 | function exposed_replayProtect(bytes32 deliveryHash) public replayProtect(deliveryHash) {} 28 | } 29 | 30 | contract WormholeReceiverTest is Test, TestConstants { 31 | WormholeReceiverTestHarness receiver; 32 | 33 | event RegisteredSenderSet( 34 | address indexed owner, uint16 indexed sourceChain, bytes32 indexed sourceAddress 35 | ); 36 | 37 | function setUp() public { 38 | receiver = new WormholeReceiverTestHarness(L1_CHAIN.wormholeRelayer, msg.sender); 39 | } 40 | } 41 | 42 | contract OnlyRelayer is Test, TestConstants { 43 | function testFuzz_SucceedIfCalledByWormholeRelayer(address relayer) public { 44 | WormholeReceiverTestHarness receiver = new WormholeReceiverTestHarness(relayer, msg.sender); 45 | 46 | vm.prank(relayer); 47 | receiver.onlyRelayerModifierFunc(); 48 | } 49 | 50 | function testFuzz_RevertIf_NotCalledByWormholeRelayer(address relayer) public { 51 | vm.assume(relayer != address(this)); 52 | WormholeReceiverTestHarness receiver = new WormholeReceiverTestHarness(relayer, msg.sender); 53 | 54 | vm.expectRevert(WormholeReceiver.OnlyRelayerAllowed.selector); 55 | receiver.onlyRelayerModifierFunc(); 56 | } 57 | } 58 | 59 | contract SetRegisteredSender is WormholeReceiverTest { 60 | function testFuzz_SuccessfullySetRegisteredSender(uint16 sourceChain, address sender) public { 61 | bytes32 senderBytes = bytes32(uint256(uint160(address(sender)))); 62 | assertEq(receiver.owner(), msg.sender, "Owner is incorrect"); 63 | 64 | vm.expectEmit(); 65 | emit RegisteredSenderSet(receiver.owner(), sourceChain, senderBytes); 66 | vm.prank(receiver.owner()); 67 | receiver.setRegisteredSender(sourceChain, senderBytes); 68 | 69 | assertEq( 70 | receiver.registeredSenders(sourceChain, senderBytes), 71 | true, 72 | "Registered sender on source chain is not correct" 73 | ); 74 | } 75 | 76 | function testFuzz_RevertIf_OwnerIsNotTheCaller(uint16 sourceChain, address sender, address caller) 77 | public 78 | { 79 | vm.assume(caller != receiver.owner()); 80 | bytes32 senderBytes = bytes32(uint256(uint160(address(sender)))); 81 | 82 | vm.expectRevert(bytes("Ownable: caller is not the owner")); 83 | vm.prank(caller); 84 | receiver.setRegisteredSender(sourceChain, senderBytes); 85 | } 86 | } 87 | 88 | contract IsRegisteredSender is WormholeReceiverTest { 89 | function testFuzz_SuccessfullyCallWithRegisteredSender(uint16 sourceChain, address sender) public { 90 | vm.assume(sender != address(0)); 91 | bytes32 senderBytes = bytes32(uint256(uint160(address(sender)))); 92 | assertEq(receiver.owner(), msg.sender, "Owner is incorrect"); 93 | 94 | vm.expectEmit(); 95 | emit RegisteredSenderSet(receiver.owner(), sourceChain, senderBytes); 96 | vm.prank(receiver.owner()); 97 | receiver.setRegisteredSender(sourceChain, senderBytes); 98 | receiver.isRegisteredSenderModifierFunc(sourceChain, senderBytes); 99 | } 100 | 101 | function testFuzz_RevertIf_NotCalledByRegisteredSender( 102 | uint16 sourceChain, 103 | address sender, 104 | address caller 105 | ) public { 106 | bytes32 senderBytes = bytes32(uint256(uint160(address(sender)))); 107 | 108 | vm.prank(caller); 109 | vm.expectRevert( 110 | abi.encodeWithSelector(WormholeReceiver.UnregisteredSender.selector, senderBytes) 111 | ); 112 | receiver.isRegisteredSenderModifierFunc(sourceChain, senderBytes); 113 | } 114 | } 115 | 116 | contract ReplayProtect is WormholeReceiverTest { 117 | function testFuzz_RevertIf_SameDeliveryHashIsUsedTwice(bytes32 deliveryHash) public { 118 | receiver.exposed_replayProtect(deliveryHash); 119 | 120 | vm.expectRevert( 121 | abi.encodeWithSelector(WormholeReceiver.AlreadyProcessed.selector, deliveryHash) 122 | ); 123 | receiver.exposed_replayProtect(deliveryHash); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/L2GovernorMetadata.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import "@openzeppelin/contracts/utils/Strings.sol"; 5 | import {IL1Block} from "src/interfaces/IL1Block.sol"; 6 | 7 | /// @notice This contract is used by an `L2VoteAggregator` to store proposal metadata. 8 | /// It expects to receive proposal metadata from a valid L1 source. 9 | /// Derived contracts are responsible for processing and validating incoming metadata. 10 | abstract contract L2GovernorMetadata { 11 | /// @notice Matches schema of L1 proposal metadata. 12 | struct Proposal { 13 | uint256 voteStart; 14 | uint256 voteEnd; 15 | bool isCanceled; 16 | } 17 | 18 | /// @notice The id of the proposal mapped to the proposal metadata. 19 | mapping(uint256 proposalId => Proposal) _proposals; 20 | 21 | /// @notice The assumed block time of the base network 22 | uint256 private L1_BLOCK_TIME = 12; 23 | /// @notice The assumed block time of the target network 24 | /// @dev These are hardcoded now for Ethereum mainnet & Optimism, as these are currently 25 | /// the target networks for the MVP launch. In the future, this should be generalized to work 26 | /// for different network combinations. Even better, once we have better support for cross chain 27 | /// voting in clients and frontend tools, this hack should removed completely. 28 | uint256 private L2_BLOCK_TIME = 2; 29 | 30 | /// @notice The contract that handles fetching the L1 block on the L2. 31 | /// @dev If the block conversion hack is removed from this contract, then this storage var is 32 | /// probably not needed in this contract and can probably be moved back to the `L2VoteAggregator` 33 | IL1Block public immutable L1_BLOCK; 34 | 35 | /// @notice The number of blocks on L1 before L2 voting closes. We close voting 1200 blocks 36 | // before the end of the proposal to cast the vote. 37 | /// @dev If the block conversion hack is removed from this contract, then this storage var is 38 | /// probably not needed in this contract and can probably be moved back to the `L2VoteAggregator` 39 | uint32 public immutable CAST_VOTE_WINDOW; 40 | 41 | error PastBlockNumber(); 42 | 43 | event ProposalCreated( 44 | uint256 proposalId, 45 | address proposer, 46 | address[] targets, 47 | uint256[] values, 48 | string[] signatures, 49 | bytes[] calldatas, 50 | uint256 startBlock, 51 | uint256 endBlock, 52 | string description 53 | ); 54 | 55 | event ProposalCanceled(uint256 proposalId); 56 | 57 | /// @param _l1BlockAddress The address of the L1Block contract. 58 | constructor(address _l1BlockAddress, uint32 _castWindow) { 59 | L1_BLOCK = IL1Block(_l1BlockAddress); 60 | CAST_VOTE_WINDOW = _castWindow; 61 | } 62 | 63 | /// @notice Add proposal to internal storage. 64 | /// @param proposalId The id of the proposal. 65 | /// @param voteStart The base chain block number when voting starts. 66 | /// @param voteEnd The base chain block number when voting ends. 67 | /// @param isCanceled Whether or not the proposal has been canceled. 68 | function _addProposal(uint256 proposalId, uint256 voteStart, uint256 voteEnd, bool isCanceled) 69 | internal 70 | virtual 71 | { 72 | _proposals[proposalId] = Proposal(voteStart, voteEnd, isCanceled); 73 | if (isCanceled) { 74 | emit ProposalCanceled(proposalId); 75 | } else { 76 | emit ProposalCreated( 77 | proposalId, 78 | address(0), 79 | new address[](0), 80 | new uint256[](0), 81 | new string[](0), 82 | new bytes[](0), 83 | block.number, 84 | _l2BlockForFutureL1Block(voteEnd - CAST_VOTE_WINDOW), 85 | string.concat("Mainnet proposal ", Strings.toString(proposalId)) 86 | ); 87 | } 88 | } 89 | 90 | /// @notice Returns the proposal metadata for a given proposal id. 91 | /// @param proposalId The id of the proposal. 92 | function getProposal(uint256 proposalId) public view virtual returns (Proposal memory) { 93 | return _proposals[proposalId]; 94 | } 95 | 96 | /// @notice Calculate the approximate block that the L2 will be producing at the time the 97 | /// L1 produces some given future block number. 98 | /// @param _l1BlockNumber The number of a future L1 block 99 | /// @return The approximate block number the L2 will be producing when L1 produces the given 100 | /// block 101 | function _l2BlockForFutureL1Block(uint256 _l1BlockNumber) internal view returns (uint256) { 102 | // We should never send an L1 block in the past. If we did, this would overflow & revert. 103 | if (_l1BlockNumber < L1_BLOCK.number()) revert PastBlockNumber(); 104 | uint256 _l1BlocksUntilEnd = _l1BlockNumber - L1_BLOCK.number(); 105 | 106 | return block.number + ((_l1BlocksUntilEnd * L1_BLOCK_TIME) / L2_BLOCK_TIME); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/WormholeL1ERC20Bridge.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {SafeERC20} from "openzeppelin/token/ERC20/utils/SafeERC20.sol"; 5 | import {ERC20Votes} from "openzeppelin/token/ERC20/extensions/ERC20Votes.sol"; 6 | import {WormholeL1VotePool} from "src/WormholeL1VotePool.sol"; 7 | import {WormholeSender} from "src/WormholeSender.sol"; 8 | import {WormholeBase} from "src/WormholeBase.sol"; 9 | import {WormholeReceiver} from "src/WormholeReceiver.sol"; 10 | 11 | contract WormholeL1ERC20Bridge is WormholeL1VotePool, WormholeSender, WormholeReceiver { 12 | using SafeERC20 for ERC20Votes; 13 | 14 | /// @notice L1 token used for deposits and voting. 15 | ERC20Votes public immutable L1_TOKEN; 16 | 17 | /// @notice Token address which is minted on L2. 18 | address public L2_TOKEN_ADDRESS; 19 | 20 | /// @notice Used to indicate whether the contract has been initialized with the L2 token address. 21 | bool public INITIALIZED = false; 22 | 23 | /// @dev Contract is already initialized with an L2 token. 24 | error AlreadyInitialized(); 25 | 26 | /// @dev The value sent to the relayer must match the cost of the message. 27 | error CostValueMismatch(); 28 | 29 | event TokenBridged( 30 | address indexed sender, 31 | address indexed targetAddress, 32 | uint256 indexed targetChain, 33 | uint256 amount, 34 | address targetToken 35 | ); 36 | 37 | event Withdraw(address indexed account, uint256 amount); 38 | 39 | /// @param l1TokenAddress The address of the L1 token. 40 | /// @param _relayer The adddress of the Wormhole relayer. 41 | /// @param _governor The address of the L1 governor. 42 | /// @param _sourceChain The Wormhole id of the chain sending the messages. 43 | /// @param _targetChain The Wormhole id of the chain to send the message. 44 | constructor( 45 | address l1TokenAddress, 46 | address _relayer, 47 | address _governor, 48 | uint16 _sourceChain, 49 | uint16 _targetChain, 50 | address _owner 51 | ) 52 | WormholeL1VotePool(_governor) 53 | WormholeBase(_relayer, _owner) 54 | WormholeSender(_sourceChain, _targetChain) 55 | { 56 | L1_TOKEN = ERC20Votes(l1TokenAddress); 57 | } 58 | 59 | /// @notice Must be called before bridging tokens to L2. 60 | /// @param l2TokenAddress The address of the L2 token. 61 | function initialize(address l2TokenAddress) public { 62 | if (INITIALIZED) revert AlreadyInitialized(); 63 | INITIALIZED = true; 64 | L2_TOKEN_ADDRESS = l2TokenAddress; 65 | } 66 | 67 | /// @notice Deposits L1 tokens into bridge and publishes a message using Wormhole to the L2 token. 68 | /// @param account The address of the user on L2 where to mint the token. 69 | /// @param amount The amount of tokens to deposit and mint on the L2. 70 | /// @return sequence An identifier for the message published to L2. 71 | function deposit(address account, uint224 amount) public payable returns (uint256 sequence) { 72 | L1_TOKEN.safeTransferFrom(msg.sender, address(this), amount); 73 | 74 | bytes memory mintCalldata = abi.encodePacked(account, amount); 75 | 76 | uint256 cost = quoteDeliveryCost(TARGET_CHAIN); 77 | if (cost != msg.value) revert CostValueMismatch(); 78 | 79 | emit TokenBridged(msg.sender, account, TARGET_CHAIN, amount, L2_TOKEN_ADDRESS); 80 | 81 | return WORMHOLE_RELAYER.sendPayloadToEvm{value: cost}( 82 | TARGET_CHAIN, 83 | L2_TOKEN_ADDRESS, 84 | mintCalldata, 85 | 0, // no receiver value needed since we're just passing a message 86 | gasLimit, 87 | REFUND_CHAIN, 88 | msg.sender 89 | ); 90 | } 91 | 92 | function receiveWormholeMessages( 93 | bytes calldata payload, 94 | bytes[] memory additionalVaas, 95 | bytes32 sourceAddress, 96 | uint16 sourceChain, 97 | bytes32 deliveryHash 98 | ) public override onlyRelayer isRegisteredSender(sourceChain, sourceAddress) { 99 | if (sourceAddress == bytes32(uint256(uint160(L2_TOKEN_ADDRESS)))) { 100 | return _receiveWithdrawalWormholeMessages( 101 | payload, additionalVaas, sourceAddress, sourceChain, deliveryHash 102 | ); 103 | } 104 | return _receiveCastVoteWormholeMessages( 105 | payload, additionalVaas, sourceAddress, sourceChain, deliveryHash 106 | ); 107 | } 108 | 109 | /// @notice Receives an encoded withdrawal message from the L2 110 | /// @param payload The payload that was sent to in the delivery request. 111 | /// @dev Expect payload to be a packed 20 byte address followed by a 32 byte amount. 112 | function _receiveWithdrawalWormholeMessages( 113 | bytes calldata payload, 114 | bytes[] memory, 115 | bytes32, 116 | uint16, 117 | bytes32 118 | ) internal { 119 | _withdraw(address(bytes20(payload[:20])), uint256(bytes32(payload[20:52]))); 120 | } 121 | 122 | /// @notice Withdraws deposited tokens to an account. 123 | /// @param account The address of the user withdrawing tokens. 124 | /// @param amount The amount of tokens to withdraw. 125 | function _withdraw(address account, uint256 amount) internal { 126 | L1_TOKEN.safeTransfer(account, amount); 127 | emit Withdraw(account, amount); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /test/WormholeL2VoteAggregator.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {Test} from "forge-std/Test.sol"; 5 | import {WormholeRelayerBasicTest} from "wormhole-solidity-sdk/testing/WormholeRelayerTest.sol"; 6 | import {ERC20VotesComp} from 7 | "openzeppelin-flexible-voting/governance/extensions/GovernorVotesComp.sol"; 8 | 9 | import {L1Block} from "src/L1Block.sol"; 10 | import {L2GovernorMetadata} from "src/L2GovernorMetadata.sol"; 11 | import {WormholeL2GovernorMetadata} from "src/WormholeL2GovernorMetadata.sol"; 12 | import {FakeERC20} from "src/FakeERC20.sol"; 13 | import {L2VoteAggregator} from "src/L2VoteAggregator.sol"; 14 | import {WormholeL2VoteAggregator} from "src/WormholeL2VoteAggregator.sol"; 15 | import {L2GovernorMetadata} from "src/WormholeL2GovernorMetadata.sol"; 16 | import {TestConstants} from "test/Constants.sol"; 17 | import {GovernorMetadataMock} from "test/mock/GovernorMetadataMock.sol"; 18 | import {GovernorFlexibleVotingMock} from "test/mock/GovernorMock.sol"; 19 | import {WormholeL1VotePoolHarness} from "test/harness/WormholeL1VotePoolHarness.sol"; 20 | import {WormholeL2VoteAggregatorHarness} from "test/harness/WormholeL2VoteAggregatorHarness.sol"; 21 | 22 | contract L2VoteAggregatorTest is TestConstants, WormholeRelayerBasicTest { 23 | FakeERC20 l2Erc20; 24 | WormholeL2VoteAggregatorHarness l2VoteAggregator; 25 | FakeERC20 l1Erc20; 26 | WormholeL1VotePoolHarness l1VotePool; 27 | GovernorMetadataMock l2GovernorMetadata; 28 | L1Block l1Block; 29 | bytes32 l2VoteAggregatorWormholeAddress; 30 | 31 | event VoteCast( 32 | address indexed voter, uint256 proposalId, uint8 support, uint256 weight, string reason 33 | ); 34 | 35 | event VoteCast( 36 | address indexed voter, uint256 proposalId, uint256 against, uint256 inFavor, uint256 abstain 37 | ); 38 | event VoteBridged( 39 | uint256 indexed proposalId, uint256 voteAgainst, uint256 voteFor, uint256 voteAbstain 40 | ); 41 | 42 | constructor() { 43 | setForkChains(TESTNET, L2_CHAIN.wormholeChainId, L1_CHAIN.wormholeChainId); 44 | } 45 | 46 | function setUpSource() public override { 47 | l2Erc20 = new FakeERC20("GovExample", "GOV"); 48 | l1Block = new L1Block(); 49 | l2VoteAggregator = new WormholeL2VoteAggregatorHarness( 50 | address(l2Erc20), 51 | L2_CHAIN.wormholeRelayer, 52 | address(l1Block), 53 | L2_CHAIN.wormholeChainId, 54 | L1_CHAIN.wormholeChainId, 55 | 1200 56 | ); 57 | } 58 | 59 | function setUpTarget() public override { 60 | l1Erc20 = new FakeERC20("GovExample", "GOV"); 61 | GovernorFlexibleVotingMock l1Governor = 62 | new GovernorFlexibleVotingMock("Testington Dao", ERC20VotesComp(address(l1Erc20))); 63 | l1VotePool = new WormholeL1VotePoolHarness(L1_CHAIN.wormholeRelayer, address(l1Governor)); 64 | l2VoteAggregatorWormholeAddress = bytes32(uint256(uint160(address(l2VoteAggregator)))); 65 | l1VotePool.setRegisteredSender(L2_CHAIN.wormholeChainId, l2VoteAggregatorWormholeAddress); 66 | } 67 | } 68 | 69 | contract Constructor is L2VoteAggregatorTest { 70 | function testFuzz_CorrectlySetsAllArgs() public { 71 | L1Block l1Block = new L1Block(); 72 | WormholeL2VoteAggregator l2VoteAggregator = new WormholeL2VoteAggregatorHarness( 73 | address(l2Erc20), 74 | L2_CHAIN.wormholeRelayer, 75 | address(l1Block), 76 | L2_CHAIN.wormholeChainId, 77 | L1_CHAIN.wormholeChainId, 78 | 1200 79 | ); 80 | 81 | assertEq(address(l1Block), address(l2VoteAggregator.L1_BLOCK())); 82 | assertEq(address(address(l2Erc20)), address(l2VoteAggregator.VOTING_TOKEN())); 83 | } 84 | } 85 | 86 | /// @dev Although the bridge method is in the `L2VoteAggregator` contract we test it here because 87 | /// it will replicate the true end to end functionality 88 | contract _bridgeVote is L2VoteAggregatorTest { 89 | function testFuzz_CorrectlyBridgeVoteAggregation(uint32 _against, uint32 _for, uint32 _abstain) 90 | public 91 | { 92 | vm.selectFork(targetFork); 93 | vm.assume(uint96(_against) + _for + _abstain != 0); 94 | uint96 totalVotes = uint96(_against) + _for + _abstain; 95 | 96 | l1Erc20.mint(address(this), totalVotes); 97 | l1Erc20.approve(address(this), totalVotes); 98 | l1Erc20.transferFrom(address(this), address(l1VotePool), totalVotes); 99 | 100 | vm.roll(block.number + 1); // To checkpoint erc20 mint 101 | uint256 _proposalId = l1VotePool.createProposalVote(address(l1Erc20)); 102 | 103 | vm.selectFork(sourceFork); 104 | l2VoteAggregator.initialize(address(l1VotePool)); 105 | uint256 cost = l2VoteAggregator.quoteDeliveryCost(L1_CHAIN.wormholeChainId); 106 | vm.recordLogs(); 107 | vm.deal(address(this), 10 ether); 108 | 109 | l2VoteAggregator.createProposalVote(_proposalId, _against, _for, _abstain); 110 | l2VoteAggregator.createProposal(_proposalId, uint128(l2VoteAggregator.CAST_VOTE_WINDOW()) + 1); 111 | vm.expectEmit(); 112 | emit VoteBridged(_proposalId, _against, _for, _abstain); 113 | l2VoteAggregator.bridgeVote{value: cost}(_proposalId); 114 | 115 | vm.expectEmit(); 116 | emit VoteCast(L1_CHAIN.wormholeRelayer, _proposalId, _against, _for, _abstain); 117 | performDelivery(); 118 | 119 | vm.selectFork(targetFork); 120 | (uint128 against, uint128 forVotes, uint128 abstain) = l1VotePool.proposalVotes(_proposalId); 121 | 122 | assertEq(against, _against, "Against value was not bridged correctly"); 123 | assertEq(forVotes, _for, "For value was not bridged correctly"); 124 | assertEq(abstain, _abstain, "abstain value was not bridged correctly"); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/optimized/WormholeL2VoteAggregatorCalldataCompressor.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {L2VoteAggregator} from "src/L2VoteAggregator.sol"; 5 | import {WormholeL2VoteAggregator} from "src/WormholeL2VoteAggregator.sol"; 6 | 7 | contract WormholeL2VoteAggregatorCalldataCompressor is WormholeL2VoteAggregator { 8 | /// @dev Thrown when calldata is invalid for the provided function Id. 9 | error InvalidCalldata(); 10 | 11 | /// @dev Thrown when calldata provides a function Id that does not exist. 12 | error FunctionDoesNotExist(); 13 | 14 | /// @dev Thrown when a function is not supported. 15 | error UnsupportedFunction(); 16 | 17 | /// @notice The internal proposal ID which is used by calldata optimized cast methods. 18 | uint16 internal nextInternalProposalId = 1; 19 | 20 | /// @notice The ID of the proposal mapped to an internal proposal ID. 21 | mapping(uint256 governorProposalId => uint16) public optimizedProposalIds; 22 | 23 | /// @param _votingToken The address of the L2 token used for voting. 24 | /// @param _relayer The address of the Wormhole relayer contract. 25 | /// state. 26 | /// @param _l1BlockAddress The address of the contract used to fetch the L1 block number. 27 | /// @param _sourceChain The Wormhole chain Id of the source chain when sending messages. 28 | /// @param _targetChain The Wormhole chain Id of the target chain when sending messages. 29 | constructor( 30 | address _votingToken, 31 | address _relayer, 32 | address _l1BlockAddress, 33 | uint16 _sourceChain, 34 | uint16 _targetChain, 35 | address _owner, 36 | uint32 _castWindow 37 | ) 38 | WormholeL2VoteAggregator( 39 | _votingToken, 40 | _relayer, 41 | _l1BlockAddress, 42 | _sourceChain, 43 | _targetChain, 44 | _owner, 45 | _castWindow 46 | ) 47 | {} 48 | 49 | /// @dev if we remove this function solc will give a missing-receive-ether warning because we have 50 | /// a payable fallback function. We cannot change the fallback function to a receive function 51 | /// because receive does not have access to msg.data. In order to prevent a missing-receive-ether 52 | /// warning we add a receive function and revert. 53 | receive() external payable { 54 | revert UnsupportedFunction(); 55 | } 56 | 57 | /// @notice Casts a vote on L2 using a calldata optimized signature. Each cast vote method has a 58 | /// different Id documented below. 59 | /// 60 | /// 1 corresponds to `castVote` 61 | /// 2 corresponds to `castVoteWithReason` 62 | /// 3 corresponds to `castVoteBySig` 63 | fallback() external payable { 64 | uint8 funcId = uint8(bytes1(msg.data[0:1])); 65 | if (funcId == 1) _castVote(msg.data); 66 | else if (funcId == 2) _castVoteWithReason(msg.data); 67 | else if (funcId == 3) _castVoteBySig(msg.data); 68 | else revert FunctionDoesNotExist(); 69 | } 70 | 71 | /// @dev Wrap the default `castVote` method in a method that optimizes the calldata. 72 | /// @param _msgData Optimized calldata for the `castVote` method. We restrict proposalId to a 73 | /// `uint16` rather than the default `uint256`. 74 | /// 75 | /// bytes 0-1: method Id 76 | /// bytes 1-3: proposal Id as a uint16 77 | /// bytes 3-4: voters support value as a uint8 78 | function _castVote(bytes calldata _msgData) internal { 79 | if (_msgData.length != 4) revert InvalidCalldata(); 80 | uint16 proposalId = uint16(bytes2(_msgData[1:3])); // Supports max Id of 65,535 81 | uint8 support = uint8(bytes1(_msgData[3:4])); 82 | castVote(proposalId, L2VoteAggregator.VoteType(support)); 83 | } 84 | 85 | /// @dev Wrap the default `castVoteWithReason` method in a method that optimizes the calldata. 86 | /// @param _msgData Optimized calldata for the `castVoteWithReason` method. We restrict proposalId 87 | /// to a `uint16` rather than the default `uint256`. 88 | /// 89 | /// bytes 0-1: method Id 90 | /// bytes 1-3: proposal Id as a uint16 91 | /// bytes 3-4: voters support value as a uint8 92 | /// bytes 4+: reason string 93 | function _castVoteWithReason(bytes calldata _msgData) internal { 94 | if (_msgData.length < 4) revert InvalidCalldata(); 95 | uint16 proposalId = uint16(bytes2(_msgData[1:3])); 96 | uint8 support = uint8(bytes1(_msgData[3:4])); 97 | string calldata reason = string(_msgData[4:]); 98 | castVoteWithReason(proposalId, L2VoteAggregator.VoteType(support), reason); 99 | } 100 | 101 | /// @dev Wrap the default `castVoteBySig` method in a method that optimizes the calldata. 102 | /// @param _msgData Optimized calldata for the `castVoteBySig` method. We restrict proposalId to a 103 | /// `uint16` rather than the default `uint256`. 104 | /// 105 | /// bytes 0-1: method Id 106 | /// bytes 1-3: proposal Id as a uint16 107 | /// bytes 3-4: voters support value as a uint8 108 | /// bytes 4-5: the v value of the signature 109 | /// bytes 5-37: the r value of the signature 110 | /// bytes 37-69: the s value of the signature 111 | function _castVoteBySig(bytes calldata _msgData) internal { 112 | if (_msgData.length != 69) revert InvalidCalldata(); 113 | uint16 proposalId = uint16(bytes2(_msgData[1:3])); 114 | uint8 support = uint8(bytes1(_msgData[3:4])); 115 | uint8 v = uint8(bytes1(_msgData[4:5])); 116 | bytes32 r = bytes32(_msgData[5:37]); 117 | bytes32 s = bytes32(_msgData[37:69]); 118 | castVoteBySig(proposalId, L2VoteAggregator.VoteType(support), v, r, s); 119 | } 120 | 121 | function _addProposal(uint256 proposalId, uint256 voteStart, uint256 voteEnd, bool isCanceled) 122 | internal 123 | virtual 124 | override 125 | { 126 | super._addProposal(proposalId, voteStart, voteEnd, isCanceled); 127 | uint16 internalId = optimizedProposalIds[proposalId]; 128 | if (internalId == 0) { 129 | optimizedProposalIds[proposalId] = nextInternalProposalId; 130 | ++nextInternalProposalId; 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /test/optimized/WormholeL2GovernorMetadataOptimized.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {Test} from "forge-std/Test.sol"; 5 | 6 | import {L2GovernorMetadata} from "src/L2GovernorMetadata.sol"; 7 | import {WormholeL2GovernorMetadataOptimizedHarness} from 8 | "test/harness/optimized/WormholeL2GovernorMetadataOptimizedHarness.sol"; 9 | import {TestConstants} from "test/Constants.sol"; 10 | import {L1BlockMock} from "test/mock/L1BlockMock.sol"; 11 | 12 | contract WormholeL2GovernorMetadataOptimizedTest is TestConstants { 13 | WormholeL2GovernorMetadataOptimizedHarness l2GovernorMetadata; 14 | L1BlockMock mockL1Block; 15 | 16 | function setUp() public { 17 | mockL1Block = new L1BlockMock(); 18 | l2GovernorMetadata = new WormholeL2GovernorMetadataOptimizedHarness( 19 | L2_CHAIN.wormholeRelayer, msg.sender, address(mockL1Block) 20 | ); 21 | vm.prank(l2GovernorMetadata.owner()); 22 | l2GovernorMetadata.setRegisteredSender( 23 | L1_CHAIN.wormholeChainId, MOCK_WORMHOLE_SERIALIZED_ADDRESS 24 | ); 25 | } 26 | } 27 | 28 | contract _AddProposal is WormholeL2GovernorMetadataOptimizedTest { 29 | function testFuzz_CorrectlyAddASingleProposal( 30 | uint256 proposalId, 31 | uint256 l1VoteStart, 32 | uint256 l1VoteEnd, 33 | bool isCanceled 34 | ) public { 35 | l1VoteEnd = mockL1Block.__boundL1VoteEnd(l1VoteEnd); 36 | l2GovernorMetadata.exposed_addProposal(proposalId, l1VoteStart, l1VoteEnd, isCanceled); 37 | L2GovernorMetadata.Proposal memory l2Proposal = l2GovernorMetadata.getProposal(proposalId); 38 | uint256 internalProposalId = l2GovernorMetadata.optimizedProposalIds(proposalId); 39 | 40 | assertEq(l2Proposal.voteStart, l1VoteStart, "Vote start has been incorrectly set"); 41 | assertEq(l2Proposal.voteEnd, l1VoteEnd, "Vote end has been incorrectly set"); 42 | assertEq(l2Proposal.isCanceled, isCanceled, "Canceled status of the vote is incorrect"); 43 | assertEq(internalProposalId, 1, "Internal id is incorrect"); 44 | } 45 | 46 | function testFuzz_CorrectlyAddAMultipleProposals( 47 | uint256 firstProposalId, 48 | uint256 firstL1VoteStart, 49 | uint256 firstL1VoteEnd, 50 | bool firstIsCanceled, 51 | uint256 secondL1VoteStart, 52 | uint256 secondL1VoteEnd, 53 | bool secondIsCanceled, 54 | uint256 thirdL1VoteStart, 55 | uint256 thirdL1VoteEnd, 56 | bool thirdIsCanceled 57 | ) public { 58 | uint256 secondProposalId = uint256(keccak256(abi.encodePacked(firstProposalId))); 59 | uint256 thirdProposalId = uint256(keccak256(abi.encodePacked(secondProposalId))); 60 | 61 | firstL1VoteEnd = mockL1Block.__boundL1VoteEnd(firstL1VoteEnd); 62 | secondL1VoteEnd = mockL1Block.__boundL1VoteEnd(secondL1VoteEnd); 63 | thirdL1VoteEnd = mockL1Block.__boundL1VoteEnd(thirdL1VoteEnd); 64 | 65 | l2GovernorMetadata.exposed_addProposal( 66 | firstProposalId, firstL1VoteStart, firstL1VoteEnd, firstIsCanceled 67 | ); 68 | l2GovernorMetadata.exposed_addProposal( 69 | secondProposalId, secondL1VoteStart, secondL1VoteEnd, secondIsCanceled 70 | ); 71 | l2GovernorMetadata.exposed_addProposal( 72 | thirdProposalId, thirdL1VoteStart, thirdL1VoteEnd, thirdIsCanceled 73 | ); 74 | 75 | L2GovernorMetadata.Proposal memory thirdL2Proposal = 76 | l2GovernorMetadata.getProposal(thirdProposalId); 77 | uint256 thirdInternalProposalId = l2GovernorMetadata.optimizedProposalIds(thirdProposalId); 78 | 79 | assertEq( 80 | thirdL2Proposal.voteStart, thirdL1VoteStart, "Third vote start has been incorrectly set" 81 | ); 82 | assertEq(thirdL2Proposal.voteEnd, thirdL1VoteEnd, "Third vote end has been incorrectly set"); 83 | assertEq( 84 | thirdL2Proposal.isCanceled, thirdIsCanceled, "Third canceled status of the vote is incorrect" 85 | ); 86 | assertEq(thirdInternalProposalId, 3, "Third internal id is incorrect"); 87 | } 88 | 89 | function testFuzz_CorrectlyUpdateTheSameProposal( 90 | uint256 proposalId, 91 | uint256 initialVoteStart, 92 | uint256 initialVoteEnd, 93 | bool initialIsCanceled, 94 | uint256 updatedVoteStart, 95 | uint256 updatedVoteEnd, 96 | bool updatedIsCanceled 97 | ) public { 98 | initialVoteEnd = mockL1Block.__boundL1VoteEnd(initialVoteEnd); 99 | updatedVoteEnd = mockL1Block.__boundL1VoteEnd(updatedVoteEnd); 100 | l2GovernorMetadata.exposed_addProposal( 101 | proposalId, initialVoteStart, initialVoteEnd, initialIsCanceled 102 | ); 103 | 104 | L2GovernorMetadata.Proposal memory l2Proposal = l2GovernorMetadata.getProposal(proposalId); 105 | uint256 initialInternalProposalId = l2GovernorMetadata.optimizedProposalIds(proposalId); 106 | 107 | assertEq(l2Proposal.voteStart, initialVoteStart, "Initial vote start has been incorrectly set"); 108 | assertEq(l2Proposal.voteEnd, initialVoteEnd, "Initial vote end has been incorrectly set"); 109 | assertEq( 110 | l2Proposal.isCanceled, initialIsCanceled, "Initial canceled status of the vote is incorrect" 111 | ); 112 | assertEq(initialInternalProposalId, 1, "Initial internal id is incorrect"); 113 | 114 | l2GovernorMetadata.exposed_addProposal( 115 | proposalId, updatedVoteStart, updatedVoteEnd, updatedIsCanceled 116 | ); 117 | 118 | L2GovernorMetadata.Proposal memory updatedL2Proposal = 119 | l2GovernorMetadata.getProposal(proposalId); 120 | uint256 updatedInternalProposalId = l2GovernorMetadata.optimizedProposalIds(proposalId); 121 | 122 | assertEq( 123 | updatedL2Proposal.voteStart, updatedVoteStart, "Updated vote start has been incorrectly set" 124 | ); 125 | assertEq(updatedL2Proposal.voteEnd, updatedVoteEnd, "Updated vote end has been incorrectly set"); 126 | assertEq( 127 | updatedL2Proposal.isCanceled, 128 | updatedIsCanceled, 129 | "Updated canceled status of the vote is incorrect" 130 | ); 131 | assertEq(updatedInternalProposalId, 1, "Updated internal id is incorrect"); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # L2 Flexible Voting 2 | 3 | **This codebase contains smart contracts that enable governance voting from Layer 2 rollups using bridged tokens. The current implementation is an MVP. These contracts have not yet been audited or deployed in production. Use at your own risk.** 4 | 5 | - [About](#about) 6 | - [Architecture](#architecture) 7 | - [Testing](#testing) 8 | - [License](#license) 9 | 10 | 11 | ## About 12 | 13 | [Flexible Voting](https://www.scopelift.co/blog/introducing-flexible-voting) is a Governor extension that enables arbitrary voting contracts to be developed, allowing token holders to maintain their voting rights even when they deposit tokens in DeFi or any other contract. 14 | 15 | This codebase contains smart contracts that enable governance voting from Layer 2 rollups using bridged tokens. Today, when a user deposits their governance token into a bridge, they lose access to the voting rights of that token. These contracts allow holders of bridged governance tokens to vote on Layer 2—paying the lower gas fees these networks offer—then see their votes reflected on Layer 1 in a trust minimized fashion. 16 | 17 | The current implementation was built with a grant from the Ethereum Foundation. It is a minimum viable product, demonstrating the feasibility of such a system with a set of contracts that *could* be deployed in production. Please note these contracts have not yet been audited, and should be used only with caution in their current state. 18 | 19 | 20 | ## Architecture 21 | 22 | This diagram represents the architecture of the current implementation. 23 | 24 | ```mermaid 25 | flowchart TD 26 | subgraph L1 27 | X(ERC20 Bridge) 28 | E --------> |Fetches proposal metadata|G(Governor Metadata Bridge) 29 | end 30 | subgraph L2 31 | A(Token holder) -->|votes| B(Vote Aggregator) 32 | X -------> |Bridge Token| A 33 | A --> |Withdraw token|X 34 | B --> |Read proposal metadata|F(Governor Metadata) 35 | G --> |Bridge proposal metadata| F 36 | X ---> |Cast Vote| E(Governor) 37 | B --> |Send message with vote distribution| X 38 | end 39 | ``` 40 | 41 | Let's review each component individually and summarize its role in the system. 42 | 43 | 44 | #### On Layer 1 45 | 46 | * __Governor__ - The DAO's Flexible Voting compatible governance contract. 47 | * __Governor Metadata Bridge__ - Contract that reads governance proposal metadata from the Governor and sends it to L2. 48 | * __ERC20 Bridge__ - Contract where users lock their tokens to bridge them to L2. Also receives the aggregated votes from L2 and forwards them (via Flexible Voting extension) to the Governor. 49 | 50 | #### On Layer 2 51 | 52 | * __Bridged ERC20__ - The delegation-enabled ERC20 voting contract the user receives on L2. 53 | * __Governor Metadata__ - The contract that receives governance proposal metadata sent from L1 and makes it available to the vote aggregator. 54 | * __Vote Aggregator__ - The contract that collects the votes of holders of the bridged governance token on L2 and forwards the aggregated votes back to L1. 55 | 56 | ## Development 57 | 58 | ### Foundry 59 | 60 | This project uses [Foundry](https://github.com/foundry-rs/foundry). Follow [these instructions](https://github.com/foundry-rs/foundry#installation) to install it. 61 | 62 | 63 | #### Getting started 64 | 65 | Clone the repo 66 | 67 | ```bash 68 | git clone git@github.com:ScopeLift/l2-flexible-voting.git 69 | cd l2-flexible-voting 70 | ``` 71 | 72 | Copy the `env.sample` file and populate it with values 73 | 74 | ```bash 75 | cp env.sample .env 76 | # Open the .env file and add your values 77 | ``` 78 | 79 | ```bash 80 | forge install 81 | forge build 82 | forge test 83 | ``` 84 | 85 | ### Formatting 86 | 87 | Formatting is done via [scopelint](https://github.com/ScopeLift/scopelint). To install scopelint, run: 88 | 89 | ```bash 90 | cargo install scopelint 91 | ``` 92 | 93 | #### Apply formatting 94 | 95 | ```bash 96 | scopelint fmt 97 | ``` 98 | 99 | #### Check formatting 100 | 101 | ```bash 102 | scopelint check 103 | ``` 104 | 105 | ## Scripts 106 | 107 | This repository contains a series of Foundry scripts which can be used to deploy and exercise the contracts on testnets or real networks. 108 | 109 | * __WormholeL2FlexibleVotingDeploy.s.sol__ - Deploys all of the components needed to setup up L2 Flexible Voting. 110 | * __WormholeMintOnL2.s.sol__ - Calls the bridge on L1 which will call the mint function on the L2 token. 111 | * __WormholeSendProposalToL2.s.sol__ - Create an L1 and L2 governor metadata contract, and have the L1 contract pass a proposal to the L2 metadata contract. 112 | 113 | While `WormholeL2FlexibleVotingDeploy.s.sol` can be used for production deployments, the other scripts are meant for end-to-end testing on real networks. 114 | 115 | ### Deploying L2 Flexible Voting 116 | 117 | The script used to deploy all of the components for L2 Flexible Voting is in `WormholeL2FlexibleVotingDeploy.s.sol`. Some environment variables that should be set when deploying to production are: 118 | 119 | - `L1_GOVERNOR_ADDRESS`: The address of the Governor on Layer 1. If this is left blank a mock Governor will be deployed. 120 | - `L1_TOKEN_ADDRESS`: The address of an `ERC20Votes` compatible token which is used by the L1 Governor. If this is left blank then a mock L1 token will be deployed. 121 | - `L1_BLOCK_ADDRESS`: The address of the contract on L2 that tracks the L1 block time. If using Optimism the contract address can be found [here](https://community.optimism.io/docs/protocol/protocol-2.0/#l1block). Other L2's like [Arbitrum](https://docs.arbitrum.io/time) may use the L1 block number when calling `block.number` and would require a custom contract. If this is left blank we deploy a mock `L1Block` contract. 122 | - `CONTRACT_OWNER`: The address that is used for the owner of ownable contracts. This defaults to `msg.sender` when blank. We recommend consulting [Forge's best practices](https://docs.arbitrum.io/time) when deploying. 123 | - `L2_TOKEN_NAME`: The name of the L2 token that will be deployed. If this is left blank an example name will be used. 124 | - `L2_TOKEN_SYMBOL`: The symbol of the L2 token that will be deployed. If this is left blank an example symbol will be used. 125 | 126 | We recommend filling in all of these values when deploying to production. 127 | 128 | To deploy run the below with a preferred private key solution. 129 | 130 | ```sh 131 | forge script script/WormholeL2FlexibleVotingDeploy.s.sol:WormholeL2FlexibleVotingDeploy --broadcast 132 | ``` 133 | ## License 134 | 135 | This project is available under the [MIT](LICENSE.txt) license. 136 | 137 | Copyright (c) 2023 ScopeLift 138 | -------------------------------------------------------------------------------- /test/WormholeL2ERC20.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {Test} from "forge-std/Test.sol"; 5 | import {IGovernor} from "openzeppelin/governance/Governor.sol"; 6 | import {WormholeRelayerBasicTest} from "wormhole-solidity-sdk/testing/WormholeRelayerTest.sol"; 7 | 8 | import {WormholeL1ERC20Bridge} from "src/WormholeL1ERC20Bridge.sol"; 9 | import {FakeERC20} from "src/FakeERC20.sol"; 10 | import {L1Block} from "src/L1Block.sol"; 11 | import {WormholeL2ERC20} from "src/WormholeL2ERC20.sol"; 12 | import {TestConstants} from "test/Constants.sol"; 13 | import {GovernorMock} from "test/mock/GovernorMock.sol"; 14 | import {WormholeReceiver} from "src/WormholeReceiver.sol"; 15 | 16 | contract L2ERC20Test is TestConstants, WormholeRelayerBasicTest { 17 | WormholeL2ERC20 l2Erc20; 18 | FakeERC20 l1Erc20; 19 | WormholeL1ERC20Bridge l1Erc20Bridge; 20 | 21 | event Withdraw(address indexed account, uint256 amount); 22 | 23 | event TokenBridged( 24 | address indexed account, 25 | address indexed targetAddress, 26 | uint16 targetChain, 27 | uint256 amount, 28 | address targetToken 29 | ); 30 | 31 | constructor() { 32 | setForkChains(TESTNET, L2_CHAIN.wormholeChainId, L1_CHAIN.wormholeChainId); 33 | } 34 | 35 | function setUpSource() public override { 36 | L1Block l1Block = new L1Block(); 37 | l2Erc20 = new WormholeL2ERC20( 38 | "Hello", 39 | "WRLD", 40 | L2_CHAIN.wormholeRelayer, 41 | address(l1Block), 42 | L2_CHAIN.wormholeChainId, 43 | L1_CHAIN.wormholeChainId, 44 | msg.sender 45 | ); 46 | 47 | vm.prank(l2Erc20.owner()); 48 | l2Erc20.setRegisteredSender(L1_CHAIN.wormholeChainId, MOCK_WORMHOLE_SERIALIZED_ADDRESS); 49 | } 50 | 51 | function setUpTarget() public override { 52 | l1Erc20 = new FakeERC20("Hello", "WRLD"); 53 | IGovernor gov = new GovernorMock("Testington Dao", l1Erc20); 54 | l1Erc20Bridge = new WormholeL1ERC20Bridge( 55 | address(l1Erc20), 56 | L1_CHAIN.wormholeRelayer, 57 | address(gov), 58 | L1_CHAIN.wormholeChainId, 59 | L2_CHAIN.wormholeChainId, 60 | msg.sender 61 | ); 62 | 63 | vm.prank(l1Erc20Bridge.owner()); 64 | l1Erc20Bridge.setRegisteredSender( 65 | L2_CHAIN.wormholeChainId, bytes32(uint256(uint160(address(l2Erc20)))) 66 | ); 67 | } 68 | } 69 | 70 | contract Constructor is L2ERC20Test { 71 | function testFuzz_CorrectlySetsAllArgs() public { 72 | L1Block l1Block = new L1Block(); 73 | WormholeL2ERC20 erc20 = new WormholeL2ERC20( 74 | "Hello", 75 | "WRLD", 76 | 0x0CBE91CF822c73C2315FB05100C2F714765d5c20, 77 | address(l1Block), 78 | L2_CHAIN.wormholeChainId, 79 | L1_CHAIN.wormholeChainId, 80 | msg.sender 81 | ); 82 | 83 | assertEq(address(l1Block), address(erc20.L1_BLOCK())); 84 | } 85 | } 86 | 87 | contract Initialize is L2ERC20Test { 88 | function testFork_CorrectlyInitializeL2Token(address l1Erc20Bridge) public { 89 | l2Erc20.initialize(l1Erc20Bridge); 90 | assertEq(l2Erc20.L1_BRIDGE_ADDRESS(), l1Erc20Bridge, "L1 bridge address is not setup correctly"); 91 | assertEq(l2Erc20.INITIALIZED(), true, "L1 bridged isn't initialized"); 92 | } 93 | 94 | function testFork_RevertWhen_AlreadyInitializedWithBridgeAddress(address l1Erc20Bridge) public { 95 | l2Erc20.initialize(l1Erc20Bridge); 96 | 97 | vm.expectRevert(WormholeL2ERC20.AlreadyInitialized.selector); 98 | l2Erc20.initialize(l1Erc20Bridge); 99 | } 100 | } 101 | 102 | contract ReceiveWormholeMessages is L2ERC20Test { 103 | function testForkFuzz_CorrectlyReceiveWormholeMessages(address account, uint224 l1Amount) public { 104 | vm.assume(account != address(0)); // Cannot be zero address 105 | l2Erc20.initialize(address(l1Erc20Bridge)); 106 | 107 | vm.prank(L2_CHAIN.wormholeRelayer); 108 | l2Erc20.receiveWormholeMessages( 109 | abi.encodePacked(account, l1Amount), 110 | new bytes[](0), 111 | MOCK_WORMHOLE_SERIALIZED_ADDRESS, 112 | L1_CHAIN.wormholeChainId, 113 | bytes32("") 114 | ); 115 | uint256 l2Amount = l2Erc20.balanceOf(account); 116 | assertEq(l2Amount, l1Amount, "Amount after receive is incorrect"); 117 | 118 | uint256 weight = l2Erc20.getVotes(account); 119 | assertEq(weight, l1Amount, "Votes were not delegated propoerly"); 120 | } 121 | 122 | function testFuzz_RevertIf_NotCalledByRelayer(address account, uint224 amount, address caller) 123 | public 124 | { 125 | vm.assume(caller != L2_CHAIN.wormholeRelayer); 126 | bytes memory payload = abi.encode(account, amount); 127 | vm.prank(caller); 128 | vm.expectRevert(WormholeReceiver.OnlyRelayerAllowed.selector); 129 | l2Erc20.receiveWormholeMessages( 130 | payload, 131 | new bytes[](0), 132 | MOCK_WORMHOLE_SERIALIZED_ADDRESS, 133 | L1_CHAIN.wormholeChainId, 134 | bytes32("") 135 | ); 136 | } 137 | 138 | function testFuzz_RevertIf_NotCalledByRegisteredSender( 139 | address account, 140 | uint256 amount, 141 | bytes32 caller 142 | ) public { 143 | vm.assume(caller != MOCK_WORMHOLE_SERIALIZED_ADDRESS); 144 | 145 | bytes memory payload = abi.encode(account, amount); 146 | vm.prank(L2_CHAIN.wormholeRelayer); 147 | vm.assume(caller != MOCK_WORMHOLE_SERIALIZED_ADDRESS); 148 | vm.expectRevert(abi.encodeWithSelector(WormholeReceiver.UnregisteredSender.selector, caller)); 149 | l2Erc20.receiveWormholeMessages( 150 | payload, new bytes[](0), caller, L1_CHAIN.wormholeChainId, bytes32("") 151 | ); 152 | } 153 | } 154 | 155 | contract Clock is L2ERC20Test { 156 | function testForkFuzz_CorrectlySetClock(uint48 currentBlock) public { 157 | l2Erc20.initialize(address(l1Erc20Bridge)); 158 | 159 | vm.roll(currentBlock); 160 | uint48 l1Block = l2Erc20.clock(); // The test L1 block implementation uses block.number 161 | assertEq(l1Block, currentBlock, "L2 clock is incorrect"); 162 | } 163 | } 164 | 165 | contract CLOCK_MODE is L2ERC20Test { 166 | function test_CorrectlySetClockMode() public { 167 | l2Erc20.initialize(address(l1Erc20Bridge)); 168 | string memory mode = l2Erc20.CLOCK_MODE(); 169 | 170 | assertEq(mode, "mode=blocknumber&from=eip155:1", "Clock mode is incorrect"); 171 | } 172 | } 173 | 174 | contract L1Unlock is L2ERC20Test { 175 | function testForkFuzz_CorrectlyWithdrawToken(address account, uint224 amount) public { 176 | vm.assume(account != address(0)); 177 | 178 | vm.selectFork(targetFork); 179 | l1Erc20Bridge.initialize(address(l2Erc20)); 180 | l1Erc20.mint(address(l1Erc20Bridge), amount); 181 | 182 | vm.selectFork(sourceFork); 183 | l2Erc20.initialize(address(l1Erc20Bridge)); 184 | vm.recordLogs(); 185 | vm.prank(L2_CHAIN.wormholeRelayer); 186 | l2Erc20.receiveWormholeMessages( 187 | abi.encodePacked(account, amount), 188 | new bytes[](0), 189 | MOCK_WORMHOLE_SERIALIZED_ADDRESS, 190 | L1_CHAIN.wormholeChainId, 191 | bytes32("") 192 | ); 193 | 194 | uint256 cost = l2Erc20.quoteDeliveryCost(L1_CHAIN.wormholeChainId); 195 | vm.deal(account, 1 ether); 196 | vm.expectEmit(); 197 | emit TokenBridged(account, account, L1_CHAIN.wormholeChainId, amount, address(l1Erc20Bridge)); 198 | vm.prank(account); 199 | l2Erc20.l1Unlock{value: cost}(account, amount); 200 | 201 | emit Withdraw(account, amount); 202 | performDelivery(); 203 | 204 | vm.selectFork(targetFork); 205 | 206 | uint256 l1Balance = l1Erc20.balanceOf(account); 207 | assertEq(l1Balance, amount, "L1 balance is incorrect"); 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /script/WormholeL2FlexibleVotingDeploy.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {WormholeL1GovernorMetadataBridge} from "src/WormholeL1GovernorMetadataBridge.sol"; 5 | import {WormholeL2GovernorMetadata} from "src/WormholeL2GovernorMetadata.sol"; 6 | 7 | import {Script, stdJson} from "forge-std/Script.sol"; 8 | 9 | import {TimelockController} from "openzeppelin-flexible-voting/governance/TimelockController.sol"; 10 | import {ERC20Votes} from "openzeppelin-flexible-voting/token/ERC20/extensions/ERC20Votes.sol"; 11 | import {ERC20VotesComp} from 12 | "openzeppelin-flexible-voting/governance/extensions/GovernorVotesComp.sol"; 13 | import {ICompoundTimelock} from "openzeppelin-flexible-voting/vendor/compound/ICompoundTimelock.sol"; 14 | 15 | import {L1Block} from "src/L1Block.sol"; 16 | import {FakeERC20} from "src/FakeERC20.sol"; 17 | import {WormholeL1ERC20Bridge} from "src/WormholeL1ERC20Bridge.sol"; 18 | import {WormholeL2ERC20} from "src/WormholeL2ERC20.sol"; 19 | import {WormholeL2VoteAggregator} from "src/WormholeL2VoteAggregator.sol"; 20 | import {GovernorCompTestnet, GovernorTestnet} from "script/helpers/Governors.sol"; 21 | 22 | import {ScriptConstants} from "test/Constants.sol"; 23 | import {GovernorFlexibleVotingMock} from "test/mock/GovernorMock.sol"; 24 | import {ERC20VotesCompMock} from "test/mock/ERC20VotesCompMock.sol"; 25 | 26 | /// @notice Deploy all the necessary components for L2 Flexible Voting. 27 | contract WormholeL2FlexibleVotingDeploy is Script, ScriptConstants { 28 | using stdJson for string; 29 | 30 | error ConfigurationError(string); 31 | 32 | event Configuration( 33 | address governorAddress, 34 | address l1TokenAddress, 35 | address l1BlockAddress, 36 | address contractOwner, 37 | string l2TokenName, 38 | string l2TokenSymbol, 39 | bool isCompToken 40 | ); 41 | 42 | function run() public { 43 | setFallbackToDefaultRpcUrls(false); 44 | 45 | address l1BlockAddress = vm.envOr("L1_BLOCK_ADDRESS", address(0)); 46 | string memory l2TokenName = vm.envOr("L2_TOKEN_NAME", string("Scopeapotomus")); 47 | string memory l2TokenSymbol = vm.envOr("L2_TOKEN_SYMBOL", string("SCOPE")); 48 | 49 | uint256 l1ForkId = vm.createSelectFork(L1_CHAIN.rpcUrl); 50 | (address governorAddress, address l1TokenAddress, bool isCompToken) = _setupGovernor(); 51 | 52 | emit Configuration( 53 | governorAddress, 54 | l1TokenAddress, 55 | l1BlockAddress, 56 | vm.envOr("CONTRACT_OWNER", msg.sender), 57 | l2TokenName, 58 | l2TokenSymbol, 59 | isCompToken 60 | ); 61 | 62 | // Create L1 bridge that mints the L2 token 63 | vm.broadcast(); 64 | WormholeL1ERC20Bridge l1TokenBridge = new WormholeL1ERC20Bridge( 65 | l1TokenAddress, 66 | L1_CHAIN.wormholeRelayer, 67 | governorAddress, 68 | L1_CHAIN.wormholeChainId, 69 | L2_CHAIN.wormholeChainId, 70 | vm.envOr("CONTRACT_OWNER", msg.sender) 71 | ); 72 | 73 | vm.broadcast(); 74 | // Through trial and error we determined this was the lowest gas limit 75 | // for the L1 token bridge. 76 | l1TokenBridge.updateGasLimit(500_000); 77 | 78 | // Create L1 metadata bridge that sends proposal metadata to L2 79 | vm.broadcast(); 80 | WormholeL1GovernorMetadataBridge l1MetadataBridge = new WormholeL1GovernorMetadataBridge( 81 | governorAddress, 82 | L1_CHAIN.wormholeRelayer, 83 | L1_CHAIN.wormholeChainId, 84 | L2_CHAIN.wormholeChainId, 85 | vm.envOr("CONTRACT_OWNER", msg.sender) 86 | ); 87 | 88 | vm.createSelectFork(L2_CHAIN.rpcUrl); 89 | emit Configuration( 90 | governorAddress, 91 | l1TokenAddress, 92 | l1BlockAddress, 93 | vm.envOr("CONTRACT_OWNER", msg.sender), 94 | l2TokenName, 95 | l2TokenSymbol, 96 | isCompToken 97 | ); 98 | 99 | if (l1BlockAddress == address(0)) { 100 | vm.broadcast(); 101 | L1Block l1Block = new L1Block(); 102 | l1BlockAddress = address(l1Block); 103 | } 104 | 105 | // Create L2 ERC20Votes token 106 | vm.broadcast(); 107 | WormholeL2ERC20 l2Token = new WormholeL2ERC20( 108 | l2TokenName, 109 | l2TokenSymbol, 110 | L2_CHAIN.wormholeRelayer, 111 | l1BlockAddress, 112 | L2_CHAIN.wormholeChainId, 113 | L1_CHAIN.wormholeChainId, 114 | vm.envOr("CONTRACT_OWNER", msg.sender) 115 | ); 116 | 117 | // Deploy the L2 vote aggregator 118 | vm.broadcast(); 119 | WormholeL2VoteAggregator voteAggregator = new WormholeL2VoteAggregator( 120 | address(l2Token), 121 | L2_CHAIN.wormholeRelayer, 122 | l1BlockAddress, 123 | L2_CHAIN.wormholeChainId, 124 | L1_CHAIN.wormholeChainId, 125 | vm.envOr("CONTRACT_OWNER", msg.sender), 126 | uint32(vm.envOr("CAST_WINDOW", uint256(1200))) 127 | ); 128 | 129 | vm.broadcast(); 130 | voteAggregator.setRegisteredSender( 131 | L1_CHAIN.wormholeChainId, _toWormholeAddress(address(l1MetadataBridge)) 132 | ); 133 | 134 | // Register L1 ERC20 bridge on L2 token 135 | vm.broadcast(); 136 | l2Token.setRegisteredSender( 137 | L1_CHAIN.wormholeChainId, _toWormholeAddress(address(l1TokenBridge)) 138 | ); 139 | 140 | vm.broadcast(); 141 | voteAggregator.initialize(address(l1TokenBridge)); 142 | 143 | vm.broadcast(); 144 | l2Token.initialize(address(l1TokenBridge)); 145 | 146 | vm.selectFork(l1ForkId); 147 | 148 | // Register L2 token on ERC20 bridge 149 | vm.broadcast(); 150 | l1TokenBridge.setRegisteredSender( 151 | L2_CHAIN.wormholeChainId, _toWormholeAddress(address(l2Token)) 152 | ); 153 | 154 | // Register Vote Aggregator on L1ERC20 bridge 155 | vm.broadcast(); 156 | l1TokenBridge.setRegisteredSender( 157 | L2_CHAIN.wormholeChainId, _toWormholeAddress(address(voteAggregator)) 158 | ); 159 | 160 | vm.broadcast(); 161 | l1MetadataBridge.initialize(address(voteAggregator)); 162 | 163 | vm.broadcast(); 164 | l1TokenBridge.initialize(address(l2Token)); 165 | } 166 | 167 | /// @dev If a `Governor` and/or token address is not set this function will create a token and 168 | /// Governor. It will also create a Compound compatible `Governor` and token if the script is 169 | /// configured to do so. 170 | function _setupGovernor() internal returns (address, address, bool) { 171 | address governorAddress = vm.envOr("L1_GOVERNOR_ADDRESS", address(0)); 172 | address l1TokenAddress = vm.envOr("L1_TOKEN_ADDRESS", address(0)); 173 | bool isCompToken = vm.envOr("L1_COMP_TOKEN", false); 174 | 175 | // Revert missing governor address exists but not the token address 176 | if (governorAddress != address(0) && l1TokenAddress == address(0)) { 177 | revert ConfigurationError("Governor address has been specified without a token address."); 178 | } 179 | 180 | // Deploy L1 token on is not provided 181 | if (l1TokenAddress == address(0)) { 182 | if (isCompToken) { 183 | vm.broadcast(); 184 | ERC20VotesCompMock deployedL1Token = new ERC20VotesCompMock("GovernanceComp", "GOVc"); 185 | l1TokenAddress = address(deployedL1Token); 186 | } else { 187 | vm.broadcast(); 188 | FakeERC20 deployedL1Token = new FakeERC20("Governance", "GOV"); 189 | l1TokenAddress = address(deployedL1Token); 190 | } 191 | } 192 | // Deploy the L1 governor used in the L1 bridge 193 | if (governorAddress == address(0)) { 194 | vm.broadcast(); 195 | TimelockController _timelock = 196 | new TimelockController(300, new address[](0), new address[](0), address(0)); 197 | 198 | if (isCompToken) { 199 | vm.broadcast(); 200 | GovernorCompTestnet gov = new GovernorCompTestnet( 201 | "Dao of Tests", ERC20VotesComp(l1TokenAddress), ICompoundTimelock(payable(_timelock)) 202 | ); 203 | ERC20Votes(gov.token()).delegate(address(this)); 204 | governorAddress = address(gov); 205 | } else { 206 | vm.broadcast(); 207 | GovernorTestnet gov = 208 | new GovernorTestnet("Dao of Tests", ERC20Votes(l1TokenAddress), _timelock); 209 | governorAddress = address(gov); 210 | } 211 | } 212 | return (governorAddress, l1TokenAddress, isCompToken); 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /src/L2CountingFractional.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; 5 | import {Governor} from "@openzeppelin/contracts/governance/Governor.sol"; 6 | import {GovernorCompatibilityBravo} from 7 | "@openzeppelin/contracts/governance/compatibility/GovernorCompatibilityBravo.sol"; 8 | import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; 9 | 10 | abstract contract L2CountingFractional { 11 | struct ProposalVote { 12 | uint128 againstVotes; 13 | uint128 forVotes; 14 | uint128 abstainVotes; 15 | } 16 | 17 | /** 18 | * @dev Mapping from proposal ID to vote tallies for that proposal. 19 | */ 20 | mapping(uint256 => ProposalVote) internal _proposalVotes; 21 | 22 | /** 23 | * @dev Mapping from proposal ID and address to the weight the address 24 | * has cast on that proposal, e.g. _proposalVotersWeightCast[42][0xBEEF] 25 | * would tell you the number of votes that 0xBEEF has cast on proposal 42. 26 | */ 27 | // Made both of these internal 28 | mapping(uint256 => mapping(address => uint128)) internal _proposalVotersWeightCast; 29 | 30 | /** 31 | * @dev See {IGovernor-COUNTING_MODE}. 32 | */ 33 | // solhint-disable-next-line func-name-mixedcase 34 | function COUNTING_MODE() public pure virtual returns (string memory) { 35 | return "support=bravo&quorum=for,abstain¶ms=fractional"; 36 | } 37 | 38 | /** 39 | * @dev See {IGovernor-hasVoted}. 40 | */ 41 | function hasVoted(uint256 proposalId, address account) public view virtual returns (bool) { 42 | return _proposalVotersWeightCast[proposalId][account] > 0; 43 | } 44 | 45 | /** 46 | * @dev Get the number of votes cast thus far on proposal `proposalId` by 47 | * account `account`. Useful for integrations that allow delegates to cast 48 | * rolling, partial votes. 49 | */ 50 | function voteWeightCast(uint256 proposalId, address account) public view returns (uint128) { 51 | return _proposalVotersWeightCast[proposalId][account]; 52 | } 53 | 54 | /** 55 | * @dev Accessor to the internal vote counts. 56 | */ 57 | function proposalVotes(uint256 proposalId) 58 | public 59 | view 60 | virtual 61 | returns (uint256 againstVotes, uint256 forVotes, uint256 abstainVotes) 62 | { 63 | ProposalVote storage proposalVote = _proposalVotes[proposalId]; 64 | return (proposalVote.againstVotes, proposalVote.forVotes, proposalVote.abstainVotes); 65 | } 66 | 67 | /** 68 | * @notice See {Governor-_countVote}. 69 | * 70 | * @dev Function that records the delegate's votes. 71 | * 72 | * If the `voteData` bytes parameter is empty, then this module behaves 73 | * identically to GovernorBravo. That is, it assigns the full weight of the 74 | * delegate to the `support` parameter, which follows the `VoteType` enum 75 | * from Governor Bravo. 76 | * 77 | * If the `voteData` bytes parameter is not zero, then it _must_ be three 78 | * packed uint128s, totaling 48 bytes, representing the weight the delegate 79 | * assigns to Against, For, and Abstain respectively, i.e. 80 | * `abi.encodePacked(againstVotes, forVotes, abstainVotes)`. The sum total of 81 | * the three decoded vote weights _must_ be less than or equal to the 82 | * delegate's remaining weight on the proposal, i.e. their checkpointed 83 | * total weight minus votes already cast on the proposal. 84 | * 85 | * See `_countVoteNominal` and `_countVoteFractional` for more details. 86 | */ 87 | function _countVote( 88 | uint256 proposalId, 89 | address account, 90 | uint8 support, 91 | uint256 totalWeight, 92 | bytes memory voteData 93 | ) internal virtual { 94 | require(totalWeight > 0, "L2CountingFractional: no weight"); 95 | if (_proposalVotersWeightCast[proposalId][account] >= totalWeight) { 96 | revert("L2CountingFractional: all weight cast"); 97 | } 98 | 99 | uint128 safeTotalWeight = SafeCast.toUint128(totalWeight); 100 | 101 | if (voteData.length == 0) _countVoteNominal(proposalId, account, safeTotalWeight, support); 102 | else _countVoteFractional(proposalId, account, safeTotalWeight, voteData); 103 | } 104 | 105 | /** 106 | * @dev Record votes with full weight cast for `support`. 107 | * 108 | * Because this function votes with the delegate's full weight, it can only 109 | * be called once per proposal. It will revert if combined with a fractional 110 | * vote before or after. 111 | */ 112 | function _countVoteNominal( 113 | uint256 proposalId, 114 | address account, 115 | uint128 totalWeight, 116 | uint8 support 117 | ) internal { 118 | require( 119 | _proposalVotersWeightCast[proposalId][account] == 0, 120 | "L2CountingFractional: vote would exceed weight" 121 | ); 122 | 123 | _proposalVotersWeightCast[proposalId][account] = totalWeight; 124 | 125 | if (support == uint8(GovernorCompatibilityBravo.VoteType.Against)) { 126 | _proposalVotes[proposalId].againstVotes += totalWeight; 127 | } else if (support == uint8(GovernorCompatibilityBravo.VoteType.For)) { 128 | _proposalVotes[proposalId].forVotes += totalWeight; 129 | } else if (support == uint8(GovernorCompatibilityBravo.VoteType.Abstain)) { 130 | _proposalVotes[proposalId].abstainVotes += totalWeight; 131 | } else { 132 | revert("L2CountingFractional: invalid support value, must be included in VoteType enum"); 133 | } 134 | } 135 | 136 | /** 137 | * @dev Count votes with fractional weight. 138 | * 139 | * `voteData` is expected to be three packed uint128s, i.e. 140 | * `abi.encodePacked(againstVotes, forVotes, abstainVotes)`. 141 | * 142 | * This function can be called multiple times for the same account and 143 | * proposal, i.e. partial/rolling votes are allowed. For example, an account 144 | * with total weight of 10 could call this function three times with the 145 | * following vote data: 146 | * - against: 1, for: 0, abstain: 2 147 | * - against: 3, for: 1, abstain: 0 148 | * - against: 1, for: 1, abstain: 1 149 | * The result of these three calls would be that the account casts 5 votes 150 | * AGAINST, 2 votes FOR, and 3 votes ABSTAIN on the proposal. Though 151 | * partial, votes are still final once cast and cannot be changed or 152 | * overridden. Subsequent partial votes simply increment existing totals. 153 | * 154 | * Note that if partial votes are cast, all remaining weight must be cast 155 | * with _countVoteFractional: _countVoteNominal will revert. 156 | */ 157 | function _countVoteFractional( 158 | uint256 proposalId, 159 | address account, 160 | uint128 totalWeight, 161 | bytes memory voteData 162 | ) internal { 163 | require(voteData.length == 48, "L2CountingFractional: invalid voteData"); 164 | 165 | (uint128 _againstVotes, uint128 _forVotes, uint128 _abstainVotes) = _decodePackedVotes(voteData); 166 | 167 | uint128 _existingWeight = _proposalVotersWeightCast[proposalId][account]; 168 | uint256 _newWeight = uint256(_againstVotes) + _forVotes + _abstainVotes + _existingWeight; 169 | 170 | require(_newWeight <= totalWeight, "L2CountingFractional: vote would exceed weight"); 171 | 172 | // It's safe to downcast here because we've just confirmed that 173 | // _newWeight <= totalWeight, and totalWeight is a uint128. 174 | _proposalVotersWeightCast[proposalId][account] = uint128(_newWeight); 175 | 176 | ProposalVote memory _proposalVote = _proposalVotes[proposalId]; 177 | _proposalVote = ProposalVote( 178 | _proposalVote.againstVotes + _againstVotes, 179 | _proposalVote.forVotes + _forVotes, 180 | _proposalVote.abstainVotes + _abstainVotes 181 | ); 182 | 183 | _proposalVotes[proposalId] = _proposalVote; 184 | } 185 | 186 | uint256 internal constant _MASK_HALF_WORD_RIGHT = 0xffffffffffffffffffffffffffffffff; // 128 bits 187 | // of 0's, 128 bits of 1's 188 | 189 | /** 190 | * @dev Decodes three packed uint128's. Uses assembly because of a Solidity 191 | * language limitation which prevents slicing bytes stored in memory, rather 192 | * than calldata. 193 | */ 194 | function _decodePackedVotes(bytes memory voteData) 195 | internal 196 | pure 197 | returns (uint128 againstVotes, uint128 forVotes, uint128 abstainVotes) 198 | { 199 | assembly { 200 | againstVotes := shr(128, mload(add(voteData, 0x20))) 201 | forVotes := and(_MASK_HALF_WORD_RIGHT, mload(add(voteData, 0x20))) 202 | abstainVotes := shr(128, mload(add(voteData, 0x40))) 203 | } 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /test/WormholeL1GovernorMetadataBridge.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {Test} from "forge-std/Test.sol"; 5 | import {ERC20Votes} from "openzeppelin/token/ERC20/extensions/ERC20Votes.sol"; 6 | import {WormholeRelayerBasicTest} from "wormhole-solidity-sdk/testing/WormholeRelayerTest.sol"; 7 | 8 | import {WormholeL1GovernorMetadataBridge} from "src/WormholeL1GovernorMetadataBridge.sol"; 9 | import {FakeERC20} from "src/FakeERC20.sol"; 10 | import {L2GovernorMetadata} from "src/L2GovernorMetadata.sol"; 11 | import {WormholeL2GovernorMetadata} from "src/WormholeL2GovernorMetadata.sol"; 12 | import {TestConstants} from "test/Constants.sol"; 13 | import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; 14 | import {GovernorMock} from "test/mock/GovernorMock.sol"; 15 | import {L1BlockMock} from "test/mock/L1BlockMock.sol"; 16 | 17 | contract L1GovernorMetadataBridgeTest is TestConstants, WormholeRelayerBasicTest { 18 | FakeERC20 l1Erc20; 19 | GovernorMock governorMock; 20 | WormholeL1GovernorMetadataBridge l1GovernorMetadataBridge; 21 | WormholeL2GovernorMetadata l2GovernorMetadata; 22 | 23 | event ProposalCreated( 24 | uint256 proposalId, 25 | address proposer, 26 | address[] targets, 27 | uint256[] values, 28 | string[] signatures, 29 | bytes[] calldatas, 30 | uint256 startBlock, 31 | uint256 endBlock, 32 | string description 33 | ); 34 | event ProposalMetadataBridged( 35 | uint16 indexed targetChain, 36 | address indexed targetGovernor, 37 | uint256 indexed proposalId, 38 | uint256 voteStart, 39 | uint256 voteEnd, 40 | bool isCanceled 41 | ); 42 | event ProposalCanceled(uint256 proposalId); 43 | 44 | constructor() { 45 | setForkChains(TESTNET, L1_CHAIN.wormholeChainId, L2_CHAIN.wormholeChainId); 46 | } 47 | 48 | function setUpSource() public override { 49 | ERC20Votes erc20 = new FakeERC20("GovExample", "GOV"); 50 | governorMock = new GovernorMock("Testington Dao", erc20); 51 | l1GovernorMetadataBridge = new WormholeL1GovernorMetadataBridge( 52 | address(governorMock), 53 | L1_CHAIN.wormholeRelayer, 54 | L1_CHAIN.wormholeChainId, 55 | L2_CHAIN.wormholeChainId, 56 | msg.sender 57 | ); 58 | } 59 | 60 | function setUpTarget() public override { 61 | L1BlockMock _mockL1Block = new L1BlockMock(); 62 | l2GovernorMetadata = new WormholeL2GovernorMetadata( 63 | L2_CHAIN.wormholeRelayer, msg.sender, address(_mockL1Block), 1200 64 | ); 65 | vm.prank(l2GovernorMetadata.owner()); 66 | l2GovernorMetadata.setRegisteredSender( 67 | L1_CHAIN.wormholeChainId, bytes32(uint256(uint160(address(l1GovernorMetadataBridge)))) 68 | ); 69 | } 70 | } 71 | 72 | contract Constructor is Test, TestConstants { 73 | function testFork_CorrectlySetAllArgs(address governorMock) public { 74 | WormholeL1GovernorMetadataBridge l1GovernorMetadataBridge = new WormholeL1GovernorMetadataBridge( 75 | governorMock, 76 | L1_CHAIN.wormholeRelayer, 77 | L1_CHAIN.wormholeChainId, 78 | L2_CHAIN.wormholeChainId, 79 | msg.sender 80 | ); 81 | assertEq( 82 | address(l1GovernorMetadataBridge.GOVERNOR()), governorMock, "Governor is not set correctly" 83 | ); 84 | } 85 | } 86 | 87 | contract Initialize is L1GovernorMetadataBridgeTest { 88 | function testFork_CorrectlyInitializeL2GovernorMetadata(address l2GovernorMetadata) public { 89 | l1GovernorMetadataBridge.initialize(address(l2GovernorMetadata)); 90 | assertEq( 91 | l1GovernorMetadataBridge.L2_GOVERNOR_ADDRESS(), 92 | l2GovernorMetadata, 93 | "L2 governor address is not setup correctly" 94 | ); 95 | assertEq(l1GovernorMetadataBridge.INITIALIZED(), true, "Bridge isn't initialized"); 96 | } 97 | 98 | function testFork_RevertWhen_AlreadyInitializedWithL2GovernorMetadataAddress( 99 | address l2GovernorMetadata 100 | ) public { 101 | l1GovernorMetadataBridge.initialize(address(l2GovernorMetadata)); 102 | 103 | vm.expectRevert(WormholeL1GovernorMetadataBridge.AlreadyInitialized.selector); 104 | l1GovernorMetadataBridge.initialize(address(l2GovernorMetadata)); 105 | } 106 | } 107 | 108 | contract BridgeProposalMetadata is L1GovernorMetadataBridgeTest { 109 | function testFork_CorrectlyBridgeProposal(uint224 _amount) public { 110 | l1GovernorMetadataBridge.initialize(address(l2GovernorMetadata)); 111 | uint256 cost = l1GovernorMetadataBridge.quoteDeliveryCost(L2_CHAIN.wormholeChainId); 112 | vm.recordLogs(); 113 | 114 | bytes memory proposalCalldata = 115 | abi.encode(FakeERC20.mint.selector, address(governorMock), _amount); 116 | 117 | address[] memory targets = new address[](1); 118 | bytes[] memory calldatas = new bytes[](1); 119 | uint256[] memory values = new uint256[](1); 120 | 121 | targets[0] = address(l1Erc20); 122 | calldatas[0] = proposalCalldata; 123 | values[0] = 0; 124 | 125 | // Create proposal 126 | uint256 proposalId = 127 | governorMock.propose(targets, values, calldatas, "Proposal: To inflate governance token"); 128 | 129 | vm.expectEmit(); 130 | emit ProposalMetadataBridged( 131 | L2_CHAIN.wormholeChainId, 132 | address(l2GovernorMetadata), 133 | proposalId, 134 | governorMock.proposalSnapshot(proposalId), 135 | governorMock.proposalDeadline(proposalId), 136 | false 137 | ); 138 | l1GovernorMetadataBridge.bridgeProposalMetadata{value: cost}(proposalId); 139 | uint256 l1VoteStart = governorMock.proposalSnapshot(proposalId); 140 | uint256 l1VoteEnd = governorMock.proposalDeadline(proposalId); 141 | 142 | // TODO: Comment in when event is modified back 143 | // vm.expectEmit(); 144 | // emit ProposalCreated( 145 | // proposalId, 146 | // address(0), 147 | // new address[](0), 148 | // new uint256[](0), 149 | // new string[](0), 150 | // new bytes[](0), 151 | // block.number, 152 | // block.number + 43_200, 153 | // string.concat("Mainnet proposal ", Strings.toString(proposalId)) 154 | // ); 155 | performDelivery(); 156 | 157 | vm.selectFork(targetFork); 158 | L2GovernorMetadata.Proposal memory l2Proposal = l2GovernorMetadata.getProposal(proposalId); 159 | assertEq(l2Proposal.voteStart, l1VoteStart, "voteStart is incorrect"); 160 | assertEq(l2Proposal.voteEnd, l1VoteEnd, "voteEnd is incorrect"); 161 | assertEq(l2Proposal.isCanceled, false, "isCanceled is incorrect"); 162 | } 163 | 164 | function testFork_CorrectlyBridgeCanceledProposal(uint224 _amount) public { 165 | l1GovernorMetadataBridge.initialize(address(l2GovernorMetadata)); 166 | uint256 cost = l1GovernorMetadataBridge.quoteDeliveryCost(L2_CHAIN.wormholeChainId); 167 | vm.recordLogs(); 168 | 169 | bytes memory proposalCalldata = 170 | abi.encode(FakeERC20.mint.selector, address(governorMock), _amount); 171 | 172 | address[] memory targets = new address[](1); 173 | bytes[] memory calldatas = new bytes[](1); 174 | uint256[] memory values = new uint256[](1); 175 | 176 | targets[0] = address(l1Erc20); 177 | calldatas[0] = proposalCalldata; 178 | values[0] = 0; 179 | 180 | // Create proposal 181 | uint256 proposalId = 182 | governorMock.propose(targets, values, calldatas, "Proposal: To inflate governance token"); 183 | governorMock.cancel( 184 | targets, values, calldatas, keccak256(bytes("Proposal: To inflate governance token")) 185 | ); 186 | 187 | vm.expectEmit(); 188 | emit ProposalMetadataBridged( 189 | L2_CHAIN.wormholeChainId, 190 | address(l2GovernorMetadata), 191 | proposalId, 192 | governorMock.proposalSnapshot(proposalId), 193 | governorMock.proposalDeadline(proposalId), 194 | true 195 | ); 196 | l1GovernorMetadataBridge.bridgeProposalMetadata{value: cost}(proposalId); 197 | 198 | vm.expectEmit(); 199 | emit ProposalCanceled(proposalId); 200 | 201 | performDelivery(); 202 | 203 | vm.selectFork(targetFork); 204 | L2GovernorMetadata.Proposal memory l2Proposal = l2GovernorMetadata.getProposal(proposalId); 205 | assertEq(l2Proposal.isCanceled, true, "isCanceled is incorrect"); 206 | } 207 | 208 | function testFork_RevertWhen_ProposalIsMissing(uint256 _proposalId) public { 209 | l1GovernorMetadataBridge.initialize(address(l2GovernorMetadata)); 210 | uint256 cost = l1GovernorMetadataBridge.quoteDeliveryCost(L1_CHAIN.wormholeChainId); 211 | vm.recordLogs(); 212 | 213 | vm.expectRevert(WormholeL1GovernorMetadataBridge.InvalidProposalId.selector); 214 | l1GovernorMetadataBridge.bridgeProposalMetadata{value: cost}(_proposalId); 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /test/WormholeL2GovernorMetadata.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {Test} from "forge-std/Test.sol"; 5 | 6 | import {L2GovernorMetadata} from "src/L2GovernorMetadata.sol"; 7 | import {WormholeL2GovernorMetadata} from "src/WormholeL2GovernorMetadata.sol"; 8 | import {TestConstants} from "test/Constants.sol"; 9 | import {WormholeReceiver} from "src/WormholeReceiver.sol"; 10 | import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; 11 | import {L1BlockMock} from "test/mock/L1BlockMock.sol"; 12 | 13 | contract L2GovernorMetadataTest is TestConstants { 14 | WormholeL2GovernorMetadata l2GovernorMetadata; 15 | L1BlockMock mockL1Block; 16 | 17 | event ProposalCreated( 18 | uint256 proposalId, 19 | address proposer, 20 | address[] targets, 21 | uint256[] values, 22 | string[] signatures, 23 | bytes[] calldatas, 24 | uint256 startBlock, 25 | uint256 endBlock, 26 | string description 27 | ); 28 | 29 | event ProposalCanceled(uint256 proposalId); 30 | 31 | function setUp() public { 32 | mockL1Block = new L1BlockMock(); 33 | l2GovernorMetadata = new WormholeL2GovernorMetadata( 34 | L2_CHAIN.wormholeRelayer, msg.sender, address(mockL1Block), 1200 35 | ); 36 | vm.prank(l2GovernorMetadata.owner()); 37 | l2GovernorMetadata.setRegisteredSender( 38 | L1_CHAIN.wormholeChainId, MOCK_WORMHOLE_SERIALIZED_ADDRESS 39 | ); 40 | } 41 | 42 | function expectProposalEvent( 43 | uint256 proposalId, 44 | uint256, /*voteStart*/ 45 | uint256 voteEnd, 46 | bool isCanceled 47 | ) internal { 48 | vm.expectEmit(); 49 | if (!isCanceled) { 50 | emit ProposalCreated( 51 | proposalId, 52 | address(0), 53 | new address[](0), 54 | new uint256[](0), 55 | new string[](0), 56 | new bytes[](0), 57 | block.number, 58 | mockL1Block.__expectedL2BlockForFutureBlock(voteEnd), 59 | string.concat("Mainnet proposal ", Strings.toString(proposalId)) 60 | ); 61 | } else { 62 | emit ProposalCanceled(proposalId); 63 | } 64 | } 65 | } 66 | 67 | contract Constructor is L2GovernorMetadataTest { 68 | function testFuzz_CorrectlySetsAllArgs(address wormholeCore) public { 69 | new WormholeL2GovernorMetadata(wormholeCore, msg.sender, address(ARBITRARY_ADDRESS), 1200); 70 | // nothing to assert as there are no constructor args set 71 | } 72 | } 73 | 74 | contract ReceiveWormholeMessages is L2GovernorMetadataTest { 75 | function testFuzz_CorrectlySaveProposalMetadata( 76 | uint256 proposalId, 77 | uint256 l1VoteStart, 78 | uint256 l1VoteEnd, 79 | bool isCanceled 80 | ) public { 81 | l1VoteEnd = mockL1Block.__boundL1VoteEnd(l1VoteEnd); 82 | 83 | bytes memory payload = abi.encode(proposalId, l1VoteStart, l1VoteEnd, isCanceled); 84 | expectProposalEvent(proposalId, l1VoteStart, l1VoteEnd, isCanceled); 85 | vm.prank(L2_CHAIN.wormholeRelayer); 86 | l2GovernorMetadata.receiveWormholeMessages( 87 | payload, 88 | new bytes[](0), 89 | MOCK_WORMHOLE_SERIALIZED_ADDRESS, 90 | L1_CHAIN.wormholeChainId, 91 | bytes32("") 92 | ); 93 | L2GovernorMetadata.Proposal memory l2Proposal = l2GovernorMetadata.getProposal(proposalId); 94 | assertEq(l2Proposal.voteStart, l1VoteStart, "Vote start has been incorrectly set"); 95 | assertEq(l2Proposal.voteEnd, l1VoteEnd, "Vote start has been incorrectly set"); 96 | assertEq(l2Proposal.isCanceled, isCanceled, "Canceled status of the vote is incorrect"); 97 | } 98 | 99 | function testFuzz_CorrectlySaveProposalMetadataForTwoProposals( 100 | uint256 firstProposalId, 101 | uint256 firstVoteStart, 102 | uint256 firstVoteEnd, 103 | bool firstCanceled, 104 | uint256 secondProposalId, 105 | uint256 secondVoteStart, 106 | uint256 secondVoteEnd, 107 | bool secondCanceled 108 | ) public { 109 | vm.assume(firstProposalId != secondProposalId); 110 | firstVoteEnd = mockL1Block.__boundL1VoteEnd(firstVoteEnd); 111 | secondVoteEnd = mockL1Block.__boundL1VoteEnd(secondVoteEnd); 112 | 113 | bytes memory firstPayload = 114 | abi.encode(firstProposalId, firstVoteStart, firstVoteEnd, firstCanceled); 115 | expectProposalEvent(firstProposalId, firstVoteStart, firstVoteEnd, firstCanceled); 116 | vm.prank(L2_CHAIN.wormholeRelayer); 117 | l2GovernorMetadata.receiveWormholeMessages( 118 | firstPayload, 119 | new bytes[](0), 120 | MOCK_WORMHOLE_SERIALIZED_ADDRESS, 121 | L1_CHAIN.wormholeChainId, 122 | bytes32("") 123 | ); 124 | 125 | bytes memory secondPayload = 126 | abi.encode(secondProposalId, secondVoteStart, secondVoteEnd, secondCanceled); 127 | expectProposalEvent(secondProposalId, secondVoteStart, secondVoteEnd, secondCanceled); 128 | vm.prank(L2_CHAIN.wormholeRelayer); 129 | l2GovernorMetadata.receiveWormholeMessages( 130 | secondPayload, 131 | new bytes[](0), 132 | MOCK_WORMHOLE_SERIALIZED_ADDRESS, 133 | L1_CHAIN.wormholeChainId, 134 | bytes32("0x1") 135 | ); 136 | 137 | L2GovernorMetadata.Proposal memory firstProposal = 138 | l2GovernorMetadata.getProposal(firstProposalId); 139 | assertEq( 140 | firstProposal.voteStart, firstVoteStart, "First proposal vote start has been incorrectly set" 141 | ); 142 | assertEq( 143 | firstProposal.voteEnd, firstVoteEnd, "First proposal vote start has been incorrectly set" 144 | ); 145 | assertEq( 146 | firstProposal.isCanceled, 147 | firstCanceled, 148 | "First proposal cancelled status has been incorrectly set" 149 | ); 150 | 151 | L2GovernorMetadata.Proposal memory secondProposal = 152 | l2GovernorMetadata.getProposal(secondProposalId); 153 | assertEq( 154 | secondProposal.voteStart, 155 | secondVoteStart, 156 | "Second proposal vote start has been incorrectly set" 157 | ); 158 | assertEq( 159 | secondProposal.voteEnd, secondVoteEnd, "Second proposal vote start has been incorrectly set" 160 | ); 161 | assertEq( 162 | secondProposal.isCanceled, 163 | secondCanceled, 164 | "Second proposal cancelled status has been incorrectly set" 165 | ); 166 | } 167 | 168 | function testFuzz_CorrectlyUpdateProposalToCanceled( 169 | uint256 proposalId, 170 | uint256 voteStart, 171 | uint256 voteEnd 172 | ) public { 173 | voteEnd = mockL1Block.__boundL1VoteEnd(voteEnd); 174 | bytes memory payload = abi.encode(proposalId, voteStart, voteEnd, false); 175 | vm.prank(L2_CHAIN.wormholeRelayer); 176 | l2GovernorMetadata.receiveWormholeMessages( 177 | payload, 178 | new bytes[](0), 179 | MOCK_WORMHOLE_SERIALIZED_ADDRESS, 180 | L1_CHAIN.wormholeChainId, 181 | bytes32("") 182 | ); 183 | 184 | bytes memory secondPayload = abi.encode(proposalId, voteStart, voteEnd, true); 185 | vm.prank(L2_CHAIN.wormholeRelayer); 186 | l2GovernorMetadata.receiveWormholeMessages( 187 | secondPayload, 188 | new bytes[](0), 189 | MOCK_WORMHOLE_SERIALIZED_ADDRESS, 190 | L1_CHAIN.wormholeChainId, 191 | bytes32("0x1") 192 | ); 193 | 194 | L2GovernorMetadata.Proposal memory proposal = l2GovernorMetadata.getProposal(proposalId); 195 | 196 | assertEq(proposal.voteStart, voteStart, "Proposal vote start has been incorrectly set"); 197 | assertEq(proposal.voteEnd, voteEnd, "Proposal vote start has been incorrectly set"); 198 | assertEq(proposal.isCanceled, true, "Canceled status has been incorrectly set"); 199 | } 200 | 201 | function testFuzz_RevertIf_NotCalledByRelayer( 202 | uint256 proposalId, 203 | uint256 l1VoteStart, 204 | uint256 l1VoteEnd, 205 | address caller 206 | ) public { 207 | bytes memory payload = abi.encode(proposalId, l1VoteStart, l1VoteEnd); 208 | vm.assume(caller != L2_CHAIN.wormholeRelayer); 209 | vm.prank(caller); 210 | 211 | vm.expectRevert(WormholeReceiver.OnlyRelayerAllowed.selector); 212 | l2GovernorMetadata.receiveWormholeMessages( 213 | payload, new bytes[](0), bytes32(""), uint16(0), bytes32("") 214 | ); 215 | } 216 | 217 | function testFuzz_RevertIf_NotCalledByRegisteredSender( 218 | uint256 proposalId, 219 | uint256 l1VoteStart, 220 | uint256 l1VoteEnd, 221 | bytes32 caller 222 | ) public { 223 | vm.assume(caller != MOCK_WORMHOLE_SERIALIZED_ADDRESS); 224 | bytes memory payload = abi.encode(proposalId, l1VoteStart, l1VoteEnd); 225 | vm.assume(caller != MOCK_WORMHOLE_SERIALIZED_ADDRESS); 226 | vm.prank(L2_CHAIN.wormholeRelayer); 227 | vm.expectRevert(abi.encodeWithSelector(WormholeReceiver.UnregisteredSender.selector, caller)); 228 | l2GovernorMetadata.receiveWormholeMessages( 229 | payload, new bytes[](0), caller, L1_CHAIN.wormholeChainId, bytes32("") 230 | ); 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /script/helpers/Governors.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import { 5 | GovernorVotesComp, 6 | ERC20VotesComp 7 | } from "openzeppelin-flexible-voting/governance/extensions/GovernorVotesComp.sol"; 8 | import {GovernorVotes} from "openzeppelin-flexible-voting/governance/extensions/GovernorVotes.sol"; 9 | import {GovernorTimelockControl} from 10 | "openzeppelin-flexible-voting/governance/extensions/GovernorTimelockControl.sol"; 11 | import {ERC20Votes} from "openzeppelin-flexible-voting/token/ERC20/extensions/ERC20Votes.sol"; 12 | import { 13 | Governor as FlexGovernor, 14 | Governor, 15 | IGovernor 16 | } from "openzeppelin-flexible-voting/governance/Governor.sol"; 17 | import {TimelockController} from "openzeppelin-flexible-voting/governance/TimelockController.sol"; 18 | import {GovernorTimelockCompound} from 19 | "openzeppelin-flexible-voting/governance/extensions/GovernorTimelockCompound.sol"; 20 | import {ICompoundTimelock} from "openzeppelin-flexible-voting/vendor/compound/ICompoundTimelock.sol"; 21 | 22 | import {GovernorCountingFractional} from "flexible-voting/src/GovernorCountingFractional.sol"; 23 | 24 | contract GovernorTestnetSettings { 25 | function quorum(uint256) public view virtual returns (uint256) { 26 | return 1_000_000e18; 27 | } 28 | 29 | function votingDelay() public view virtual returns (uint256) { 30 | return 90; 31 | } 32 | 33 | function votingPeriod() public view virtual returns (uint256) { 34 | return 1800; 35 | } 36 | 37 | function proposalThreshold() public view virtual returns (uint256) { 38 | return 500_000e18; 39 | } 40 | } 41 | 42 | contract GovernorCompTestnet is 43 | GovernorVotesComp, 44 | GovernorCountingFractional, 45 | GovernorTimelockCompound, 46 | GovernorTestnetSettings 47 | { 48 | constructor(string memory _name, ERC20VotesComp _token, ICompoundTimelock _timelock) 49 | FlexGovernor(_name) 50 | GovernorVotesComp(_token) 51 | GovernorTimelockCompound(_timelock) 52 | {} 53 | 54 | function quorum(uint256 blockNumber) 55 | public 56 | view 57 | override(GovernorTestnetSettings, IGovernor) 58 | returns (uint256) 59 | { 60 | return GovernorTestnetSettings.quorum(blockNumber); 61 | } 62 | 63 | function votingDelay() public view override(GovernorTestnetSettings, IGovernor) returns (uint256) { 64 | return GovernorTestnetSettings.votingDelay(); 65 | } 66 | 67 | function votingPeriod() 68 | public 69 | view 70 | override(GovernorTestnetSettings, IGovernor) 71 | returns (uint256) 72 | { 73 | return GovernorTestnetSettings.votingPeriod(); 74 | } 75 | 76 | function proposalThreshold() 77 | public 78 | view 79 | override(FlexGovernor, GovernorTestnetSettings) 80 | returns (uint256) 81 | { 82 | return GovernorTestnetSettings.proposalThreshold(); 83 | } 84 | 85 | /// @dev We override this function to resolve ambiguity between inherited contracts. 86 | function castVoteWithReasonAndParamsBySig( 87 | uint256 proposalId, 88 | uint8 support, 89 | string calldata reason, 90 | bytes memory params, 91 | uint8 v, 92 | bytes32 r, 93 | bytes32 s 94 | ) public override(GovernorCountingFractional, IGovernor, FlexGovernor) returns (uint256) { 95 | return GovernorCountingFractional.castVoteWithReasonAndParamsBySig( 96 | proposalId, support, reason, params, v, r, s 97 | ); 98 | } 99 | 100 | /// @dev We override this function to resolve ambiguity between inherited contracts. 101 | function supportsInterface(bytes4 interfaceId) 102 | public 103 | view 104 | virtual 105 | override(Governor, GovernorTimelockCompound) 106 | returns (bool) 107 | { 108 | return GovernorTimelockCompound.supportsInterface(interfaceId); 109 | } 110 | 111 | /// @dev We override this function to resolve ambiguity between inherited contracts. 112 | function state(uint256 proposalId) 113 | public 114 | view 115 | virtual 116 | override(Governor, GovernorTimelockCompound) 117 | returns (ProposalState) 118 | { 119 | return GovernorTimelockCompound.state(proposalId); 120 | } 121 | 122 | /// @dev We override this function to resolve ambiguity between inherited contracts. 123 | function _execute( 124 | uint256 proposalId, 125 | address[] memory targets, 126 | uint256[] memory values, 127 | bytes[] memory calldatas, 128 | bytes32 descriptionHash 129 | ) internal virtual override(FlexGovernor, GovernorTimelockCompound) { 130 | return 131 | GovernorTimelockCompound._execute(proposalId, targets, values, calldatas, descriptionHash); 132 | } 133 | 134 | /// @dev We override this function to resolve ambiguity between inherited contracts. 135 | function _cancel( 136 | address[] memory targets, 137 | uint256[] memory values, 138 | bytes[] memory calldatas, 139 | bytes32 descriptionHash 140 | ) internal virtual override(FlexGovernor, GovernorTimelockCompound) returns (uint256) { 141 | return GovernorTimelockCompound._cancel(targets, values, calldatas, descriptionHash); 142 | } 143 | 144 | /// @dev We override this function to resolve ambiguity between inherited contracts. 145 | function _executor() 146 | internal 147 | view 148 | virtual 149 | override(FlexGovernor, GovernorTimelockCompound) 150 | returns (address) 151 | { 152 | return GovernorTimelockCompound._executor(); 153 | } 154 | } 155 | 156 | contract GovernorTestnet is 157 | GovernorVotes, 158 | GovernorCountingFractional, 159 | GovernorTestnetSettings, 160 | GovernorTimelockControl 161 | { 162 | constructor(string memory _name, ERC20Votes _token, TimelockController _timelock) 163 | FlexGovernor(_name) 164 | GovernorVotes(_token) 165 | GovernorTimelockControl(_timelock) 166 | {} 167 | 168 | function quorum(uint256 blockNumber) 169 | public 170 | view 171 | override(GovernorTestnetSettings, IGovernor) 172 | returns (uint256) 173 | { 174 | return GovernorTestnetSettings.quorum(blockNumber); 175 | } 176 | 177 | function votingDelay() public view override(GovernorTestnetSettings, IGovernor) returns (uint256) { 178 | return GovernorTestnetSettings.votingDelay(); 179 | } 180 | 181 | function votingPeriod() 182 | public 183 | view 184 | override(GovernorTestnetSettings, IGovernor) 185 | returns (uint256) 186 | { 187 | return GovernorTestnetSettings.votingPeriod(); 188 | } 189 | 190 | function proposalThreshold() 191 | public 192 | view 193 | override(FlexGovernor, GovernorTestnetSettings) 194 | returns (uint256) 195 | { 196 | return GovernorTestnetSettings.proposalThreshold(); 197 | } 198 | 199 | function castVoteWithReasonAndParamsBySig( 200 | uint256 proposalId, 201 | uint8 support, 202 | string calldata reason, 203 | bytes memory params, 204 | uint8 v, 205 | bytes32 r, 206 | bytes32 s 207 | ) public override(GovernorCountingFractional, IGovernor, FlexGovernor) returns (uint256) { 208 | return GovernorCountingFractional.castVoteWithReasonAndParamsBySig( 209 | proposalId, support, reason, params, v, r, s 210 | ); 211 | } 212 | 213 | /// @dev We override this function to resolve ambiguity between inherited contracts. 214 | function supportsInterface(bytes4 interfaceId) 215 | public 216 | view 217 | virtual 218 | override(Governor, GovernorTimelockControl) 219 | returns (bool) 220 | { 221 | return GovernorTimelockControl.supportsInterface(interfaceId); 222 | } 223 | 224 | /// @dev We override this function to resolve ambiguity between inherited contracts. 225 | function state(uint256 proposalId) 226 | public 227 | view 228 | virtual 229 | override(Governor, GovernorTimelockControl) 230 | returns (ProposalState) 231 | { 232 | return GovernorTimelockControl.state(proposalId); 233 | } 234 | 235 | /// @dev We override this function to resolve ambiguity between inherited contracts. 236 | function _execute( 237 | uint256 proposalId, 238 | address[] memory targets, 239 | uint256[] memory values, 240 | bytes[] memory calldatas, 241 | bytes32 descriptionHash 242 | ) internal virtual override(FlexGovernor, GovernorTimelockControl) { 243 | return GovernorTimelockControl._execute(proposalId, targets, values, calldatas, descriptionHash); 244 | } 245 | 246 | /// @dev We override this function to resolve ambiguity between inherited contracts. 247 | function _cancel( 248 | address[] memory targets, 249 | uint256[] memory values, 250 | bytes[] memory calldatas, 251 | bytes32 descriptionHash 252 | ) internal virtual override(FlexGovernor, GovernorTimelockControl) returns (uint256) { 253 | return GovernorTimelockControl._cancel(targets, values, calldatas, descriptionHash); 254 | } 255 | 256 | /// @dev We override this function to resolve ambiguity between inherited contracts. 257 | function _executor() 258 | internal 259 | view 260 | virtual 261 | override(FlexGovernor, GovernorTimelockControl) 262 | returns (address) 263 | { 264 | return GovernorTimelockControl._executor(); 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /test/WormholeL1ERC20Bridge.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {IGovernor} from "openzeppelin/governance/Governor.sol"; 5 | import {WormholeRelayerBasicTest} from "wormhole-solidity-sdk/testing/WormholeRelayerTest.sol"; 6 | import {ERC20VotesComp} from 7 | "openzeppelin-flexible-voting/governance/extensions/GovernorVotesComp.sol"; 8 | 9 | import {L1Block} from "src/L1Block.sol"; 10 | import {FakeERC20} from "src/FakeERC20.sol"; 11 | import {WormholeL1ERC20Bridge} from "src/WormholeL1ERC20Bridge.sol"; 12 | import {WormholeL2ERC20} from "src/WormholeL2ERC20.sol"; 13 | import {L1ERC20BridgeHarness} from "test/harness/L1ERC20BridgeHarness.sol"; 14 | import {TestConstants} from "test/Constants.sol"; 15 | import {GovernorFlexibleVotingMock} from "test/mock/GovernorMock.sol"; 16 | 17 | contract L1ERC20BridgeTest is TestConstants, WormholeRelayerBasicTest { 18 | WormholeL2ERC20 l2Erc20; 19 | FakeERC20 l1Erc20; 20 | WormholeL1ERC20Bridge l1Erc20Bridge; 21 | GovernorFlexibleVotingMock gov; 22 | 23 | event VoteCast( 24 | address indexed voter, 25 | uint256 proposalId, 26 | uint256 voteAgainst, 27 | uint256 voteFor, 28 | uint256 voteAbstain 29 | ); 30 | 31 | event TokenBridged( 32 | address indexed sender, 33 | address indexed targetAddress, 34 | uint256 indexed targetChain, 35 | uint256 amount, 36 | address targetToken 37 | ); 38 | 39 | constructor() { 40 | setForkChains(TESTNET, L1_CHAIN.wormholeChainId, L2_CHAIN.wormholeChainId); 41 | } 42 | 43 | function setUpSource() public override { 44 | l1Erc20 = new FakeERC20("Hello", "WRLD"); 45 | gov = new GovernorFlexibleVotingMock("Testington Dao", ERC20VotesComp(address(l1Erc20))); 46 | l1Erc20Bridge = new WormholeL1ERC20Bridge( 47 | address(l1Erc20), 48 | L1_CHAIN.wormholeRelayer, 49 | address(gov), 50 | L1_CHAIN.wormholeChainId, 51 | L2_CHAIN.wormholeChainId, 52 | msg.sender 53 | ); 54 | } 55 | 56 | function setUpTarget() public override { 57 | L1Block l1Block = new L1Block(); 58 | l2Erc20 = new WormholeL2ERC20( 59 | "Hello", 60 | "WRLD", 61 | L2_CHAIN.wormholeRelayer, 62 | address(l1Block), 63 | L2_CHAIN.wormholeChainId, 64 | L1_CHAIN.wormholeChainId, 65 | msg.sender 66 | ); 67 | vm.prank(l2Erc20.owner()); 68 | l2Erc20.setRegisteredSender( 69 | L1_CHAIN.wormholeChainId, bytes32(uint256(uint160(address(l1Erc20Bridge)))) 70 | ); 71 | } 72 | } 73 | 74 | contract Constructor is L1ERC20BridgeTest { 75 | function testForkFuzz_CorrectlySetAllArgs(address l1Erc) public { 76 | WormholeL1ERC20Bridge l1Erc20Bridge = new WormholeL1ERC20Bridge( 77 | address(l1Erc), 78 | L1_CHAIN.wormholeRelayer, 79 | address(gov), 80 | L1_CHAIN.wormholeChainId, 81 | L2_CHAIN.wormholeChainId, 82 | msg.sender 83 | ); 84 | assertEq(address(l1Erc20Bridge.L1_TOKEN()), l1Erc, "L1 token is not set correctly"); 85 | } 86 | } 87 | 88 | contract Initialize is L1ERC20BridgeTest { 89 | function testFork_CorrectlyInitializeL2Token(address l2Erc20) public { 90 | l1Erc20Bridge.initialize(address(l2Erc20)); 91 | assertEq(l1Erc20Bridge.L2_TOKEN_ADDRESS(), l2Erc20, "L2 token address is not setup correctly"); 92 | assertTrue(l1Erc20Bridge.INITIALIZED(), "Bridge isn't initialized"); 93 | } 94 | 95 | function testFork_RevertWhen_AlreadyInitializedWithL2Erc20Address(address l2Erc20) public { 96 | l1Erc20Bridge.initialize(address(l2Erc20)); 97 | 98 | vm.expectRevert(WormholeL1ERC20Bridge.AlreadyInitialized.selector); 99 | l1Erc20Bridge.initialize(address(l2Erc20)); 100 | } 101 | } 102 | 103 | contract Deposit is L1ERC20BridgeTest { 104 | function testForkFuzz_CorrectlyDepositTokens(uint96 _amount) public { 105 | l1Erc20Bridge.initialize(address(l2Erc20)); 106 | uint256 cost = l1Erc20Bridge.quoteDeliveryCost(L2_CHAIN.wormholeChainId); 107 | vm.recordLogs(); 108 | 109 | l1Erc20.approve(address(l1Erc20Bridge), _amount); 110 | l1Erc20.mint(address(this), _amount); 111 | 112 | vm.expectEmit(); 113 | emit TokenBridged( 114 | address(this), address(this), L2_CHAIN.wormholeChainId, _amount, address(l2Erc20) 115 | ); 116 | l1Erc20Bridge.deposit{value: cost}(address(this), _amount); 117 | 118 | uint256 bridgeBalance = l1Erc20.balanceOf(address(l1Erc20Bridge)); 119 | assertEq(bridgeBalance, _amount, "Amount has not been transfered to the bridge"); 120 | 121 | vm.prank(L2_CHAIN.wormholeRelayer); 122 | performDelivery(); 123 | 124 | vm.selectFork(targetFork); 125 | assertEq(l2Erc20.balanceOf(address(this)), _amount, "L2 token balance is not correct"); 126 | } 127 | } 128 | 129 | // One test should get the emit event the ther should get the VoteCast 130 | contract ReceiveWormholeMessages is L1ERC20BridgeTest { 131 | // Single L1 Vote 132 | function testFuzz_CastVoteOnL1(uint32 forVotes, uint32 againstVotes, uint32 abstainVotes) public { 133 | abstainVotes = 0; 134 | uint96 totalVotes = uint96(forVotes) + againstVotes + abstainVotes; // Add 1 so the user always 135 | // has voting power 136 | if (totalVotes == 0) ++totalVotes; 137 | // Mint and transfer tokens to bridge 138 | l1Erc20.mint(address(this), totalVotes); 139 | l1Erc20.approve(address(this), totalVotes); 140 | l1Erc20.transferFrom(address(this), address(l1Erc20Bridge), totalVotes); 141 | 142 | vm.roll(block.number + 1); 143 | address[] memory targets = new address[](1); 144 | bytes[] memory calldatas = new bytes[](1); 145 | uint256[] memory values = new uint256[](1); 146 | 147 | bytes memory proposalCalldata = abi.encode(FakeERC20.mint.selector, address(gov), 100_000); 148 | targets[0] = address(l1Erc20); 149 | calldatas[0] = proposalCalldata; 150 | values[0] = 0; 151 | 152 | uint256 proposalId = gov.propose(targets, values, calldatas, "Proposal: To inflate token"); 153 | uint256 voteEnd = gov.proposalDeadline(proposalId); 154 | 155 | vm.roll(voteEnd - 1); 156 | bytes memory voteCalldata = abi.encode(proposalId, forVotes, againstVotes, abstainVotes); 157 | vm.prank(l1Erc20Bridge.owner()); 158 | l1Erc20Bridge.setRegisteredSender(L1_CHAIN.wormholeChainId, MOCK_WORMHOLE_SERIALIZED_ADDRESS); 159 | 160 | vm.expectEmit(); 161 | emit VoteCast(L1_CHAIN.wormholeRelayer, proposalId, forVotes, againstVotes, abstainVotes); 162 | 163 | vm.prank(L1_CHAIN.wormholeRelayer); 164 | l1Erc20Bridge.receiveWormholeMessages( 165 | voteCalldata, 166 | new bytes[](0), 167 | MOCK_WORMHOLE_SERIALIZED_ADDRESS, 168 | L1_CHAIN.wormholeChainId, 169 | bytes32("") 170 | ); 171 | } 172 | } 173 | 174 | // Top level receive is tested in WormholeL2ERC20 and L2VoteAggregator 175 | contract _ReceiveWithdrawalWormholeMessages is L1ERC20BridgeTest { 176 | event Withdraw(address indexed account, uint256 amount); 177 | 178 | function testForkFuzz_CorrectlyReceiveWithdrawal( 179 | address _account, 180 | uint96 _amount, 181 | address l2Erc20 182 | ) public { 183 | vm.assume(_account != address(0)); 184 | FakeERC20 l1Erc20 = new FakeERC20("Hello", "WRLD"); 185 | L1ERC20BridgeHarness l1Erc20Bridge = new L1ERC20BridgeHarness( 186 | address(l1Erc20), 187 | L1_CHAIN.wormholeRelayer, 188 | address(gov), 189 | L1_CHAIN.wormholeChainId, 190 | L2_CHAIN.wormholeChainId, 191 | msg.sender 192 | ); 193 | 194 | l1Erc20Bridge.initialize(address(l2Erc20)); 195 | l1Erc20.approve(address(this), _amount); 196 | l1Erc20.mint(address(this), _amount); 197 | vm.deal(address(this), 1 ether); 198 | 199 | l1Erc20.transfer(address(l1Erc20Bridge), _amount); 200 | assertEq(l1Erc20.balanceOf(address(l1Erc20Bridge)), _amount, "The Bridge balance is incorrect"); 201 | 202 | bytes memory withdrawalCalldata = abi.encodePacked(_account, uint256(_amount)); 203 | vm.expectEmit(); 204 | emit Withdraw(_account, _amount); 205 | l1Erc20Bridge.exposed_receiveWithdrawalWormholeMessages( 206 | withdrawalCalldata, new bytes[](0), bytes32(""), uint16(0), bytes32("") 207 | ); 208 | assertEq(l1Erc20.balanceOf(address(_account)), _amount, "The account balance is incorrect"); 209 | } 210 | } 211 | 212 | contract _Withdraw is L1ERC20BridgeTest { 213 | event Withdraw(address indexed account, uint256 amount); 214 | 215 | function testFork_CorrectlyWithdrawTokens(address _account, uint224 _amount, address l2Erc20) 216 | public 217 | { 218 | vm.assume(_account != address(0)); 219 | 220 | FakeERC20 l1Erc20 = new FakeERC20("Hello", "WRLD"); 221 | L1ERC20BridgeHarness l1Erc20Bridge = new L1ERC20BridgeHarness( 222 | address(l1Erc20), 223 | L1_CHAIN.wormholeRelayer, 224 | address(gov), 225 | L1_CHAIN.wormholeChainId, 226 | L2_CHAIN.wormholeChainId, 227 | msg.sender 228 | ); 229 | l1Erc20Bridge.initialize(address(l2Erc20)); 230 | 231 | l1Erc20.approve(address(this), _amount); 232 | l1Erc20.mint(address(this), _amount); 233 | vm.deal(address(this), 1 ether); 234 | 235 | l1Erc20.transfer(address(l1Erc20Bridge), _amount); 236 | assertEq(l1Erc20.balanceOf(address(l1Erc20Bridge)), _amount, "The Bridge balance is incorrect"); 237 | 238 | vm.expectEmit(); 239 | emit Withdraw(_account, _amount); 240 | l1Erc20Bridge.withdraw(_account, _amount); 241 | assertEq(l1Erc20.balanceOf(address(_account)), _amount, "The account balance is incorrect"); 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /test/L2CountingFractional.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {TestConstants} from "test/Constants.sol"; 5 | import {L2CountingFractionalHarness} from "test/harness/L2CountingFractionalHarness.sol"; 6 | 7 | contract L2CountingFractionalTest is TestConstants { 8 | L2CountingFractionalHarness countingFractional; 9 | 10 | function setUp() public { 11 | countingFractional = new L2CountingFractionalHarness(); 12 | } 13 | } 14 | 15 | contract COUNTING_MODE is L2CountingFractionalTest { 16 | function test_CorrectlyReceiveCountingMode() public { 17 | assertEq( 18 | countingFractional.COUNTING_MODE(), 19 | "support=bravo&quorum=for,abstain¶ms=fractional", 20 | "COUNTING_MODE is incorrect" 21 | ); 22 | } 23 | } 24 | 25 | contract HasVoted is L2CountingFractionalTest { 26 | function testFuzz_AccountHasNotCastAVote(uint256 proposalId, address account) public { 27 | bool hasVoted = countingFractional.hasVoted(proposalId, account); 28 | assertFalse(hasVoted, "Account has voted"); 29 | } 30 | 31 | function testFuzz_AccountHasCastAVoteWithCountVote( 32 | uint256 proposalId, 33 | address account, 34 | uint120 totalWeight 35 | ) public { 36 | vm.assume(totalWeight != 0); 37 | countingFractional.exposed_countVote(proposalId, account, 1, totalWeight, ""); 38 | bool hasVoted = countingFractional.hasVoted(proposalId, account); 39 | assertTrue(hasVoted, "Account has not voted"); 40 | } 41 | } 42 | 43 | contract VoteWeightCast is L2CountingFractionalTest { 44 | function testFuzz_AccountHasNotCastAVote(uint256 proposalId, address account) public { 45 | uint128 voteWeight = countingFractional.voteWeightCast(proposalId, account); 46 | assertEq(voteWeight, 0); 47 | } 48 | 49 | function testFuzz_AccountHasCastAVote(uint256 proposalId, address account, uint120 totalWeight) 50 | public 51 | { 52 | vm.assume(totalWeight != 0); 53 | countingFractional.exposed_countVote(proposalId, account, 1, totalWeight, ""); 54 | uint128 voteWeight = countingFractional.voteWeightCast(proposalId, account); 55 | assertEq(voteWeight, totalWeight); 56 | } 57 | } 58 | 59 | contract ProposalVotes is L2CountingFractionalTest { 60 | function testFuzz_ProposalHasNotBeenCreatedYet(uint256 proposalId) public { 61 | (uint256 againstVotes, uint256 forVotes, uint256 abstainVotes) = 62 | countingFractional.proposalVotes(proposalId); 63 | assertEq(againstVotes, 0, "There are against votes"); 64 | assertEq(forVotes, 0, "There are for votes"); 65 | assertEq(abstainVotes, 0, "There are abstain votes"); 66 | } 67 | 68 | function testFuzz_ProposalHasVotesCast( 69 | uint256 proposalId, 70 | uint128 against, 71 | uint128 inFavor, 72 | uint128 abstain 73 | ) public { 74 | countingFractional.workaround_createProposalVote(proposalId, against, inFavor, abstain); 75 | (uint256 againstVotes, uint256 forVotes, uint256 abstainVotes) = 76 | countingFractional.proposalVotes(proposalId); 77 | assertEq(againstVotes, against, "Against votes are incorrect"); 78 | assertEq(forVotes, inFavor, "For votes are incorrect"); 79 | assertEq(abstainVotes, abstain, "Abstain votes are incorrect"); 80 | } 81 | } 82 | 83 | contract _CountVote is L2CountingFractionalTest { 84 | function testFuzz_RevertIf_AccountHasNoWeightOnProposal( 85 | uint256 proposalId, 86 | address account, 87 | uint8 support, 88 | bytes memory voteData 89 | ) public { 90 | support = uint8(bound(support, 0, 2)); 91 | vm.expectRevert("L2CountingFractional: no weight"); 92 | countingFractional.exposed_countVote(proposalId, account, support, 0, voteData); 93 | } 94 | 95 | function testFuzz_CorrectlySubmitCountVoteFractional( 96 | uint256 proposalId, 97 | address account, 98 | uint8 support, 99 | uint40 against, 100 | uint40 inFavor, 101 | uint40 abstain 102 | ) public { 103 | uint128 totalWeight = uint128(against) + inFavor + abstain; 104 | vm.assume(totalWeight != 0); 105 | 106 | bytes memory voteData = abi.encodePacked(uint128(against), uint128(inFavor), uint128(abstain)); 107 | support = uint8(bound(support, 0, 2)); 108 | countingFractional.exposed_countVote(proposalId, account, support, totalWeight, voteData); 109 | } 110 | 111 | function testFuzz_RevertIf_SubmittedVoteIsGreaterThanTheTotalWeight( 112 | uint256 proposalId, 113 | address account, 114 | uint8 support, 115 | uint128 totalWeight 116 | ) public { 117 | vm.assume(totalWeight != 0); 118 | support = uint8(bound(support, 0, 2)); 119 | 120 | countingFractional.workaround_createProposalVoterWeightCast(proposalId, account, totalWeight); 121 | vm.expectRevert("L2CountingFractional: all weight cast"); 122 | countingFractional.exposed_countVote(proposalId, account, support, 1, ""); 123 | } 124 | } 125 | 126 | contract _CountVoteNominal is L2CountingFractionalTest { 127 | function testFuzz_CorrectlyTallyAgainstVote( 128 | uint256 proposalId, 129 | address account, 130 | uint128 totalWeight 131 | ) public { 132 | vm.assume(totalWeight != 0); 133 | 134 | countingFractional.exposed_countVoteNominal(proposalId, account, totalWeight, 0); 135 | (uint256 againstVotes,,) = countingFractional.proposalVotes(proposalId); 136 | assertEq(againstVotes, totalWeight, "Against votes are incorrect"); 137 | } 138 | 139 | function testFuzz_CorrectlyTallyForVote(uint256 proposalId, address account, uint128 totalWeight) 140 | public 141 | { 142 | vm.assume(totalWeight != 0); 143 | 144 | countingFractional.exposed_countVoteNominal(proposalId, account, totalWeight, 1); 145 | (, uint256 forVotes,) = countingFractional.proposalVotes(proposalId); 146 | assertEq(forVotes, totalWeight, "For votes are incorrect"); 147 | } 148 | 149 | function testFuzz_CorrectlyTallyAbstainVote( 150 | uint256 proposalId, 151 | address account, 152 | uint128 totalWeight 153 | ) public { 154 | vm.assume(totalWeight != 0); 155 | 156 | countingFractional.exposed_countVoteNominal(proposalId, account, totalWeight, 2); 157 | (,, uint256 abstainVotes) = countingFractional.proposalVotes(proposalId); 158 | assertEq(abstainVotes, totalWeight, "Abstain votes are incorrect"); 159 | } 160 | 161 | function testFuzz_RevertIf_InvalidVote( 162 | uint256 proposalId, 163 | address account, 164 | uint128 totalWeight, 165 | uint8 support 166 | ) public { 167 | vm.assume(totalWeight != 0); 168 | support = uint8(bound(support, 3, type(uint8).max)); 169 | 170 | vm.expectRevert( 171 | "L2CountingFractional: invalid support value, must be included in VoteType enum" 172 | ); 173 | countingFractional.exposed_countVoteNominal(proposalId, account, totalWeight, support); 174 | } 175 | 176 | function testFuzz_RevertIf_VoteExceedsWeight(uint256 proposalId, address account) public { 177 | countingFractional.exposed_countVoteNominal(proposalId, account, 1, 0); 178 | vm.expectRevert("L2CountingFractional: vote would exceed weight"); 179 | countingFractional.exposed_countVoteNominal(proposalId, account, 0, 0); 180 | } 181 | } 182 | 183 | contract _CountVoteFractional is L2CountingFractionalTest { 184 | function testFuzz_CorrectlyTallyVote( 185 | uint256 proposalId, 186 | address account, 187 | uint40 against, 188 | uint40 inFavor, 189 | uint40 abstain 190 | ) public { 191 | uint128 totalWeight = uint128(against) + inFavor + abstain; 192 | vm.assume(totalWeight != 0); 193 | bytes memory voteData = abi.encodePacked(uint128(against), uint128(inFavor), uint128(abstain)); 194 | 195 | countingFractional.exposed_countVoteFractional(proposalId, account, totalWeight, voteData); 196 | } 197 | 198 | function testFuzz_RevertIf_VoteDataIsTooShort( 199 | uint256 proposalId, 200 | address account, 201 | uint40 against, 202 | uint40 inFavor, 203 | uint40 abstain 204 | ) public { 205 | uint128 totalWeight = uint120(against) + inFavor + abstain; 206 | vm.assume(totalWeight != 0); 207 | bytes memory voteData = abi.encodePacked(uint120(against), uint128(inFavor), uint128(abstain)); 208 | 209 | vm.expectRevert("L2CountingFractional: invalid voteData"); 210 | countingFractional.exposed_countVoteFractional(proposalId, account, totalWeight, voteData); 211 | } 212 | 213 | function testFuzz_RevertIf_VoteDataIsTooLong( 214 | uint256 proposalId, 215 | address account, 216 | uint40 against, 217 | uint40 inFavor, 218 | uint40 abstain 219 | ) public { 220 | uint128 totalWeight = uint120(against) + inFavor + abstain; 221 | vm.assume(totalWeight != 0); 222 | bytes memory voteData = abi.encodePacked(uint136(against), uint128(inFavor), uint128(abstain)); 223 | 224 | vm.expectRevert("L2CountingFractional: invalid voteData"); 225 | countingFractional.exposed_countVoteFractional(proposalId, account, totalWeight, voteData); 226 | } 227 | 228 | function testFuzz_RevertIf_VotingWeightHasBeenExceeded( 229 | uint256 proposalId, 230 | address account, 231 | uint40 against, 232 | uint40 inFavor, 233 | uint40 abstain 234 | ) public { 235 | uint128 totalWeight = 0; 236 | vm.assume(uint128(against) + inFavor + abstain != 0); 237 | bytes memory voteData = abi.encodePacked(uint128(against), uint128(inFavor), uint128(abstain)); 238 | 239 | vm.expectRevert("L2CountingFractional: vote would exceed weight"); 240 | countingFractional.exposed_countVoteFractional(proposalId, account, totalWeight, voteData); 241 | } 242 | } 243 | 244 | contract _DecodePackedVotes is L2CountingFractionalTest { 245 | function testFuzz_CorrectlyDecodePackedVotes( 246 | uint128 againstVotes, 247 | uint128 forVotes, 248 | uint128 abstainVotes 249 | ) public { 250 | bytes memory voteData = abi.encodePacked(againstVotes, forVotes, abstainVotes); 251 | (uint128 decodedAgainst, uint128 decodedFor, uint128 decodedAbstain) = 252 | countingFractional.exposed_decodePackedVotes(voteData); 253 | assertEq(decodedAgainst, againstVotes, "Decoded against is incorrect"); 254 | assertEq(decodedFor, forVotes, "Decoded for is incorrect"); 255 | assertEq(decodedAbstain, abstainVotes, "Decoded abstain is incorrect"); 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /src/L2VoteAggregator.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {ERC20Votes} from "openzeppelin/token/ERC20/extensions/ERC20Votes.sol"; 5 | import {SafeCast} from "openzeppelin/utils/math/SafeCast.sol"; 6 | import {EIP712} from "openzeppelin/utils/cryptography/EIP712.sol"; 7 | import {ECDSA} from "openzeppelin/utils/cryptography/ECDSA.sol"; 8 | 9 | import {L2GovernorMetadata} from "src/WormholeL2GovernorMetadata.sol"; 10 | import {L2CountingFractional} from "src/L2CountingFractional.sol"; 11 | 12 | /// @notice A contract to collect votes on L2 to be bridged to L1. 13 | abstract contract L2VoteAggregator is EIP712, L2GovernorMetadata, L2CountingFractional { 14 | bytes32 public constant BALLOT_TYPEHASH = keccak256("Ballot(uint256 proposalId,uint8 support)"); 15 | 16 | /// @notice The token used to vote on proposals provided by the `GovernorMetadata`. 17 | ERC20Votes public immutable VOTING_TOKEN; 18 | 19 | /// @notice The address of the bridge that receives L2 votes. 20 | address public L1_BRIDGE_ADDRESS; 21 | 22 | /// @notice Used to indicate whether the contract has been initialized with the L1 bridge address. 23 | bool public INITIALIZED = false; 24 | 25 | /// @dev Thrown when an address has no voting weight on a proposal. 26 | error NoWeight(); 27 | 28 | /// @dev Thrown when an address has already voted. 29 | error AlreadyVoted(); 30 | 31 | /// @dev Thrown when an invalid vote is cast. 32 | error InvalidVoteType(); 33 | 34 | /// @dev Thrown when proposal is inactive. 35 | error ProposalInactive(); 36 | 37 | /// @dev Contract is already initialized with an L2 token. 38 | error AlreadyInitialized(); 39 | 40 | /// @dev We do not support the method, but provide it to be compatible with 3rd party tooling. 41 | error UnsupportedMethod(); 42 | 43 | /// @dev The voting options corresponding to those used in the Governor. 44 | enum VoteType { 45 | Against, 46 | For, 47 | Abstain 48 | } 49 | 50 | /// @dev The states of a proposal on L2. 51 | enum ProposalState { 52 | Pending, 53 | Active, 54 | Canceled, 55 | INVALID_Defeated, 56 | INVALID_Succeeded, 57 | INVALID_Queued, 58 | Expired, 59 | INVALID_Executed 60 | } 61 | 62 | /// @notice A mapping of proposal to a mapping of voter address to boolean indicating whether a 63 | /// voter has voted or not. 64 | mapping(uint256 proposalId => mapping(address voterAddress => bool)) private 65 | _proposalVotersHasVoted; 66 | 67 | /// @dev Emitted when a vote is cast on L2. 68 | event VoteCast( 69 | address indexed voter, uint256 proposalId, uint8 support, uint256 weight, string reason 70 | ); 71 | 72 | /** 73 | * @dev Emitted when a vote is cast with params. 74 | * 75 | * Note: `support` values should be seen as buckets. Their interpretation depends on the voting 76 | * module used. 77 | * `params` are additional encoded parameters. Their interpepretation also depends on the voting 78 | * module used. 79 | */ 80 | event VoteCastWithParams( 81 | address indexed voter, 82 | uint256 proposalId, 83 | uint8 support, 84 | uint256 weight, 85 | string reason, 86 | bytes params 87 | ); 88 | 89 | event VoteBridged( 90 | uint256 indexed proposalId, uint256 voteAgainst, uint256 voteFor, uint256 voteAbstain 91 | ); 92 | 93 | /// @param _votingToken The token used to vote on proposals. 94 | constructor(address _votingToken) EIP712("L2VoteAggregator", "1") { 95 | VOTING_TOKEN = ERC20Votes(_votingToken); 96 | } 97 | 98 | function initialize(address l1BridgeAddress) public { 99 | if (INITIALIZED) revert AlreadyInitialized(); 100 | INITIALIZED = true; 101 | L1_BRIDGE_ADDRESS = l1BridgeAddress; 102 | } 103 | 104 | /// @notice This function does not make sense in the L2 context, but we have added it to have 105 | /// compatibility with existing Governor tooling. 106 | function votingDelay() external view virtual returns (uint256) { 107 | return 0; 108 | } 109 | 110 | /// @notice This function does not make sense in the L2 context, but we have added it to have 111 | /// compatibility with existing Governor tooling. 112 | function votingPeriod() external view virtual returns (uint256) { 113 | return 0; 114 | } 115 | 116 | /// @notice This function does not make sense in the L2 context, but we have added it to have 117 | /// compatibility with existing Governor tooling. 118 | function quorum(uint256) external view virtual returns (uint256) { 119 | return 0; 120 | } 121 | 122 | /// @notice This function does not make sense in the L2 context, but we have added it to have 123 | /// compatibility with existing Governor tooling. 124 | function proposalThreshold() external view virtual returns (uint256) { 125 | return 0; 126 | } 127 | 128 | // @notice Shows the state of of a proposal on L2. We only support a subset of the Governor 129 | // proposal states. If the vote has not started the state is pending, if voting has started it is 130 | // active, if it has been canceled then the state is canceled, and if the voting has finished 131 | // without it being canceled we will mark it as expired. We use expired because users can no 132 | // longer vote and no other L2 action can be taken on the proposal. 133 | function state(uint256 proposalId) external view virtual returns (ProposalState) { 134 | L2GovernorMetadata.Proposal memory proposal = getProposal(proposalId); 135 | if (VOTING_TOKEN.clock() < proposal.voteStart) return ProposalState.Pending; 136 | else if (proposalL2VoteActive(proposalId)) return ProposalState.Active; 137 | else if (proposal.isCanceled) return ProposalState.Canceled; 138 | else return ProposalState.Expired; 139 | } 140 | 141 | /// @notice This function does not make sense in the L2 context because it requires an L2 block as 142 | /// the second parameter rather than an L1 block. We added it to have 143 | /// compatibility with existing Governor tooling. 144 | function getVotes(address, uint256) external view virtual returns (uint256) { 145 | return 0; 146 | } 147 | 148 | /// @notice This function does not make sense in the L2 context, but we have added it to have 149 | /// compatibility with existing Governor tooling. 150 | function propose(address[] memory, uint256[] memory, bytes[] memory, string memory) 151 | external 152 | virtual 153 | returns (uint256) 154 | { 155 | revert UnsupportedMethod(); 156 | } 157 | 158 | /// @notice This function does not make sense in the L2 context, but we have added it to have 159 | /// compatibility with existing Governor tooling. 160 | function execute(address[] memory, uint256[] memory, bytes[] memory, bytes32) 161 | external 162 | payable 163 | virtual 164 | returns (uint256) 165 | { 166 | revert UnsupportedMethod(); 167 | } 168 | 169 | /// @notice Where a user can express their vote based on their L2 token voting power. 170 | /// @param proposalId The id of the proposal to vote on. 171 | /// @param support The type of vote to cast. 172 | function castVote(uint256 proposalId, VoteType support) public returns (uint256) { 173 | return _castVote(proposalId, msg.sender, uint8(support), ""); 174 | } 175 | 176 | /// @notice Where a user can express their vote based on their L2 token voting power, and provide 177 | /// a reason. 178 | /// @param proposalId The id of the proposal to vote on. 179 | /// @param support The type of vote to cast. 180 | /// @param reason The reason the vote was cast. 181 | function castVoteWithReason(uint256 proposalId, VoteType support, string calldata reason) 182 | public 183 | virtual 184 | returns (uint256) 185 | { 186 | return _castVote(proposalId, msg.sender, uint8(support), reason); 187 | } 188 | 189 | function castVoteWithReasonAndParams( 190 | uint256 proposalId, 191 | uint8 support, 192 | string calldata reason, 193 | bytes memory params 194 | ) public virtual returns (uint256) { 195 | return _castVote(proposalId, msg.sender, support, reason, params); 196 | } 197 | 198 | /// @notice Where a user can express their vote based on their L2 token voting power using a 199 | /// signature. 200 | /// @param proposalId The id of the proposal to vote on. 201 | /// @param support The type of vote to cast. 202 | function castVoteBySig(uint256 proposalId, VoteType support, uint8 v, bytes32 r, bytes32 s) 203 | public 204 | virtual 205 | returns (uint256) 206 | { 207 | address voter = ECDSA.recover( 208 | _hashTypedDataV4(keccak256(abi.encode(BALLOT_TYPEHASH, proposalId, support))), v, r, s 209 | ); 210 | return _castVote(proposalId, voter, uint8(support), ""); 211 | } 212 | 213 | /// @notice Bridges a vote to the L1. 214 | /// @param proposalId The id of the proposal to bridge. 215 | function bridgeVote(uint256 proposalId) external payable { 216 | if (!proposalL1VoteActive(proposalId)) revert ProposalInactive(); 217 | 218 | (uint256 againstVotes, uint256 forVotes, uint256 abstainVotes) = proposalVotes(proposalId); 219 | 220 | bytes memory proposalCalldata = abi.encode(proposalId, againstVotes, forVotes, abstainVotes); 221 | _bridgeVote(proposalCalldata); 222 | emit VoteBridged(proposalId, againstVotes, forVotes, abstainVotes); 223 | } 224 | 225 | function _bridgeVote(bytes memory proposalCalldata) internal virtual; 226 | 227 | /// @notice Method which returns the deadline for token holders to express their voting 228 | /// preferences to this Aggregator contract. Will always be before the Governor's corresponding 229 | /// proposal deadline. 230 | /// @param proposalId The ID of the proposal. 231 | /// @return _lastVotingBlock the voting block where L2 voting ends. 232 | function internalVotingPeriodEnd(uint256 proposalId) 233 | public 234 | view 235 | returns (uint256 _lastVotingBlock) 236 | { 237 | L2GovernorMetadata.Proposal memory proposal = getProposal(proposalId); 238 | _lastVotingBlock = proposal.voteEnd - CAST_VOTE_WINDOW; 239 | } 240 | 241 | function _castVote(uint256 proposalId, address account, uint8 support, string memory reason) 242 | internal 243 | virtual 244 | returns (uint256) 245 | { 246 | return _castVote(proposalId, account, support, reason, ""); 247 | } 248 | 249 | function _castVote( 250 | uint256 proposalId, 251 | address account, 252 | uint8 support, 253 | string memory reason, 254 | bytes memory params 255 | ) internal virtual returns (uint256) { 256 | if (!proposalL2VoteActive(proposalId)) revert ProposalInactive(); 257 | 258 | L2GovernorMetadata.Proposal memory proposal = getProposal(proposalId); 259 | uint256 weight = VOTING_TOKEN.getPastVotes(account, proposal.voteStart); 260 | if (weight == 0) revert NoWeight(); 261 | _countVote(proposalId, account, support, weight, params); 262 | 263 | if (params.length == 0) emit VoteCast(account, proposalId, support, weight, reason); 264 | else emit VoteCastWithParams(account, proposalId, support, weight, reason, params); 265 | 266 | return weight; 267 | } 268 | 269 | function proposalL2VoteActive(uint256 proposalId) public view returns (bool active) { 270 | L2GovernorMetadata.Proposal memory proposal = getProposal(proposalId); 271 | 272 | return L1_BLOCK.number() <= internalVotingPeriodEnd(proposalId) 273 | && L1_BLOCK.number() >= proposal.voteStart && !proposal.isCanceled; 274 | } 275 | 276 | function proposalL1VoteActive(uint256 proposalId) public view returns (bool active) { 277 | L2GovernorMetadata.Proposal memory proposal = getProposal(proposalId); 278 | 279 | return L1_BLOCK.number() <= proposal.voteEnd && L1_BLOCK.number() >= proposal.voteStart 280 | && !proposal.isCanceled; 281 | } 282 | } 283 | -------------------------------------------------------------------------------- /test/WormholeL1VotePool.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {WormholeRelayerBasicTest} from "wormhole-solidity-sdk/testing/WormholeRelayerTest.sol"; 5 | import {ERC20VotesComp} from 6 | "openzeppelin-flexible-voting/governance/extensions/GovernorVotesComp.sol"; 7 | 8 | import {FakeERC20} from "src/FakeERC20.sol"; 9 | import {L1Block} from "src/L1Block.sol"; 10 | import {L1VotePool} from "src/L1VotePool.sol"; 11 | import {WormholeL2VoteAggregator} from "src/WormholeL2VoteAggregator.sol"; 12 | 13 | import {TestConstants} from "test/Constants.sol"; 14 | import {WormholeL1VotePoolHarness} from "test/harness/WormholeL1VotePoolHarness.sol"; 15 | import {WormholeL2VoteAggregatorHarness} from "test/harness/WormholeL2VoteAggregatorHarness.sol"; 16 | import {GovernorMetadataMock} from "test/mock/GovernorMetadataMock.sol"; 17 | import {GovernorFlexibleVotingMock} from "test/mock/GovernorMock.sol"; 18 | 19 | contract WormholeL1VotePoolTest is TestConstants, WormholeRelayerBasicTest { 20 | WormholeL1VotePoolHarness l1VotePool; 21 | WormholeL2VoteAggregatorHarness l2VoteAggregator; 22 | FakeERC20 l2Erc20; 23 | FakeERC20 l1Erc20; 24 | GovernorFlexibleVotingMock gov; 25 | uint128 DEFAULT_VOTE_END; 26 | 27 | event VoteCast( 28 | address indexed voter, 29 | uint256 proposalId, 30 | uint256 voteAgainst, 31 | uint256 voteFor, 32 | uint256 voteAbstain 33 | ); 34 | event VoteBridged( 35 | uint256 indexed proposalId, uint256 voteAgainst, uint256 voteFor, uint256 voteAbstain 36 | ); 37 | 38 | constructor() { 39 | setForkChains(TESTNET, L2_CHAIN.wormholeChainId, L1_CHAIN.wormholeChainId); 40 | } 41 | 42 | function setUpSource() public override { 43 | L1Block l1Block = new L1Block(); 44 | l2Erc20 = new FakeERC20("GovExample", "GOV"); 45 | l2VoteAggregator = new WormholeL2VoteAggregatorHarness( 46 | address(l2Erc20), 47 | L2_CHAIN.wormholeRelayer, 48 | address(l1Block), 49 | L2_CHAIN.wormholeChainId, 50 | L1_CHAIN.wormholeChainId, 51 | 1200 52 | ); 53 | DEFAULT_VOTE_END = uint128(l2VoteAggregator.CAST_VOTE_WINDOW()) + 1; 54 | } 55 | 56 | function setUpTarget() public override { 57 | l1Erc20 = new FakeERC20("GovExample", "GOV"); 58 | gov = new GovernorFlexibleVotingMock("Testington Dao", ERC20VotesComp(address(l1Erc20))); 59 | l1VotePool = new WormholeL1VotePoolHarness(L1_CHAIN.wormholeRelayer, address(gov)); 60 | l1VotePool.setRegisteredSender( 61 | L2_CHAIN.wormholeChainId, bytes32(uint256(uint160(address(l2VoteAggregator)))) 62 | ); 63 | } 64 | } 65 | 66 | contract Constructor is WormholeL1VotePoolTest { 67 | function testFuzz_CorrectlySetArguments() public { 68 | l1Erc20 = new FakeERC20("GovExample", "GOV"); 69 | GovernorFlexibleVotingMock gov = 70 | new GovernorFlexibleVotingMock("Testington Dao", ERC20VotesComp(address(l1Erc20))); 71 | l1VotePool = new WormholeL1VotePoolHarness(L1_CHAIN.wormholeRelayer, address(gov)); 72 | 73 | assertEq(address(l1VotePool.GOVERNOR()), address(gov), "Governor is not set correctly"); 74 | } 75 | } 76 | 77 | contract _ReceiveCastVoteWormholeMessages is WormholeL1VotePoolTest { 78 | function testFuzz_CorrectlyBridgeVoteAggregation( 79 | uint32 _l2Against, 80 | uint32 _l2For, 81 | uint32 _l2Abstain 82 | ) public { 83 | vm.selectFork(targetFork); 84 | vm.assume(uint96(_l2Against) + _l2For + _l2Abstain != 0); 85 | 86 | uint96 totalVotes = uint96(_l2Against) + _l2For + _l2Abstain; 87 | 88 | l1Erc20.mint(address(this), totalVotes); 89 | l1Erc20.approve(address(this), totalVotes); 90 | l1Erc20.transferFrom(address(this), address(l1VotePool), totalVotes); 91 | 92 | vm.roll(block.number + 1); // To checkpoint erc20 mint 93 | uint256 _proposalId = l1VotePool.createProposalVote(address(l1Erc20)); 94 | 95 | vm.selectFork(sourceFork); 96 | l2VoteAggregator.initialize(address(l1VotePool)); 97 | uint256 cost = l2VoteAggregator.quoteDeliveryCost(L1_CHAIN.wormholeChainId); 98 | vm.recordLogs(); 99 | vm.deal(address(this), 10 ether); 100 | 101 | l2VoteAggregator.createProposalVote(_proposalId, _l2Against, _l2For, _l2Abstain); 102 | l2VoteAggregator.createProposal(_proposalId, DEFAULT_VOTE_END); 103 | vm.expectEmit(); 104 | emit VoteBridged(_proposalId, _l2Against, _l2For, _l2Abstain); 105 | l2VoteAggregator.bridgeVote{value: cost}(_proposalId); 106 | 107 | vm.expectEmit(); 108 | emit VoteCast(L1_CHAIN.wormholeRelayer, _proposalId, _l2Against, _l2For, _l2Abstain); 109 | performDelivery(); 110 | 111 | vm.selectFork(targetFork); 112 | (uint128 l1Against, uint128 l1For, uint128 l1Abstain) = l1VotePool.proposalVotes(_proposalId); 113 | 114 | assertEq(l1Against, _l2Against, "Against value was not bridged correctly"); 115 | assertEq(l1For, _l2For, "For value was not bridged correctly"); 116 | assertEq(l1Abstain, _l2Abstain, "abstain value was not bridged correctly"); 117 | 118 | // Governor votes 119 | (uint256 totalAgainstVotes, uint256 totalForVotes, uint256 totalAbstainVotes) = 120 | gov.proposalVotes(_proposalId); 121 | assertEq(totalAgainstVotes, _l2Against, "Against value was not bridged correctly"); 122 | assertEq(totalForVotes, _l2For, "For value was not bridged correctly"); 123 | assertEq(totalAbstainVotes, _l2Abstain, "Abstain value was not bridged correctly"); 124 | } 125 | 126 | function testFuzz_CorrectlyBridgeVoteAggregationWithExistingVote( 127 | uint32 _l2Against, 128 | uint32 _l2For, 129 | uint32 _l2Abstain, 130 | uint32 _l2NewAgainst, 131 | uint32 _l2NewFor, 132 | uint32 _l2NewAbstain 133 | ) public { 134 | vm.assume(_l2NewAgainst > _l2Against); 135 | vm.assume(_l2NewFor > _l2For); 136 | vm.assume(_l2NewAbstain > _l2Abstain); 137 | 138 | vm.selectFork(targetFork); 139 | uint96 totalVotes = uint96(_l2NewAgainst) + _l2NewFor + _l2NewAbstain; 140 | 141 | l1Erc20.mint(address(this), totalVotes); 142 | l1Erc20.approve(address(this), totalVotes); 143 | l1Erc20.transferFrom(address(this), address(l1VotePool), totalVotes); 144 | 145 | vm.roll(block.number + 1); // To checkpoint erc20 mint 146 | uint256 _proposalId = 147 | l1VotePool.createProposalVote(address(l1Erc20), _l2Against, _l2For, _l2Abstain); 148 | 149 | vm.selectFork(sourceFork); 150 | l2VoteAggregator.initialize(address(l1VotePool)); 151 | uint256 cost = l2VoteAggregator.quoteDeliveryCost(L1_CHAIN.wormholeChainId); 152 | vm.recordLogs(); 153 | vm.deal(address(this), 10 ether); 154 | 155 | l2VoteAggregator.createProposalVote(_proposalId, _l2NewAgainst, _l2NewFor, _l2NewAbstain); 156 | l2VoteAggregator.createProposal(_proposalId, DEFAULT_VOTE_END); 157 | vm.expectEmit(); 158 | emit VoteBridged(_proposalId, _l2NewAgainst, _l2NewFor, _l2NewAbstain); 159 | l2VoteAggregator.bridgeVote{value: cost}(_proposalId); 160 | 161 | vm.expectEmit(); 162 | emit VoteCast( 163 | L1_CHAIN.wormholeRelayer, 164 | _proposalId, 165 | _l2NewAgainst - _l2Against, 166 | _l2NewFor - _l2For, 167 | _l2NewAbstain - _l2Abstain 168 | ); 169 | performDelivery(); 170 | 171 | vm.selectFork(targetFork); 172 | (uint128 l1Against, uint128 l1For, uint128 l1Abstain) = l1VotePool.proposalVotes(_proposalId); 173 | 174 | assertEq(l1Against, _l2NewAgainst, "Against value was not bridged correctly"); 175 | assertEq(l1For, _l2NewFor, "For value was not bridged correctly"); 176 | assertEq(l1Abstain, _l2NewAbstain, "abstain value was not bridged correctly"); 177 | 178 | // Governor votes 179 | (uint256 totalAgainstVotes, uint256 totalForVotes, uint256 totalAbstainVotes) = 180 | gov.proposalVotes(_proposalId); 181 | assertEq(totalAgainstVotes, _l2NewAgainst, "Total Against value is incorrect"); 182 | assertEq(totalForVotes, _l2NewFor, "Total For value is incorrect"); 183 | assertEq(totalAbstainVotes, _l2NewAbstain, "Total Abstain value is incorrect"); 184 | } 185 | 186 | function testFuzz_RevertWhen_InvalidVoteHasBeenBridged( 187 | uint32 _l2Against, 188 | uint32 _l2For, 189 | uint32 _l2Abstain, 190 | uint32 _l2NewAgainst, 191 | uint32 _l2NewFor, 192 | uint32 _l2NewAbstain 193 | ) public { 194 | vm.assume(_l2NewAgainst < _l2Against); 195 | vm.assume(_l2NewFor < _l2For); 196 | vm.assume(_l2NewAbstain < _l2Abstain); 197 | 198 | vm.selectFork(targetFork); 199 | uint96 totalVotes = uint96(_l2Against) + _l2For + _l2Abstain; 200 | 201 | l1Erc20.mint(address(this), totalVotes); 202 | l1Erc20.approve(address(this), totalVotes); 203 | l1Erc20.transferFrom(address(this), address(l1VotePool), totalVotes); 204 | 205 | vm.roll(block.number + 1); // To checkpoint erc20 mint 206 | uint256 _proposalId = 207 | l1VotePool.createProposalVote(address(l1Erc20), _l2Against, _l2For, _l2Abstain); 208 | 209 | vm.prank(L1_CHAIN.wormholeRelayer); 210 | vm.expectRevert(L1VotePool.InvalidProposalVote.selector); 211 | l1VotePool.receiveWormholeMessages( 212 | abi.encode(_proposalId, _l2NewAgainst, _l2NewFor, _l2NewAbstain), 213 | new bytes[](0), 214 | bytes32(uint256(uint160(address(l2VoteAggregator)))), 215 | L2_CHAIN.wormholeChainId, 216 | bytes32(""), 217 | true 218 | ); 219 | } 220 | 221 | function testFuzz_RevertWhen_VoteBeforeProposalStart( 222 | uint32 _l2Against, 223 | uint32 _l2For, 224 | uint32 _l2Abstain, 225 | uint32 _l2NewAgainst, 226 | uint32 _l2NewFor, 227 | uint32 _l2NewAbstain 228 | ) public { 229 | _l2NewAgainst = uint32(bound(_l2NewAgainst, 0, _l2Against)); 230 | _l2NewFor = uint32(bound(_l2NewFor, 0, _l2For)); 231 | _l2NewAbstain = uint32(bound(_l2NewAbstain, 0, _l2Abstain)); 232 | 233 | vm.selectFork(targetFork); 234 | 235 | l1Erc20.approve(address(l1VotePool), uint96(_l2Against) + _l2For + _l2Abstain); 236 | l1Erc20.mint(address(this), uint96(_l2Against) + _l2For + _l2Abstain); 237 | 238 | uint256 _proposalId = l1VotePool.createProposalVote(address(l1Erc20)); 239 | 240 | vm.prank(L1_CHAIN.wormholeRelayer); 241 | vm.expectRevert("Governor: vote not currently active"); 242 | l1VotePool.receiveWormholeMessages( 243 | abi.encode(_proposalId, _l2NewAgainst, _l2NewFor, _l2NewAbstain), 244 | new bytes[](0), 245 | bytes32(uint256(uint160(address(l2VoteAggregator)))), 246 | L2_CHAIN.wormholeChainId, 247 | bytes32(""), 248 | false 249 | ); 250 | } 251 | 252 | function testFuzz_RevertWhen_VoteAfterProposalEnd( 253 | uint32 _l2Against, 254 | uint32 _l2For, 255 | uint32 _l2Abstain, 256 | uint32 _l2NewAgainst, 257 | uint32 _l2NewFor, 258 | uint32 _l2NewAbstain 259 | ) public { 260 | _l2NewAgainst = uint32(bound(_l2NewAgainst, 0, _l2Against)); 261 | _l2NewFor = uint32(bound(_l2NewFor, 0, _l2For)); 262 | _l2NewAbstain = uint32(bound(_l2NewAbstain, 0, _l2Abstain)); 263 | 264 | vm.selectFork(targetFork); 265 | 266 | l1Erc20.approve(address(l1VotePool), uint96(_l2Against) + _l2For + _l2Abstain); 267 | l1Erc20.mint(address(this), uint96(_l2Against) + _l2For + _l2Abstain); 268 | 269 | uint256 _proposalId = l1VotePool.createProposalVote(address(l1Erc20)); 270 | l1VotePool._jumpToProposalEnd(_proposalId, 1); 271 | 272 | vm.prank(L1_CHAIN.wormholeRelayer); 273 | vm.expectRevert("Governor: vote not currently active"); 274 | l1VotePool.receiveWormholeMessages( 275 | abi.encode(_proposalId, _l2NewAgainst, _l2NewFor, _l2NewAbstain), 276 | new bytes[](0), 277 | bytes32(uint256(uint160(address(l2VoteAggregator)))), 278 | L2_CHAIN.wormholeChainId, 279 | bytes32(""), 280 | false 281 | ); 282 | } 283 | 284 | function testFuzz_RevertWhen_BridgeReceivedWhenCanceled( 285 | uint32 _l2Against, 286 | uint32 _l2For, 287 | uint32 _l2Abstain, 288 | uint32 _l2NewAgainst, 289 | uint32 _l2NewFor, 290 | uint32 _l2NewAbstain 291 | ) public { 292 | _l2NewAgainst = uint32(bound(_l2NewAgainst, 0, _l2Against)); 293 | _l2NewFor = uint32(bound(_l2NewFor, 0, _l2For)); 294 | _l2NewAbstain = uint32(bound(_l2NewAbstain, 0, _l2Abstain)); 295 | 296 | vm.selectFork(targetFork); 297 | uint96 totalVotes = uint96(_l2Against) + _l2For + _l2Abstain; 298 | 299 | l1Erc20.mint(address(this), totalVotes); 300 | l1Erc20.approve(address(this), totalVotes); 301 | l1Erc20.transferFrom(address(this), address(l1VotePool), totalVotes); 302 | 303 | uint256 _proposalId = l1VotePool.createProposalVote(address(l1Erc20)); 304 | l1VotePool.cancel(address(l1Erc20)); 305 | l1VotePool._jumpToActiveProposal(_proposalId); 306 | 307 | vm.prank(L1_CHAIN.wormholeRelayer); 308 | vm.expectRevert("Governor: vote not currently active"); 309 | l1VotePool.receiveWormholeMessages( 310 | abi.encode(_proposalId, _l2NewAgainst, _l2NewFor, _l2NewAbstain), 311 | new bytes[](0), 312 | bytes32(uint256(uint160(address(l2VoteAggregator)))), 313 | L2_CHAIN.wormholeChainId, 314 | bytes32(""), 315 | false 316 | ); 317 | } 318 | } 319 | -------------------------------------------------------------------------------- /test/optimized/WormholeL2VoteAggregatorCalldataCompressor.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import {Test} from "forge-std/Test.sol"; 5 | 6 | import {L1Block} from "src/L1Block.sol"; 7 | import {WormholeL2VoteAggregatorCalldataCompressor} from 8 | "src/optimized/WormholeL2VoteAggregatorCalldataCompressor.sol"; 9 | import {WormholeL2VoteAggregator} from "src/WormholeL2VoteAggregator.sol"; 10 | import {FakeERC20} from "src/FakeERC20.sol"; 11 | import {WormholeL2ERC20} from "src/WormholeL2ERC20.sol"; 12 | import { 13 | GovernorMetadataOptimizedMock, GovernorMetadataMock 14 | } from "test/mock/GovernorMetadataMock.sol"; 15 | import {L2GovernorMetadata} from "src/L2GovernorMetadata.sol"; 16 | import {L2VoteAggregator} from "src/L2VoteAggregator.sol"; 17 | import {TestConstants} from "test/Constants.sol"; 18 | import {GovernorMetadataMockBase} from "test/mock/GovernorMetadataMock.sol"; 19 | 20 | // Use this is the sig tests 21 | contract WormholeL2VoteAggregatorCalldataCompressorHarness is 22 | WormholeL2VoteAggregatorCalldataCompressor, 23 | GovernorMetadataMockBase 24 | { 25 | constructor( 26 | address _votingToken, 27 | address _relayer, 28 | address _l1BlockAddress, 29 | uint16 _sourceChain, 30 | uint16 _targetChain, 31 | uint32 _castWindow 32 | ) 33 | WormholeL2VoteAggregatorCalldataCompressor( 34 | _votingToken, 35 | _relayer, 36 | _l1BlockAddress, 37 | _sourceChain, 38 | _targetChain, 39 | msg.sender, 40 | _castWindow 41 | ) 42 | {} 43 | 44 | function createProposalVote(uint256 _proposalId, uint128 _against, uint128 _for, uint128 _abstain) 45 | public 46 | { 47 | _proposalVotes[_proposalId] = ProposalVote(_against, _for, _abstain); 48 | } 49 | 50 | function exposed_castVote( 51 | uint256 proposalId, 52 | address voter, 53 | VoteType support, 54 | string memory reason 55 | ) public returns (uint256) { 56 | return _castVote(proposalId, voter, uint8(support), reason); 57 | } 58 | 59 | function exposed_domainSeparatorV4() public view returns (bytes32) { 60 | return _domainSeparatorV4(); 61 | } 62 | 63 | /// @inheritdoc L2GovernorMetadata 64 | function _addProposal(uint256 proposalId, uint256 voteStart, uint256 voteEnd, bool isCanceled) 65 | internal 66 | virtual 67 | override(L2GovernorMetadata, WormholeL2VoteAggregatorCalldataCompressor) 68 | { 69 | WormholeL2VoteAggregatorCalldataCompressor._addProposal( 70 | proposalId, voteStart, voteEnd, isCanceled 71 | ); 72 | } 73 | } 74 | 75 | contract WormholeL2ERC20CalldataCompressorTest is Test, TestConstants { 76 | WormholeL2VoteAggregatorCalldataCompressor router; 77 | FakeERC20 l2Erc20; 78 | address voterAddress; 79 | uint256 privateKey; 80 | WormholeL2VoteAggregatorCalldataCompressorHarness routerHarness; 81 | 82 | event VoteCast( 83 | address indexed voter, uint256 proposalId, uint8 support, uint256 weight, string reason 84 | ); 85 | 86 | function setUp() public { 87 | (voterAddress, privateKey) = makeAddrAndKey("voter"); 88 | L1Block l1Block = new L1Block(); 89 | l2Erc20 = new FakeERC20("GovExample", "GOV"); 90 | router = new WormholeL2VoteAggregatorCalldataCompressor( 91 | address(l2Erc20), 92 | L2_CHAIN.wormholeRelayer, 93 | address(l1Block), 94 | L2_CHAIN.wormholeChainId, 95 | L1_CHAIN.wormholeChainId, 96 | msg.sender, 97 | 1200 98 | ); 99 | routerHarness = new WormholeL2VoteAggregatorCalldataCompressorHarness( 100 | address(l2Erc20), 101 | L2_CHAIN.wormholeRelayer, 102 | address(l1Block), 103 | L2_CHAIN.wormholeChainId, 104 | L1_CHAIN.wormholeChainId, 105 | 1200 106 | ); 107 | } 108 | 109 | function _signVoteMessage(uint256 _proposalId, uint8 _support) 110 | internal 111 | view 112 | returns (uint8, bytes32, bytes32) 113 | { 114 | bytes32 _voteMessage = keccak256( 115 | abi.encode(keccak256("Ballot(uint256 proposalId,uint8 support)"), _proposalId, _support) 116 | ); 117 | 118 | bytes32 _voteMessageHash = keccak256( 119 | abi.encodePacked("\x19\x01", routerHarness.exposed_domainSeparatorV4(), _voteMessage) 120 | ); 121 | 122 | return vm.sign(privateKey, _voteMessageHash); 123 | } 124 | } 125 | 126 | /// @dev All of the internal methods are tested in this Fallback contract 127 | contract Fallback is WormholeL2ERC20CalldataCompressorTest { 128 | function testFuzz_RevertIf_CastVoteMsgDataIsTooLong( 129 | uint16 _proposalId, 130 | uint32 _timeToEnd, 131 | uint96 _amount 132 | ) public { 133 | _timeToEnd = uint32(bound(_timeToEnd, 2000, type(uint32).max)); 134 | 135 | vm.assume(_amount != 0); 136 | vm.assume(_proposalId != 1); 137 | l2Erc20.mint(address(this), _amount); 138 | 139 | GovernorMetadataMock.Proposal memory l2Proposal = 140 | routerHarness.createProposal(_proposalId, _timeToEnd); 141 | 142 | vm.roll(l2Proposal.voteStart + 1); 143 | vm.expectRevert(abi.encode(WormholeL2VoteAggregatorCalldataCompressor.InvalidCalldata.selector)); 144 | (bool ok,) = address(router).call( 145 | abi.encodePacked(uint8(1), uint256(_proposalId), L2VoteAggregator.VoteType.For) 146 | ); 147 | assertFalse(ok, "Call did not revert as expected"); 148 | } 149 | 150 | function testFuzz_RevertIf_CastVoteMsgDataIsTooShort( 151 | uint16 _proposalId, 152 | uint32 _timeToEnd, 153 | uint96 _amount 154 | ) public { 155 | _timeToEnd = uint32(bound(_timeToEnd, 2000, type(uint32).max)); 156 | 157 | vm.assume(_amount != 0); 158 | vm.assume(_proposalId != 1); 159 | l2Erc20.mint(address(this), _amount); 160 | 161 | GovernorMetadataMock.Proposal memory l2Proposal = 162 | routerHarness.createProposal(_proposalId, _timeToEnd); 163 | 164 | vm.roll(l2Proposal.voteStart + 1); 165 | vm.expectRevert(abi.encode(WormholeL2VoteAggregatorCalldataCompressor.InvalidCalldata.selector)); 166 | (bool ok,) = address(router).call( 167 | abi.encodePacked(uint8(1), uint8(_proposalId), L2VoteAggregator.VoteType.For) 168 | ); 169 | assertFalse(ok, "Call did not revert as expected"); 170 | } 171 | 172 | function testFuzz_CorrectlyCastVoteFor(uint16 _proposalId, uint32 _timeToEnd, uint96 _amount) 173 | public 174 | { 175 | _timeToEnd = uint32(bound(_timeToEnd, 2000, type(uint32).max)); 176 | 177 | vm.assume(_amount != 0); 178 | vm.assume(_proposalId != 1); 179 | l2Erc20.mint(address(this), _amount); 180 | 181 | GovernorMetadataMock.Proposal memory l2Proposal = 182 | routerHarness.createProposal(_proposalId, _timeToEnd); 183 | 184 | vm.roll(l2Proposal.voteStart + 1); 185 | vm.expectEmit(); 186 | emit VoteCast(address(this), _proposalId, 1, _amount, ""); 187 | 188 | (bool ok,) = address(routerHarness).call( 189 | abi.encodePacked(uint8(1), uint16(_proposalId), L2VoteAggregator.VoteType.For) 190 | ); 191 | assertTrue(ok); 192 | (, uint256 forVotes,) = routerHarness.proposalVotes(_proposalId); 193 | 194 | assertEq(forVotes, _amount, "Votes for is not correct"); 195 | } 196 | 197 | function testFuzz_CorrectlyCastVoteAgainst(uint16 _proposalId, uint32 _timeToEnd, uint96 _amount) 198 | public 199 | { 200 | _timeToEnd = uint32(bound(_timeToEnd, 2000, type(uint32).max)); 201 | 202 | vm.assume(_amount != 0); 203 | vm.assume(_proposalId != 1); 204 | l2Erc20.mint(address(this), _amount); 205 | 206 | GovernorMetadataMock.Proposal memory l2Proposal = 207 | routerHarness.createProposal(_proposalId, _timeToEnd); 208 | 209 | vm.roll(l2Proposal.voteStart + 1); 210 | vm.expectEmit(); 211 | emit VoteCast(address(this), _proposalId, 0, _amount, ""); 212 | 213 | (bool ok,) = address(routerHarness).call( 214 | abi.encodePacked(uint8(1), uint16(_proposalId), L2VoteAggregator.VoteType.Against) 215 | ); 216 | assertTrue(ok); 217 | (uint256 againstVotes,,) = routerHarness.proposalVotes(_proposalId); 218 | 219 | assertEq(againstVotes, _amount, "Votes Against is not correct"); 220 | } 221 | 222 | function testFuzz_CorrectlyCastVoteAbstain(uint16 _proposalId, uint32 _timeToEnd, uint96 _amount) 223 | public 224 | { 225 | _timeToEnd = uint32(bound(_timeToEnd, 2000, type(uint32).max)); 226 | 227 | vm.assume(_amount != 0); 228 | vm.assume(_proposalId != 1); 229 | l2Erc20.mint(address(this), _amount); 230 | 231 | GovernorMetadataMock.Proposal memory l2Proposal = 232 | routerHarness.createProposal(_proposalId, _timeToEnd); 233 | 234 | vm.roll(l2Proposal.voteStart + 1); 235 | vm.expectEmit(); 236 | emit VoteCast(address(this), _proposalId, 2, _amount, ""); 237 | 238 | (bool ok,) = address(routerHarness).call( 239 | abi.encodePacked(uint8(1), uint16(_proposalId), L2VoteAggregator.VoteType.Abstain) 240 | ); 241 | assertTrue(ok); 242 | (,, uint256 abstainVotes) = routerHarness.proposalVotes(_proposalId); 243 | 244 | assertEq(abstainVotes, _amount, "Votes abstained are not correct"); 245 | } 246 | 247 | function testFuzz_CorrectlyCastVoteWithReasonAgainst( 248 | uint16 _proposalId, 249 | uint32 _timeToEnd, 250 | uint96 _amount, 251 | string memory _reason 252 | ) public { 253 | _timeToEnd = uint32(bound(_timeToEnd, 2000, type(uint32).max)); 254 | vm.assume(_amount != 0); 255 | vm.assume(_proposalId != 1); 256 | 257 | l2Erc20.mint(address(this), _amount); 258 | 259 | GovernorMetadataMock.Proposal memory l2Proposal = 260 | routerHarness.createProposal(_proposalId, _timeToEnd); 261 | 262 | vm.roll(l2Proposal.voteStart + 1); 263 | vm.expectEmit(); 264 | emit VoteCast(address(this), _proposalId, 0, _amount, _reason); 265 | 266 | (bool ok,) = address(routerHarness).call( 267 | abi.encodePacked(uint8(2), uint16(_proposalId), L2VoteAggregator.VoteType.Against, _reason) 268 | ); 269 | assertTrue(ok); 270 | (uint256 against,,) = routerHarness.proposalVotes(_proposalId); 271 | assertEq(against, _amount, "Votes against is not correct"); 272 | } 273 | 274 | function testFuzz_CorrectlyCastVoteWithReasonAbstain( 275 | uint16 _proposalId, 276 | uint32 _timeToEnd, 277 | uint96 _amount, 278 | string memory _reason 279 | ) public { 280 | _timeToEnd = uint32(bound(_timeToEnd, 2000, type(uint32).max)); 281 | vm.assume(_amount != 0); 282 | vm.assume(_proposalId != 1); 283 | 284 | l2Erc20.mint(address(this), _amount); 285 | 286 | GovernorMetadataMock.Proposal memory l2Proposal = 287 | routerHarness.createProposal(_proposalId, _timeToEnd); 288 | 289 | vm.roll(l2Proposal.voteStart + 1); 290 | vm.expectEmit(); 291 | emit VoteCast(address(this), _proposalId, 2, _amount, _reason); 292 | 293 | (bool ok,) = address(routerHarness).call( 294 | abi.encodePacked(uint8(2), uint16(_proposalId), L2VoteAggregator.VoteType.Abstain, _reason) 295 | ); 296 | assertTrue(ok); 297 | (,, uint256 abstain) = routerHarness.proposalVotes(_proposalId); 298 | assertEq(abstain, _amount, "Votes abstain is not correct"); 299 | } 300 | 301 | function testFuzz_CorrectlyCastVoteWithReasonFor( 302 | uint16 _proposalId, 303 | uint32 _timeToEnd, 304 | uint96 _amount, 305 | string memory _reason 306 | ) public { 307 | _timeToEnd = uint32(bound(_timeToEnd, 2000, type(uint32).max)); 308 | vm.assume(_amount != 0); 309 | vm.assume(_proposalId != 1); 310 | 311 | l2Erc20.mint(address(this), _amount); 312 | 313 | GovernorMetadataMock.Proposal memory l2Proposal = 314 | routerHarness.createProposal(_proposalId, _timeToEnd); 315 | 316 | vm.roll(l2Proposal.voteStart + 1); 317 | vm.expectEmit(); 318 | emit VoteCast(address(this), _proposalId, 1, _amount, _reason); 319 | 320 | (bool ok,) = address(routerHarness).call( 321 | abi.encodePacked(uint8(2), uint16(_proposalId), L2VoteAggregator.VoteType.For, _reason) 322 | ); 323 | assertTrue(ok); 324 | (, uint256 _for,) = routerHarness.proposalVotes(_proposalId); 325 | assertEq(_for, _amount, "Votes for is not correct"); 326 | } 327 | 328 | function testFuzz_CorrectlyCastVoteBySigFor(uint16 _proposalId, uint32 _timeToEnd, uint96 _amount) 329 | public 330 | { 331 | _timeToEnd = uint32(bound(_timeToEnd, 2000, type(uint32).max)); 332 | vm.assume(_amount != 0); 333 | vm.assume(_proposalId != 1); 334 | 335 | L2VoteAggregator.VoteType _voteType = L2VoteAggregator.VoteType.For; 336 | 337 | vm.prank(voterAddress); 338 | l2Erc20.mint(voterAddress, _amount); 339 | 340 | GovernorMetadataMock.Proposal memory l2Proposal = 341 | routerHarness.createProposal(_proposalId, _timeToEnd); 342 | 343 | vm.roll(l2Proposal.voteStart + 1); 344 | vm.expectEmit(); 345 | emit VoteCast(voterAddress, _proposalId, 1, _amount, ""); 346 | 347 | (uint8 _v, bytes32 _r, bytes32 _s) = _signVoteMessage(_proposalId, uint8(_voteType)); 348 | 349 | (bool ok,) = address(routerHarness).call( 350 | abi.encodePacked(uint8(3), uint16(_proposalId), _voteType, _v, _r, _s) 351 | ); 352 | assertTrue(ok); 353 | (, uint256 _for,) = routerHarness.proposalVotes(_proposalId); 354 | assertEq(_for, _amount, "Votes for is not correct"); 355 | } 356 | 357 | function testFuzz_CorrectlyCastVoteBySigAbstain( 358 | uint16 _proposalId, 359 | uint32 _timeToEnd, 360 | uint96 _amount 361 | ) public { 362 | _timeToEnd = uint32(bound(_timeToEnd, 2000, type(uint32).max)); 363 | vm.assume(_amount != 0); 364 | vm.assume(_proposalId != 1); 365 | 366 | L2VoteAggregator.VoteType _voteType = L2VoteAggregator.VoteType.Abstain; 367 | 368 | vm.prank(voterAddress); 369 | l2Erc20.mint(voterAddress, _amount); 370 | 371 | GovernorMetadataMock.Proposal memory l2Proposal = 372 | routerHarness.createProposal(_proposalId, _timeToEnd); 373 | 374 | vm.roll(l2Proposal.voteStart + 1); 375 | vm.expectEmit(); 376 | emit VoteCast(voterAddress, _proposalId, uint8(_voteType), _amount, ""); 377 | 378 | (uint8 _v, bytes32 _r, bytes32 _s) = _signVoteMessage(_proposalId, uint8(_voteType)); 379 | 380 | (bool ok,) = address(routerHarness).call( 381 | abi.encodePacked(uint8(3), uint16(_proposalId), _voteType, _v, _r, _s) 382 | ); 383 | assertTrue(ok); 384 | (,, uint256 _abstain) = routerHarness.proposalVotes(_proposalId); 385 | assertEq(_abstain, _amount, "Votes abstain is not correct"); 386 | } 387 | 388 | function testFuzz_CorrectlyCastVoteBySigAgainst( 389 | uint16 _proposalId, 390 | uint32 _timeToEnd, 391 | uint96 _amount 392 | ) public { 393 | _timeToEnd = uint32(bound(_timeToEnd, 2000, type(uint32).max)); 394 | vm.assume(_amount != 0); 395 | vm.assume(_proposalId != 1); 396 | 397 | L2VoteAggregator.VoteType _voteType = L2VoteAggregator.VoteType.Against; 398 | 399 | vm.prank(voterAddress); 400 | l2Erc20.mint(voterAddress, _amount); 401 | 402 | GovernorMetadataMock.Proposal memory l2Proposal = 403 | routerHarness.createProposal(_proposalId, _timeToEnd); 404 | 405 | vm.roll(l2Proposal.voteStart + 1); 406 | vm.expectEmit(); 407 | emit VoteCast(voterAddress, _proposalId, uint8(_voteType), _amount, ""); 408 | 409 | (uint8 _v, bytes32 _r, bytes32 _s) = _signVoteMessage(_proposalId, uint8(_voteType)); 410 | 411 | (bool ok,) = address(routerHarness).call( 412 | abi.encodePacked(uint8(3), uint16(_proposalId), _voteType, _v, _r, _s) 413 | ); 414 | assertTrue(ok); 415 | (uint256 _against,,) = routerHarness.proposalVotes(_proposalId); 416 | assertEq(_against, _amount, "Votes against is not correct"); 417 | } 418 | 419 | function testFuzz_RevertIf_CastVoteWithReasonMsgDataIsTooShort( 420 | uint16 _proposalId, 421 | uint32 _timeToEnd, 422 | uint96 _amount, 423 | string calldata _reason 424 | ) public { 425 | _timeToEnd = uint32(bound(_timeToEnd, 2000, type(uint32).max)); 426 | vm.assume(_amount != 0); 427 | vm.assume(_proposalId != 1); 428 | 429 | L2VoteAggregator.VoteType _voteType = L2VoteAggregator.VoteType.Abstain; 430 | 431 | vm.prank(voterAddress); 432 | l2Erc20.mint(voterAddress, _amount); 433 | 434 | GovernorMetadataMock.Proposal memory l2Proposal = 435 | routerHarness.createProposal(_proposalId, _timeToEnd); 436 | 437 | vm.roll(l2Proposal.voteStart + 1); 438 | 439 | vm.expectRevert(abi.encode(WormholeL2VoteAggregatorCalldataCompressor.InvalidCalldata.selector)); 440 | (bool ok,) = address(routerHarness).call( 441 | abi.encodePacked(uint8(2), uint8(_proposalId), _voteType, _reason) 442 | ); 443 | assertFalse(ok, "Call did not revert as expected"); 444 | } 445 | 446 | function testFuzz_RevertIf_CastVoteBySigMsgDataIsTooShort( 447 | uint16 _proposalId, 448 | uint32 _timeToEnd, 449 | uint96 _amount 450 | ) public { 451 | _timeToEnd = uint32(bound(_timeToEnd, 2000, type(uint32).max)); 452 | vm.assume(_amount != 0); 453 | vm.assume(_proposalId != 1); 454 | 455 | L2VoteAggregator.VoteType _voteType = L2VoteAggregator.VoteType.Abstain; 456 | 457 | vm.prank(voterAddress); 458 | l2Erc20.mint(voterAddress, _amount); 459 | 460 | GovernorMetadataMock.Proposal memory l2Proposal = 461 | routerHarness.createProposal(_proposalId, _timeToEnd); 462 | 463 | vm.roll(l2Proposal.voteStart + 1); 464 | 465 | (uint8 _v, bytes32 _r, bytes32 _s) = _signVoteMessage(_proposalId, uint8(_voteType)); 466 | 467 | vm.expectRevert(abi.encode(WormholeL2VoteAggregatorCalldataCompressor.InvalidCalldata.selector)); 468 | (bool ok,) = address(routerHarness).call( 469 | abi.encodePacked(uint8(3), uint8(_proposalId), _voteType, _v, _r, _s) 470 | ); 471 | assertFalse(ok, "Call did not revert as expected"); 472 | } 473 | 474 | function testFuzz_RevertIf_CastVoteBySigMsgDataIsTooLong( 475 | uint16 _proposalId, 476 | uint32 _timeToEnd, 477 | uint96 _amount 478 | ) public { 479 | _timeToEnd = uint32(bound(_timeToEnd, 2000, type(uint32).max)); 480 | vm.assume(_amount != 0); 481 | vm.assume(_proposalId != 1); 482 | 483 | L2VoteAggregator.VoteType _voteType = L2VoteAggregator.VoteType.Abstain; 484 | 485 | vm.prank(voterAddress); 486 | l2Erc20.mint(voterAddress, _amount); 487 | 488 | GovernorMetadataMock.Proposal memory l2Proposal = 489 | routerHarness.createProposal(_proposalId, _timeToEnd); 490 | 491 | vm.roll(l2Proposal.voteStart + 1); 492 | (uint8 _v, bytes32 _r, bytes32 _s) = _signVoteMessage(_proposalId, uint8(_voteType)); 493 | 494 | vm.expectRevert(abi.encode(WormholeL2VoteAggregatorCalldataCompressor.InvalidCalldata.selector)); 495 | (bool ok,) = address(routerHarness).call( 496 | abi.encodePacked(uint8(3), uint24(_proposalId), _voteType, _v, _r, _s) 497 | ); 498 | assertFalse(ok, "Call did not revert as expected"); 499 | } 500 | 501 | function testFuzz_RevertIf_FunctionIdDoesNotExist(uint8 _funcId) public { 502 | _funcId = uint8(bound(_funcId, 4, type(uint8).max)); 503 | vm.expectRevert( 504 | abi.encode(WormholeL2VoteAggregatorCalldataCompressor.FunctionDoesNotExist.selector) 505 | ); 506 | (bool ok,) = address(routerHarness).call(abi.encodePacked(_funcId)); 507 | assertFalse(ok); 508 | } 509 | } 510 | --------------------------------------------------------------------------------