diff --git a/.changeset/floppy-symbols-burn.md b/.changeset/floppy-symbols-burn.md new file mode 100644 index 00000000000..559f2877ada --- /dev/null +++ b/.changeset/floppy-symbols-burn.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`CrosschainRemoteExecutor`: Add a new executor contract that relays transaction from a controller on a remote chain. diff --git a/.changeset/rare-bushes-march.md b/.changeset/rare-bushes-march.md new file mode 100644 index 00000000000..d5f9b9de6b2 --- /dev/null +++ b/.changeset/rare-bushes-march.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`GovernorCrosschain`: Governor module that facilitates the execution of crosschain operations through CrosschainRemoteExecutors and ERC-7786 gateways. diff --git a/contracts/crosschain/CrosschainRemoteExecutor.sol b/contracts/crosschain/CrosschainRemoteExecutor.sol new file mode 100644 index 00000000000..c14e6c25698 --- /dev/null +++ b/contracts/crosschain/CrosschainRemoteExecutor.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {IERC7786GatewaySource} from "../interfaces/draft-IERC7786.sol"; +import {ERC7786Recipient} from "./ERC7786Recipient.sol"; +import {ERC7579Utils, Mode, CallType, ExecType} from "../account/utils/draft-ERC7579Utils.sol"; +import {Bytes} from "../utils/Bytes.sol"; + +/** + * @dev Helper contract used to relay transactions received from a controller through an ERC-7786 gateway. This is + * used by the {GovernorCrosschain} governance module for the execution of cross-chain actions. + * + * A {CrosschainRemoteExecutor} address can be seen as the local identity of a remote executor on another chain. It + * holds assets and permissions for the sake of its controller. + */ +contract CrosschainRemoteExecutor is ERC7786Recipient { + using Bytes for bytes; + using ERC7579Utils for *; + + /// @dev Gateway used by the remote controller to relay instructions to this executor. + address private _gateway; + + /// @dev InteroperableAddress of the remote controller that is allowed to relay instructions to this executor. + bytes private _controller; + + /// @dev Emitted when the gateway or controller of this remote executor is updated. + event CrosschainControllerSet(address gateway, bytes controller); + + /// @dev Reverted when a non-controller tries to relay instructions to this executor. + error AccessRestricted(); + + constructor(address initialGateway, bytes memory initialController) { + _setup(initialGateway, initialController); + } + + /// @dev Accessor that returns the address of the gateway used by this remote executor. + function gateway() public view virtual returns (address) { + return _gateway; + } + + /** + * @dev Accessor that returns the interoperable address of the controller allowed to relay instructions to this + * remote executor. + */ + function controller() public view virtual returns (bytes memory) { + return _controller; + } + + /** + * @dev Endpoint allowing the controller to reconfigure the executor. This must be called by the executor itself + * following an instruction from the controller. + */ + function reconfigure(address newGateway, bytes memory newController) public virtual { + require(msg.sender == address(this), AccessRestricted()); + _setup(newGateway, newController); + } + + /// @dev Internal setter to reconfigure the gateway and controller. + function _setup(address gateway_, bytes memory controller_) internal virtual { + // Sanity check, this should revert if gateway is not an ERC-7786 implementation. Note that since + // supportsAttribute returns data, accounts without code would fail that test (nothing returned). + IERC7786GatewaySource(gateway_).supportsAttribute(bytes4(0)); + + _gateway = gateway_; + _controller = controller_; + + emit CrosschainControllerSet(gateway_, controller_); + } + + /// @inheritdoc ERC7786Recipient + function _isAuthorizedGateway( + address instance, + bytes calldata sender + ) internal view virtual override returns (bool) { + return gateway() == instance && controller().equal(sender); + } + + /// @inheritdoc ERC7786Recipient + function _processMessage( + address /*gateway*/, + bytes32 /*receiveId*/, + bytes calldata /*sender*/, + bytes calldata payload + ) internal virtual override { + // split payload + (CallType callType, ExecType execType, , ) = Mode.wrap(bytes32(payload[0x00:0x20])).decodeMode(); + bytes calldata executionCalldata = payload[0x20:]; + + if (callType == ERC7579Utils.CALLTYPE_SINGLE) { + executionCalldata.execSingle(execType); + } else if (callType == ERC7579Utils.CALLTYPE_BATCH) { + executionCalldata.execBatch(execType); + } else if (callType == ERC7579Utils.CALLTYPE_DELEGATECALL) { + executionCalldata.execDelegateCall(execType); + } else revert ERC7579Utils.ERC7579UnsupportedCallType(callType); + } +} diff --git a/contracts/crosschain/README.adoc b/contracts/crosschain/README.adoc index 4e00a7e5b08..966523a276d 100644 --- a/contracts/crosschain/README.adoc +++ b/contracts/crosschain/README.adoc @@ -6,6 +6,7 @@ NOTE: This document is better viewed at https://docs.openzeppelin.com/contracts/ This directory contains contracts for sending and receiving cross chain messages that follows the ERC-7786 standard. * {CrosschainLinked}: helper to facilitate communication between a contract on one chain and counterparts on remote chains through ERC-7786 gateways. +* {CrosschainRemoteExecutor}: executor contract that relays transaction from a controller on a remote chain. * {ERC7786Recipient}: generic ERC-7786 crosschain contract that receives messages from a trusted gateway. Additionally there are multiple bridge constructions: @@ -22,6 +23,8 @@ Additionally there are multiple bridge constructions: {{CrosschainLinked}} +{{CrosschainRemoteExecutor}} + {{ERC7786Recipient}} == Bridges diff --git a/contracts/governance/README.adoc b/contracts/governance/README.adoc index 0901a5521da..a782de6ac95 100644 --- a/contracts/governance/README.adoc +++ b/contracts/governance/README.adoc @@ -44,17 +44,19 @@ Timelock extensions add a delay for governance decisions to be executed. The wor Other extensions can customize the behavior or interface in multiple ways. -* {GovernorStorage}: Stores the proposal details onchain and provides enumerability of the proposals. This can be useful for some L2 chains where storage is cheap compared to calldata. +* {GovernorCrosschain}: Helps with the execution of crosschain operation using {CrosschainRemoteExector} and ERC-7786 gateways. -* {GovernorSettings}: Manages some of the settings (voting delay, voting period duration, and proposal threshold) in a way that can be updated through a governance proposal, without requiring an upgrade. +* {GovernorNoncesKeyed}: An extension of {Governor} with support for keyed nonces in addition to traditional nonces when voting by signature. * {GovernorPreventLateQuorum}: Ensures there is a minimum voting period after quorum is reached as a security protection against large voters. * {GovernorProposalGuardian}: Adds a proposal guardian that can cancel proposals at any stage in their lifecycle--this permission is passed on to the proposers if the guardian is not set. -* {GovernorSuperQuorum}: Extension of {Governor} with a super quorum. Proposals that meet the super quorum (and have a majority of for votes) advance to the `Succeeded` state before the proposal deadline. +* {GovernorSettings}: Manages some of the settings (voting delay, voting period duration, and proposal threshold) in a way that can be updated through a governance proposal, without requiring an upgrade. -* {GovernorNoncesKeyed}: An extension of {Governor} with support for keyed nonces in addition to traditional nonces when voting by signature. +* {GovernorStorage}: Stores the proposal details onchain and provides enumerability of the proposals. This can be useful for some L2 chains where storage is cheap compared to calldata. + +* {GovernorSuperQuorum}: Extension of {Governor} with a super quorum. Proposals that meet the super quorum (and have a majority of for votes) advance to the `Succeeded` state before the proposal deadline. In addition to modules and extensions, the core contract requires a few virtual functions to be implemented to your particular specifications: @@ -92,17 +94,19 @@ NOTE: Functions of the `Governor` contract do not include access control. If you {{GovernorTimelockCompound}} -{{GovernorSettings}} +{{GovernorCrosschain}} -{{GovernorPreventLateQuorum}} +{{GovernorNoncesKeyed}} -{{GovernorStorage}} +{{GovernorPreventLateQuorum}} {{GovernorProposalGuardian}} -{{GovernorSuperQuorum}} +{{GovernorSettings}} -{{GovernorNoncesKeyed}} +{{GovernorStorage}} + +{{GovernorSuperQuorum}} == Utils diff --git a/contracts/governance/extensions/GovernorCrosschain.sol b/contracts/governance/extensions/GovernorCrosschain.sol new file mode 100644 index 00000000000..1b1e907deac --- /dev/null +++ b/contracts/governance/extensions/GovernorCrosschain.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.26; + +import {Governor} from "../Governor.sol"; +import {Mode} from "../../account/utils/draft-ERC7579Utils.sol"; +import {ERC7786Recipient} from "../../crosschain/ERC7786Recipient.sol"; +import {IERC7786GatewaySource} from "../../interfaces/draft-IERC7786.sol"; + +/// @dev Extension of {Governor} for cross-chain governance through ERC-7786 gateways and {CrosschainRemoteExecutor}. +abstract contract GovernorCrosschain is Governor { + /// @dev Send crosschain instruction to an arbitrary remote executor via an arbitrary ERC-7786 gateway. + function relayCrosschain( + address gateway, + bytes memory executor, + Mode mode, + bytes memory executionCalldata + ) public virtual onlyGovernance { + _crosschainExecute(gateway, executor, mode, executionCalldata); + } + + /// @dev Send crosschain instruction to an arbitrary remote executor via an arbitrary ERC-7786 gateway. + function _crosschainExecute( + address gateway, + bytes memory executor, + Mode mode, + bytes memory executionCalldata + ) internal virtual { + IERC7786GatewaySource(gateway).sendMessage(executor, abi.encodePacked(mode, executionCalldata), new bytes[](0)); + } +} diff --git a/contracts/mocks/governance/GovernorCrosschain.sol b/contracts/mocks/governance/GovernorCrosschain.sol new file mode 100644 index 00000000000..f6241e70c66 --- /dev/null +++ b/contracts/mocks/governance/GovernorCrosschain.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {Governor} from "../../governance/Governor.sol"; +import {GovernorSettings} from "../../governance/extensions/GovernorSettings.sol"; +import {GovernorCountingSimple} from "../../governance/extensions/GovernorCountingSimple.sol"; +import {GovernorVotesQuorumFraction} from "../../governance/extensions/GovernorVotesQuorumFraction.sol"; +import {GovernorCrosschain} from "../../governance/extensions/GovernorCrosschain.sol"; + +abstract contract GovernorCrosschainMock is + GovernorSettings, + GovernorVotesQuorumFraction, + GovernorCountingSimple, + GovernorCrosschain +{ + function proposalThreshold() public view override(Governor, GovernorSettings) returns (uint256) { + return super.proposalThreshold(); + } +} diff --git a/test/crosschain/CrosschainExecutor.test.js b/test/crosschain/CrosschainExecutor.test.js new file mode 100644 index 00000000000..f957d5eee86 --- /dev/null +++ b/test/crosschain/CrosschainExecutor.test.js @@ -0,0 +1,144 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { getLocalChain } = require('../helpers/chains'); +const { + CALL_TYPE_SINGLE, + CALL_TYPE_BATCH, + CALL_TYPE_DELEGATE, + encodeMode, + encodeSingle, + encodeBatch, + encodeDelegate, +} = require('../helpers/erc7579'); + +async function fixture() { + const chain = await getLocalChain(); + const [admin, other] = await ethers.getSigners(); + + const gateway = await ethers.deployContract('$ERC7786GatewayMock'); + const target = await ethers.deployContract('CallReceiverMock'); + + // Deploy executor + const executor = await ethers.deployContract('$CrosschainRemoteExecutor', [gateway, chain.toErc7930(admin)]); + + const remoteExecute = (from, target, mode, data) => + gateway.connect(from).sendMessage(target, ethers.concat([mode, data]), []); + + return { chain, gateway, target, executor, admin, other, remoteExecute }; +} + +describe('CrosschainRemoteController & CrosschainRemoteExecutor', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + it('setup', async function () { + await expect(this.executor.gateway()).to.eventually.equal(this.gateway); + await expect(this.executor.controller()).to.eventually.equal(this.chain.toErc7930(this.admin)); + }); + + describe('crosschain operation', function () { + it('support single mode', async function () { + const mode = encodeMode({ callType: CALL_TYPE_SINGLE }); + const data = encodeSingle(this.target, 0n, this.target.interface.encodeFunctionData('mockFunctionExtra')); + + await expect(this.remoteExecute(this.admin, this.chain.toErc7930(this.executor), mode, data)) + .to.emit(this.target, 'MockFunctionCalledExtra') + .withArgs(this.executor, 0n); + }); + + it('support batch mode', async function () { + const mode = encodeMode({ callType: CALL_TYPE_BATCH }); + const data = encodeBatch( + [this.target, 0n, this.target.interface.encodeFunctionData('mockFunctionWithArgs', [42, '0x1234'])], + [this.target, 0n, this.target.interface.encodeFunctionData('mockFunctionExtra')], + ); + + await expect(this.remoteExecute(this.admin, this.chain.toErc7930(this.executor), mode, data)) + .to.emit(this.target, 'MockFunctionCalledWithArgs') + .withArgs(42, '0x1234') + .to.emit(this.target, 'MockFunctionCalledExtra') + .withArgs(this.executor, 0n); + }); + + it('support delegate mode', async function () { + const mode = encodeMode({ callType: CALL_TYPE_DELEGATE }); + const data = encodeDelegate(this.target, this.target.interface.encodeFunctionData('mockFunctionExtra')); + + await expect(this.remoteExecute(this.admin, this.chain.toErc7930(this.executor), mode, data)) + .to.emit(this.target.attach(this.executor.target), 'MockFunctionCalledExtra') + .withArgs(this.gateway, 0n); + }); + + it('revert when mode is invalid', async function () { + const mode = encodeMode({ callType: '0x42' }); + const data = '0x'; + + await expect(this.remoteExecute(this.admin, this.chain.toErc7930(this.executor), mode, data)) + .to.be.revertedWithCustomError(this.executor, 'ERC7579UnsupportedCallType') + .withArgs('0x42'); + }); + }); + + describe('reconfigure', function () { + beforeEach(async function () { + this.newGateway = await ethers.deployContract('$ERC7786GatewayMock'); + }); + + it('through a crosschain call: success', async function () { + const mode = encodeMode({ callType: CALL_TYPE_SINGLE }); + const data = encodeSingle( + this.executor, + 0n, + this.executor.interface.encodeFunctionData('reconfigure', [ + this.newGateway.target, + this.chain.toErc7930(this.other), + ]), + ); + + await expect(this.remoteExecute(this.admin, this.chain.toErc7930(this.executor), mode, data)) + .to.emit(this.executor, 'CrosschainControllerSet') + .withArgs(this.newGateway, this.chain.toErc7930(this.other)); + + await expect(this.executor.gateway()).to.eventually.equal(this.newGateway); + await expect(this.executor.controller()).to.eventually.equal(this.chain.toErc7930(this.other)); + }); + + it('through the internal setter: success', async function () { + await expect(this.executor.$_setup(this.newGateway, this.chain.toErc7930(this.other))) + .to.emit(this.executor, 'CrosschainControllerSet') + .withArgs(this.newGateway, this.chain.toErc7930(this.other)); + + await expect(this.executor.gateway()).to.eventually.equal(this.newGateway); + await expect(this.executor.controller()).to.eventually.equal(this.chain.toErc7930(this.other)); + }); + + it('with an invalid new gateway: revert', async function () { + // directly using the internal setter + await expect(this.executor.$_setup(this.other, this.chain.toErc7930(this.other))).to.be.reverted; + + // through a crosschain call + const mode = encodeMode({ callType: CALL_TYPE_SINGLE }); + const data = encodeSingle( + this.executor, + 0n, + this.executor.interface.encodeFunctionData('reconfigure', [ + this.other.address, + this.chain.toErc7930(this.other), + ]), + ); + + await expect( + this.remoteExecute(this.admin, this.chain.toErc7930(this.executor), mode, data), + ).to.be.revertedWithCustomError(this.executor, 'FailedCall'); + }); + + it('is access controlled', async function () { + await expect( + this.executor.reconfigure(this.newGateway, this.chain.toErc7930(this.other)), + ).to.be.revertedWithCustomError(this.executor, 'AccessRestricted'); + }); + }); +}); diff --git a/test/governance/extensions/GovernorCrosschain.test.js b/test/governance/extensions/GovernorCrosschain.test.js new file mode 100644 index 00000000000..c974dad954c --- /dev/null +++ b/test/governance/extensions/GovernorCrosschain.test.js @@ -0,0 +1,158 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { getLocalChain } = require('../../helpers/chains'); +const { CALL_TYPE_SINGLE, encodeMode, encodeSingle } = require('../../helpers/erc7579'); +const { GovernorHelper } = require('../../helpers/governance'); +const { VoteType } = require('../../helpers/enums'); + +const name = 'OZ-Governor'; +const version = '1'; +const tokenName = 'MockToken'; +const tokenSymbol = 'MTKN'; +const tokenSupply = ethers.parseEther('100'); +const votingDelay = 4n; +const votingPeriod = 16n; +const value = ethers.parseEther('1'); + +async function fixture() { + const chain = await getLocalChain(); + const [owner, proposer, voter1, voter2, voter3, voter4] = await ethers.getSigners(); + + const gateway = await ethers.deployContract('$ERC7786GatewayMock'); + const receiver = await ethers.deployContract('CallReceiverMock'); + + // Deploy governance + const token = await ethers.deployContract('$ERC20Votes', [tokenName, tokenSymbol, tokenName, version]); + const governor = await ethers.deployContract('$GovernorCrosschainMock', [ + name, // name + votingDelay, // initialVotingDelay + votingPeriod, // initialVotingPeriod + 0n, // initialProposalThreshold + token, // tokenAddress + 10n, // quorumNumeratorValue + ]); + + // Deploy executor + const executor = await ethers.deployContract('$CrosschainRemoteExecutor', [gateway, chain.toErc7930(governor)]); + + await owner.sendTransaction({ to: governor, value }); + await token.$_mint(owner, tokenSupply); + + const helper = new GovernorHelper(governor, 'blocknumber'); + await helper.connect(owner).delegate({ token: token, to: voter1, value: ethers.parseEther('10') }); + await helper.connect(owner).delegate({ token: token, to: voter2, value: ethers.parseEther('7') }); + await helper.connect(owner).delegate({ token: token, to: voter3, value: ethers.parseEther('5') }); + await helper.connect(owner).delegate({ token: token, to: voter4, value: ethers.parseEther('2') }); + + return { + chain, + owner, + proposer, + voter1, + voter2, + voter3, + voter4, + gateway, + receiver, + token, + governor, + executor, + helper, + }; +} + +describe('GovernorCrosschain', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + it('execute with executor', async function () { + this.helper.setProposal( + [ + { + target: this.governor.target, + data: this.governor.interface.encodeFunctionData('relayCrosschain(address,bytes,bytes32,bytes)', [ + this.gateway.target, + this.chain.toErc7930(this.executor), + encodeMode({ callType: CALL_TYPE_SINGLE }), + encodeSingle(this.receiver, 0n, this.receiver.interface.encodeFunctionData('mockFunctionExtra')), + ]), + }, + ], + '', + ); + + await this.helper.propose(); + await this.helper.waitForSnapshot(); + await this.helper.connect(this.voter1).vote({ support: VoteType.For }); + await this.helper.connect(this.voter2).vote({ support: VoteType.For }); + await this.helper.waitForDeadline(); + + await expect(this.helper.execute()).to.emit(this.receiver, 'MockFunctionCalledExtra').withArgs(this.executor, 0n); + }); + + it('relayCrosschain is onlyGovernance', async function () { + await expect( + this.governor.getFunction('relayCrosschain(address,bytes,bytes32,bytes)')( + this.gateway, + this.chain.toErc7930(this.executor), + encodeMode({ callType: CALL_TYPE_SINGLE }), + encodeSingle(this.receiver, 0n, this.receiver.interface.encodeFunctionData('mockFunctionExtra')), + ), + ).to.be.revertedWithCustomError(this.governor, 'GovernorOnlyExecutor'); + }); + + it('reconfigure executor', async function () { + const newGovernor = await ethers.deployContract('$GovernorCrosschainMock', [ + name, // name + votingDelay, // initialVotingDelay + votingPeriod, // initialVotingPeriod + 0n, // initialProposalThreshold + this.token, // tokenAddress + 10n, // quorumNumeratorValue + ]); + + // Before reconfiguration + await expect(this.executor.gateway()).to.eventually.equal(this.gateway); + await expect(this.executor.controller()).to.eventually.equal(this.chain.toErc7930(this.governor)); + + // Propose reconfiguration + this.helper.setProposal( + [ + { + target: this.governor.target, + data: this.governor.interface.encodeFunctionData('relayCrosschain(address,bytes,bytes32,bytes)', [ + this.gateway.target, + this.chain.toErc7930(this.executor), + encodeMode({ callType: CALL_TYPE_SINGLE }), + encodeSingle( + this.executor, + 0n, + this.executor.interface.encodeFunctionData('reconfigure', [ + this.gateway.target, + this.chain.toErc7930(newGovernor), + ]), + ), + ]), + }, + ], + '', + ); + + await this.helper.propose(); + await this.helper.waitForSnapshot(); + await this.helper.connect(this.voter1).vote({ support: VoteType.For }); + await this.helper.connect(this.voter2).vote({ support: VoteType.For }); + await this.helper.waitForDeadline(); + + await expect(this.helper.execute()) + .to.emit(this.executor, 'CrosschainControllerSet') + .withArgs(this.gateway, this.chain.toErc7930(newGovernor)); + + // After reconfiguration + await expect(this.executor.gateway()).to.eventually.equal(this.gateway); + await expect(this.executor.controller()).to.eventually.equal(this.chain.toErc7930(newGovernor)); + }); +});