Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/rare-bushes-march.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'openzeppelin-solidity': minor
---

`GovernorCrosschain`: Governor module that facilitates the execution of crosschain operations through CrosschainRemoteExecutors and ERC-7786 gateways.
98 changes: 98 additions & 0 deletions contracts/crosschain/CrosschainRemoteExecutor.sol
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a side note, I think this remote executor could allow multichain accounts similar to Hyperlane's Interchain Accounts.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Executor is indeed designed to be the "remote identity" of any "account". Can be used by a governor, but also by a timelock, a multisig, or any account

Original file line number Diff line number Diff line change
@@ -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, an EOA 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);
Comment on lines +90 to +96
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pattern is shared with AccountERC7579. Maybe we should add a function to the ERC7579Utils:

function exec(bytes32 mode, bytes calldata executionCalldata) internal {
       if (callType == CALLTYPE_SINGLE) {
            executionCalldata.execSingle(execType);
        } else if (callType == CALLTYPE_BATCH) {
            executionCalldata.execBatch(execType);
        } else if (callType == CALLTYPE_DELEGATECALL) {
            executionCalldata.execDelegateCall(execType);
        } else revert ERC7579UnsupportedCallType(callType);
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No strong opinion here. I'm not sure we really need to have this helper, but I also don't see any issues in adding it.

}
}
3 changes: 3 additions & 0 deletions contracts/crosschain/README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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. Used by the {GovernorCrosschain} for the remote execution of proposals.
* {ERC7786Recipient}: generic ERC-7786 crosschain contract that receives messages from a trusted gateway.

Additionally there are multiple bridge constructions:
Expand All @@ -22,6 +23,8 @@ Additionally there are multiple bridge constructions:

{{CrosschainLinked}}

{{CrosschainRemoteExecutor}}

{{ERC7786Recipient}}

== Bridges
Expand Down
22 changes: 13 additions & 9 deletions contracts/governance/README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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

Expand Down
31 changes: 31 additions & 0 deletions contracts/governance/extensions/GovernorCrosschain.sol
Original file line number Diff line number Diff line change
@@ -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 {CrosschainRemoteExecutors}.
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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's your reasoning to allow specifying the gateway explicitly? It would be easy to batch together registerRemoteExecutor followed by relayCrosschain.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking that in some cases you may want to use a custom target. Maybe there are two executor (a main one and a secondary) and you want to target one without reconfiguring. Adding/updating the default executor target is a storage operation that is expensive and that some people may want to skip.

Mode mode,
bytes memory executionCalldata
) internal virtual {
IERC7786GatewaySource(gateway).sendMessage(executor, abi.encodePacked(mode, executionCalldata), new bytes[](0));
}
}
20 changes: 20 additions & 0 deletions contracts/mocks/governance/GovernorCrosschain.sol
Original file line number Diff line number Diff line change
@@ -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();
}
}
144 changes: 144 additions & 0 deletions test/crosschain/CrosschainExecutor.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
Loading