From d3dbb62981a00cd82b420dc2c3cf176e07b24185 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Fri, 9 Jan 2026 14:15:50 +0100 Subject: [PATCH 01/16] Add CrosschainRemoteExecutor and CrosschainRemoteController --- .../crosschain/CrosschainRemoteController.sol | 65 +++++++ .../crosschain/CrosschainRemoteExecutor.sol | 75 +++++++++ .../CrosschainRemoteControllerMock.sol | 10 ++ test/crosschain/CrosschainExecutor.test.js | 158 ++++++++++++++++++ 4 files changed, 308 insertions(+) create mode 100644 contracts/crosschain/CrosschainRemoteController.sol create mode 100644 contracts/crosschain/CrosschainRemoteExecutor.sol create mode 100644 contracts/mocks/crosschain/CrosschainRemoteControllerMock.sol create mode 100644 test/crosschain/CrosschainExecutor.test.js diff --git a/contracts/crosschain/CrosschainRemoteController.sol b/contracts/crosschain/CrosschainRemoteController.sol new file mode 100644 index 00000000000..74124653434 --- /dev/null +++ b/contracts/crosschain/CrosschainRemoteController.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.26; + +import {CrosschainLinked} from "./CrosschainLinked.sol"; + +import {IERC7786GatewaySource} from "../interfaces/draft-IERC7786.sol"; +import {InteroperableAddress} from "../utils/draft-InteroperableAddress.sol"; +import {Bytes} from "../utils/Bytes.sol"; +import {ERC7786Recipient} from "./ERC7786Recipient.sol"; + +import {Mode} from "../account/utils/draft-ERC7579Utils.sol"; + +abstract contract CrosschainRemoteController { + using Bytes for bytes; + using InteroperableAddress for bytes; + + mapping(bytes chain => CrosschainLinked.Link) private _links; + + constructor(CrosschainLinked.Link[] memory links) { + for (uint256 i = 0; i < links.length; ++i) { + _setLink(links[i].gateway, links[i].counterpart, false); + } + } + + function _crosschainExecute(bytes memory chain, Mode mode, bytes memory executionCalldata) internal virtual { + (address gateway, bytes memory counterpart) = getLink(chain); + IERC7786GatewaySource(gateway).sendMessage( + counterpart, + abi.encodePacked(mode, executionCalldata), + new bytes[](0) + ); + } + + /** + * @dev Returns the ERC-7786 gateway used for sending and receiving cross-chain messages to a given chain. + * + * Note: The `chain` parameter is a "chain-only" InteroperableAddress (empty address) and the `counterpart` returns + * the full InteroperableAddress (chain ref + address) that is on `chain`. + */ + function getLink(bytes memory chain) public view virtual returns (address gateway, bytes memory counterpart) { + CrosschainLinked.Link storage self = _links[chain]; + return (self.gateway, self.counterpart); + } + + /** + * @dev Internal setter to change the ERC-7786 gateway and counterpart for a given chain. Called at construction. + * + * Note: The `counterpart` parameter is the full InteroperableAddress (chain ref + address). + */ + function _setLink(address gateway, bytes memory counterpart, bool allowOverride) 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)); + + (bytes2 chainType, bytes memory chainReference, ) = counterpart.parseV1(); + bytes memory chain = InteroperableAddress.formatV1(chainType, chainReference, hex""); + if (allowOverride || _links[chain].gateway == address(0)) { + _links[chain] = CrosschainLinked.Link(gateway, counterpart); + emit CrosschainLinked.LinkRegistered(gateway, counterpart); + } else { + revert CrosschainLinked.LinkAlreadyRegistered(chain); + } + } +} diff --git a/contracts/crosschain/CrosschainRemoteExecutor.sol b/contracts/crosschain/CrosschainRemoteExecutor.sol new file mode 100644 index 00000000000..6eebefbaeb2 --- /dev/null +++ b/contracts/crosschain/CrosschainRemoteExecutor.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.26; + +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"; + +contract CrosschainRemoteExecutor is ERC7786Recipient { + using Bytes for bytes; + using ERC7579Utils for *; + + address private _gateway; + bytes private _controller; + + event CrosschainControllerSet(address gateway, bytes controller); + error AccessRestricted(); + + constructor(address initialGateway, bytes memory initialController) { + _setup(initialGateway, initialController); + } + + function gateway() public view virtual returns (address) { + return _gateway; + } + + function controller() public view virtual returns (bytes memory) { + return _controller; + } + + function reconfigure(address newGateway, bytes memory newController) public virtual { + require(msg.sender == address(this), AccessRestricted()); + _setup(newGateway, newController); + } + + 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); + } +} diff --git a/contracts/mocks/crosschain/CrosschainRemoteControllerMock.sol b/contracts/mocks/crosschain/CrosschainRemoteControllerMock.sol new file mode 100644 index 00000000000..b0b6214519e --- /dev/null +++ b/contracts/mocks/crosschain/CrosschainRemoteControllerMock.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.26; + +import {CrosschainRemoteController} from "../../crosschain/CrosschainRemoteController.sol"; +import {CrosschainLinked} from "../../crosschain/CrosschainLinked.sol"; + +contract CrosschainRemoteControllerMock is CrosschainRemoteController { + constructor(CrosschainLinked.Link[] memory links) CrosschainRemoteController(links) {} +} diff --git a/test/crosschain/CrosschainExecutor.test.js b/test/crosschain/CrosschainExecutor.test.js new file mode 100644 index 00000000000..f1c663b595a --- /dev/null +++ b/test/crosschain/CrosschainExecutor.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, + CALL_TYPE_BATCH, + CALL_TYPE_DELEGATE, + encodeMode, + encodeSingle, + encodeBatch, + encodeDelegate, +} = require('../helpers/erc7579'); + +async function fixture() { + const chain = await getLocalChain(); + const [eoa] = await ethers.getSigners(); + + const gateway = await ethers.deployContract('$ERC7786GatewayMock'); + const target = await ethers.deployContract('CallReceiverMock'); + + // Deploy controller + const controller = await ethers.deployContract('$CrosschainRemoteControllerMock', [[]]); + + // Deploy executor + const executor = await ethers.deployContract('$CrosschainRemoteExecutor', [gateway, chain.toErc7930(controller)]); + + // Configure controller + await expect(controller.$_setLink(gateway, chain.toErc7930(executor), false)) + .to.emit(controller, 'LinkRegistered') + .withArgs(gateway, chain.toErc7930(executor)); + + return { chain, eoa, gateway, target, controller, executor }; +} + +describe('CrosschainRemoteController & CrosschainRemoteExecutor', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + it('setup', async function () { + // controller + await expect(this.controller.getLink(this.chain.erc7930)).to.eventually.deep.equal([ + this.gateway.target, + this.chain.toErc7930(this.executor), + ]); + + // executor + await expect(this.executor.gateway()).to.eventually.equal(this.gateway); + await expect(this.executor.controller()).to.eventually.equal(this.chain.toErc7930(this.controller)); + }); + + 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.controller.$_crosschainExecute(this.chain.erc7930, 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.controller.$_crosschainExecute(this.chain.erc7930, 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.controller.$_crosschainExecute(this.chain.erc7930, 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.controller.$_crosschainExecute(this.chain.erc7930, mode, data)) + .to.be.revertedWithCustomError(this.executor, 'ERC7579UnsupportedCallType') + .withArgs('0x42'); + }); + }); + + describe('reconfigure', function () { + beforeEach(async function () { + this.newGateway = await ethers.deployContract('$ERC7786GatewayMock'); + this.newController = await ethers.deployContract('$CrosschainRemoteControllerMock', [[]]); + }); + + 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.newController), + ]), + ); + + await expect(this.controller.$_crosschainExecute(this.chain.erc7930, mode, data)) + .to.emit(this.executor, 'CrosschainControllerSet') + .withArgs(this.newGateway, this.chain.toErc7930(this.newController)); + + await expect(this.executor.gateway()).to.eventually.equal(this.newGateway); + await expect(this.executor.controller()).to.eventually.equal(this.chain.toErc7930(this.newController)); + }); + + it('through the internal setter: success', async function () { + await expect(this.executor.$_setup(this.newGateway, this.chain.toErc7930(this.newController))) + .to.emit(this.executor, 'CrosschainControllerSet') + .withArgs(this.newGateway, this.chain.toErc7930(this.newController)); + + await expect(this.executor.gateway()).to.eventually.equal(this.newGateway); + await expect(this.executor.controller()).to.eventually.equal(this.chain.toErc7930(this.newController)); + }); + + it('with an invalid new gateway: revert', async function () { + // directly using the internal setter + await expect(this.executor.$_setup(this.eoa.address, this.chain.toErc7930(this.newController))).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.eoa.address, + this.chain.toErc7930(this.newController), + ]), + ); + + await expect(this.controller.$_crosschainExecute(this.chain.erc7930, 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.newController)), + ).to.be.revertedWithCustomError(this.executor, 'AccessRestricted'); + }); + }); +}); From 2786db27637a2deee84c9142be5a651a9088f51e Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Fri, 9 Jan 2026 14:20:21 +0100 Subject: [PATCH 02/16] remove import to CrosschainLinked --- .../crosschain/CrosschainRemoteController.sol | 32 ++++++++++++++----- .../CrosschainRemoteControllerMock.sol | 3 +- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/contracts/crosschain/CrosschainRemoteController.sol b/contracts/crosschain/CrosschainRemoteController.sol index 74124653434..e59a382e089 100644 --- a/contracts/crosschain/CrosschainRemoteController.sol +++ b/contracts/crosschain/CrosschainRemoteController.sol @@ -2,8 +2,6 @@ pragma solidity ^0.8.26; -import {CrosschainLinked} from "./CrosschainLinked.sol"; - import {IERC7786GatewaySource} from "../interfaces/draft-IERC7786.sol"; import {InteroperableAddress} from "../utils/draft-InteroperableAddress.sol"; import {Bytes} from "../utils/Bytes.sol"; @@ -15,9 +13,27 @@ abstract contract CrosschainRemoteController { using Bytes for bytes; using InteroperableAddress for bytes; - mapping(bytes chain => CrosschainLinked.Link) private _links; + struct Link { + address gateway; + bytes counterpart; // Full InteroperableAddress (chain ref + address) + } + mapping(bytes chain => Link) private _links; + + /** + * @dev Emitted when a new link is registered. + * + * Note: the `counterpart` argument is a full InteroperableAddress (chain ref + address). + */ + event LinkRegistered(address gateway, bytes counterpart); + + /** + * @dev Reverted when trying to register a link for a chain that is already registered. + * + * Note: the `chain` argument is a "chain-only" InteroperableAddress (empty address). + */ + error LinkAlreadyRegistered(bytes chain); - constructor(CrosschainLinked.Link[] memory links) { + constructor(Link[] memory links) { for (uint256 i = 0; i < links.length; ++i) { _setLink(links[i].gateway, links[i].counterpart, false); } @@ -39,7 +55,7 @@ abstract contract CrosschainRemoteController { * the full InteroperableAddress (chain ref + address) that is on `chain`. */ function getLink(bytes memory chain) public view virtual returns (address gateway, bytes memory counterpart) { - CrosschainLinked.Link storage self = _links[chain]; + Link storage self = _links[chain]; return (self.gateway, self.counterpart); } @@ -56,10 +72,10 @@ abstract contract CrosschainRemoteController { (bytes2 chainType, bytes memory chainReference, ) = counterpart.parseV1(); bytes memory chain = InteroperableAddress.formatV1(chainType, chainReference, hex""); if (allowOverride || _links[chain].gateway == address(0)) { - _links[chain] = CrosschainLinked.Link(gateway, counterpart); - emit CrosschainLinked.LinkRegistered(gateway, counterpart); + _links[chain] = Link(gateway, counterpart); + emit LinkRegistered(gateway, counterpart); } else { - revert CrosschainLinked.LinkAlreadyRegistered(chain); + revert LinkAlreadyRegistered(chain); } } } diff --git a/contracts/mocks/crosschain/CrosschainRemoteControllerMock.sol b/contracts/mocks/crosschain/CrosschainRemoteControllerMock.sol index b0b6214519e..2fb5836900d 100644 --- a/contracts/mocks/crosschain/CrosschainRemoteControllerMock.sol +++ b/contracts/mocks/crosschain/CrosschainRemoteControllerMock.sol @@ -3,8 +3,7 @@ pragma solidity ^0.8.26; import {CrosschainRemoteController} from "../../crosschain/CrosschainRemoteController.sol"; -import {CrosschainLinked} from "../../crosschain/CrosschainLinked.sol"; contract CrosschainRemoteControllerMock is CrosschainRemoteController { - constructor(CrosschainLinked.Link[] memory links) CrosschainRemoteController(links) {} + constructor(CrosschainRemoteController.Link[] memory links) CrosschainRemoteController(links) {} } From 5003f4e09e0de3c56b9e56d0d453b3963007c235 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Fri, 9 Jan 2026 14:54:28 +0100 Subject: [PATCH 03/16] update --- .../crosschain/CrosschainRemoteController.sol | 67 +++++++++------- .../CrosschainRemoteControllerMock.sol | 9 --- test/crosschain/CrosschainExecutor.test.js | 80 ++++++++++++++++--- 3 files changed, 109 insertions(+), 47 deletions(-) delete mode 100644 contracts/mocks/crosschain/CrosschainRemoteControllerMock.sol diff --git a/contracts/crosschain/CrosschainRemoteController.sol b/contracts/crosschain/CrosschainRemoteController.sol index e59a382e089..285936a59e7 100644 --- a/contracts/crosschain/CrosschainRemoteController.sol +++ b/contracts/crosschain/CrosschainRemoteController.sol @@ -13,39 +13,46 @@ abstract contract CrosschainRemoteController { using Bytes for bytes; using InteroperableAddress for bytes; - struct Link { + struct ExecutorDetails { address gateway; - bytes counterpart; // Full InteroperableAddress (chain ref + address) + bytes executor; } - mapping(bytes chain => Link) private _links; + mapping(bytes chain => ExecutorDetails) private _remoteExecutors; /** - * @dev Emitted when a new link is registered. + * @dev Emitted when a new remote executor is registered. * - * Note: the `counterpart` argument is a full InteroperableAddress (chain ref + address). + * Note: the `executor` argument is a full InteroperableAddress (chain ref + address). */ - event LinkRegistered(address gateway, bytes counterpart); + event RemoteExecutorRegistered(address gateway, bytes executor); /** * @dev Reverted when trying to register a link for a chain that is already registered. * * Note: the `chain` argument is a "chain-only" InteroperableAddress (empty address). */ - error LinkAlreadyRegistered(bytes chain); + error RemoteExecutorAlreadyRegistered(bytes chain); - constructor(Link[] memory links) { - for (uint256 i = 0; i < links.length; ++i) { - _setLink(links[i].gateway, links[i].counterpart, false); - } - } + /** + * @dev Reverted when trying to send a crosschain instruction to a chain with no registered executor. + */ + error NoExecutorRegistered(bytes chain); + /// @dev Send crosschain instruction to a the canonical remote executor of a given chain. function _crosschainExecute(bytes memory chain, Mode mode, bytes memory executionCalldata) internal virtual { - (address gateway, bytes memory counterpart) = getLink(chain); - IERC7786GatewaySource(gateway).sendMessage( - counterpart, - abi.encodePacked(mode, executionCalldata), - new bytes[](0) - ); + (address gateway, bytes memory executor) = getRemoteExecutor(chain); + require(gateway != address(0), NoExecutorRegistered(chain)); + _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)); } /** @@ -54,28 +61,30 @@ abstract contract CrosschainRemoteController { * Note: The `chain` parameter is a "chain-only" InteroperableAddress (empty address) and the `counterpart` returns * the full InteroperableAddress (chain ref + address) that is on `chain`. */ - function getLink(bytes memory chain) public view virtual returns (address gateway, bytes memory counterpart) { - Link storage self = _links[chain]; - return (self.gateway, self.counterpart); + function getRemoteExecutor( + bytes memory chain + ) public view virtual returns (address gateway, bytes memory executor) { + ExecutorDetails storage self = _remoteExecutors[chain]; + return (self.gateway, self.executor); } /** - * @dev Internal setter to change the ERC-7786 gateway and counterpart for a given chain. Called at construction. + * @dev Internal setter to change the ERC-7786 gateway and executor for a given chain. Called at construction. * - * Note: The `counterpart` parameter is the full InteroperableAddress (chain ref + address). + * Note: The `executor` parameter is the full InteroperableAddress (chain ref + address). */ - function _setLink(address gateway, bytes memory counterpart, bool allowOverride) internal virtual { + function _registerRemoteExecutor(address gateway, bytes memory executor, bool allowOverride) 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)); - (bytes2 chainType, bytes memory chainReference, ) = counterpart.parseV1(); + (bytes2 chainType, bytes memory chainReference, ) = executor.parseV1(); bytes memory chain = InteroperableAddress.formatV1(chainType, chainReference, hex""); - if (allowOverride || _links[chain].gateway == address(0)) { - _links[chain] = Link(gateway, counterpart); - emit LinkRegistered(gateway, counterpart); + if (allowOverride || _remoteExecutors[chain].gateway == address(0)) { + _remoteExecutors[chain] = ExecutorDetails(gateway, executor); + emit RemoteExecutorRegistered(gateway, executor); } else { - revert LinkAlreadyRegistered(chain); + revert RemoteExecutorAlreadyRegistered(chain); } } } diff --git a/contracts/mocks/crosschain/CrosschainRemoteControllerMock.sol b/contracts/mocks/crosschain/CrosschainRemoteControllerMock.sol deleted file mode 100644 index 2fb5836900d..00000000000 --- a/contracts/mocks/crosschain/CrosschainRemoteControllerMock.sol +++ /dev/null @@ -1,9 +0,0 @@ -// SPDX-License-Identifier: MIT - -pragma solidity ^0.8.26; - -import {CrosschainRemoteController} from "../../crosschain/CrosschainRemoteController.sol"; - -contract CrosschainRemoteControllerMock is CrosschainRemoteController { - constructor(CrosschainRemoteController.Link[] memory links) CrosschainRemoteController(links) {} -} diff --git a/test/crosschain/CrosschainExecutor.test.js b/test/crosschain/CrosschainExecutor.test.js index f1c663b595a..44d58b972a8 100644 --- a/test/crosschain/CrosschainExecutor.test.js +++ b/test/crosschain/CrosschainExecutor.test.js @@ -21,14 +21,14 @@ async function fixture() { const target = await ethers.deployContract('CallReceiverMock'); // Deploy controller - const controller = await ethers.deployContract('$CrosschainRemoteControllerMock', [[]]); + const controller = await ethers.deployContract('$CrosschainRemoteController'); // Deploy executor const executor = await ethers.deployContract('$CrosschainRemoteExecutor', [gateway, chain.toErc7930(controller)]); - // Configure controller - await expect(controller.$_setLink(gateway, chain.toErc7930(executor), false)) - .to.emit(controller, 'LinkRegistered') + // Register controller + await expect(controller.$_registerRemoteExecutor(gateway, chain.toErc7930(executor), false)) + .to.emit(controller, 'RemoteExecutorRegistered') .withArgs(gateway, chain.toErc7930(executor)); return { chain, eoa, gateway, target, controller, executor }; @@ -41,7 +41,7 @@ describe('CrosschainRemoteController & CrosschainRemoteExecutor', function () { it('setup', async function () { // controller - await expect(this.controller.getLink(this.chain.erc7930)).to.eventually.deep.equal([ + await expect(this.controller.getRemoteExecutor(this.chain.erc7930)).to.eventually.deep.equal([ this.gateway.target, this.chain.toErc7930(this.executor), ]); @@ -56,7 +56,22 @@ describe('CrosschainRemoteController & CrosschainRemoteExecutor', function () { const mode = encodeMode({ callType: CALL_TYPE_SINGLE }); const data = encodeSingle(this.target, 0n, this.target.interface.encodeFunctionData('mockFunctionExtra')); - await expect(this.controller.$_crosschainExecute(this.chain.erc7930, mode, data)) + // Using arbitrary gateway + executor + await expect( + this.controller.getFunction('$_crosschainExecute(address,bytes,bytes32,bytes)')( + this.gateway, + this.chain.toErc7930(this.executor), + mode, + data, + ), + ) + .to.emit(this.target, 'MockFunctionCalledExtra') + .withArgs(this.executor, 0n); + + // Using registered executor + await expect( + this.controller.getFunction('$_crosschainExecute(bytes,bytes32,bytes)')(this.chain.erc7930, mode, data), + ) .to.emit(this.target, 'MockFunctionCalledExtra') .withArgs(this.executor, 0n); }); @@ -68,7 +83,24 @@ describe('CrosschainRemoteController & CrosschainRemoteExecutor', function () { [this.target, 0n, this.target.interface.encodeFunctionData('mockFunctionExtra')], ); - await expect(this.controller.$_crosschainExecute(this.chain.erc7930, mode, data)) + // Using arbitrary gateway + executor + await expect( + this.controller.getFunction('$_crosschainExecute(address,bytes,bytes32,bytes)')( + this.gateway, + this.chain.toErc7930(this.executor), + mode, + data, + ), + ) + .to.emit(this.target, 'MockFunctionCalledWithArgs') + .withArgs(42, '0x1234') + .to.emit(this.target, 'MockFunctionCalledExtra') + .withArgs(this.executor, 0n); + + // Using registered executor + await expect( + this.controller.getFunction('$_crosschainExecute(bytes,bytes32,bytes)')(this.chain.erc7930, mode, data), + ) .to.emit(this.target, 'MockFunctionCalledWithArgs') .withArgs(42, '0x1234') .to.emit(this.target, 'MockFunctionCalledExtra') @@ -79,7 +111,22 @@ describe('CrosschainRemoteController & CrosschainRemoteExecutor', function () { const mode = encodeMode({ callType: CALL_TYPE_DELEGATE }); const data = encodeDelegate(this.target, this.target.interface.encodeFunctionData('mockFunctionExtra')); - await expect(this.controller.$_crosschainExecute(this.chain.erc7930, mode, data)) + // Using arbitrary gateway + executor + await expect( + this.controller.getFunction('$_crosschainExecute(address,bytes,bytes32,bytes)')( + this.gateway, + this.chain.toErc7930(this.executor), + mode, + data, + ), + ) + .to.emit(this.target.attach(this.executor.target), 'MockFunctionCalledExtra') + .withArgs(this.gateway, 0n); + + // Using registered executor + await expect( + this.controller.getFunction('$_crosschainExecute(bytes,bytes32,bytes)')(this.chain.erc7930, mode, data), + ) .to.emit(this.target.attach(this.executor.target), 'MockFunctionCalledExtra') .withArgs(this.gateway, 0n); }); @@ -88,7 +135,22 @@ describe('CrosschainRemoteController & CrosschainRemoteExecutor', function () { const mode = encodeMode({ callType: '0x42' }); const data = '0x'; - await expect(this.controller.$_crosschainExecute(this.chain.erc7930, mode, data)) + // Using arbitrary gateway + executor + await expect( + this.controller.getFunction('$_crosschainExecute(address,bytes,bytes32,bytes)')( + this.gateway, + this.chain.toErc7930(this.executor), + mode, + data, + ), + ) + .to.be.revertedWithCustomError(this.executor, 'ERC7579UnsupportedCallType') + .withArgs('0x42'); + + // Using registered executor + await expect( + this.controller.getFunction('$_crosschainExecute(bytes,bytes32,bytes)')(this.chain.erc7930, mode, data), + ) .to.be.revertedWithCustomError(this.executor, 'ERC7579UnsupportedCallType') .withArgs('0x42'); }); From 68384a5c0051b60f05465b6163aa93052b84c2a2 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Fri, 9 Jan 2026 15:18:31 +0100 Subject: [PATCH 04/16] update --- test/crosschain/CrosschainExecutor.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/crosschain/CrosschainExecutor.test.js b/test/crosschain/CrosschainExecutor.test.js index 44d58b972a8..6ffdb4aa2d2 100644 --- a/test/crosschain/CrosschainExecutor.test.js +++ b/test/crosschain/CrosschainExecutor.test.js @@ -159,7 +159,7 @@ describe('CrosschainRemoteController & CrosschainRemoteExecutor', function () { describe('reconfigure', function () { beforeEach(async function () { this.newGateway = await ethers.deployContract('$ERC7786GatewayMock'); - this.newController = await ethers.deployContract('$CrosschainRemoteControllerMock', [[]]); + this.newController = await ethers.deployContract('$CrosschainRemoteController'); }); it('through a crosschain call: success', async function () { From 415f5d8d19adc5042c8e6ac67429b09f2973a045 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 22 Jan 2026 21:48:53 +0100 Subject: [PATCH 05/16] changeset and docs --- .changeset/rare-bushes-march.md | 5 + contracts/governance/README.adoc | 22 +- .../extensions/GovernorCrosschain.sol | 34 +++ .../mocks/governance/GovernorCrosschain.sol | 20 ++ .../extensions/GovernorCrosschain.test.js | 216 ++++++++++++++++++ 5 files changed, 288 insertions(+), 9 deletions(-) create mode 100644 .changeset/rare-bushes-march.md create mode 100644 contracts/governance/extensions/GovernorCrosschain.sol create mode 100644 contracts/mocks/governance/GovernorCrosschain.sol create mode 100644 test/governance/extensions/GovernorCrosschain.test.js diff --git a/.changeset/rare-bushes-march.md b/.changeset/rare-bushes-march.md new file mode 100644 index 00000000000..3afcfe3e7d9 --- /dev/null +++ b/.changeset/rare-bushes-march.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`GovernorCrosschain`: Governor module that facilitates the execute of crosschain operations through CrosschainRemoteExecutors and ERC-7786 gateways. 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..b45390becab --- /dev/null +++ b/contracts/governance/extensions/GovernorCrosschain.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {Governor} from "../Governor.sol"; +import {Mode} from "../../account/utils/draft-ERC7579Utils.sol"; +import {CrosschainRemoteController} from "../../crosschain/CrosschainRemoteController.sol"; + +/// @dev Extension of {Governor} for cross-chain governance through ERC-7786 gateways and {CrosschainRemoteExecutors}. +abstract contract GovernorCrosschain is Governor, CrosschainRemoteController { + /// @dev Send crosschain instruction to a the canonical remote executor of a given chain. + function relayCrosschain( + bytes memory chain, + Mode mode, + bytes memory executionCalldata + ) public virtual onlyGovernance { + _crosschainExecute(chain, mode, executionCalldata); + } + + /// @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 Register the canonical remote executor for a given chain. + function registerRemoteExecutor(address gateway, bytes memory executor) public virtual onlyGovernance { + _registerRemoteExecutor(gateway, executor, true); + } +} 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/governance/extensions/GovernorCrosschain.test.js b/test/governance/extensions/GovernorCrosschain.test.js new file mode 100644 index 00000000000..0a255d573ec --- /dev/null +++ b/test/governance/extensions/GovernorCrosschain.test.js @@ -0,0 +1,216 @@ +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 unregistered 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('register executor and execute with registered executor', async function () { + this.helper.setProposal( + [ + { + target: this.governor.target, + data: this.governor.interface.encodeFunctionData('registerRemoteExecutor', [ + this.gateway.target, + this.chain.toErc7930(this.executor), + ]), + }, + { + target: this.governor.target, + data: this.governor.interface.encodeFunctionData('relayCrosschain(bytes,bytes32,bytes)', [ + this.chain.erc7930, + 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.governor, 'RemoteExecutorRegistered') + .withArgs(this.gateway, this.chain.toErc7930(this.executor)) + .to.emit(this.receiver, 'MockFunctionCalledExtra') + .withArgs(this.executor, 0n); + + await expect(this.governor.getRemoteExecutor(this.chain.erc7930)).to.eventually.deep.equal([ + this.gateway.target, + this.chain.toErc7930(this.executor), + ]); + }); + + 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)); + }); + + describe('restricted', function () { + it('relayCrosschain with unregistered executor 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('relayCrosschain with registered executor is onlyGovernance', async function () { + await expect( + this.governor.getFunction('relayCrosschain(bytes,bytes32,bytes)')( + this.chain.erc7930, + encodeMode({ callType: CALL_TYPE_SINGLE }), + encodeSingle(this.receiver, 0n, this.receiver.interface.encodeFunctionData('mockFunctionExtra')), + ), + ).to.be.revertedWithCustomError(this.governor, 'GovernorOnlyExecutor'); + }); + + it('registerRemoteExecutor is onlyGovernance', async function () { + await expect( + this.governor.registerRemoteExecutor(this.gateway, this.chain.toErc7930(this.executor)), + ).to.be.revertedWithCustomError(this.governor, 'GovernorOnlyExecutor'); + }); + }); +}); From 4d79c70e42de7bfb1ee754836d96f975d007dcf1 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 12 Feb 2026 17:10:36 +0100 Subject: [PATCH 06/16] Update .changeset/rare-bushes-march.md Co-authored-by: Francisco Giordano --- .changeset/rare-bushes-march.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/rare-bushes-march.md b/.changeset/rare-bushes-march.md index 3afcfe3e7d9..d5f9b9de6b2 100644 --- a/.changeset/rare-bushes-march.md +++ b/.changeset/rare-bushes-march.md @@ -2,4 +2,4 @@ 'openzeppelin-solidity': minor --- -`GovernorCrosschain`: Governor module that facilitates the execute of crosschain operations through CrosschainRemoteExecutors and ERC-7786 gateways. +`GovernorCrosschain`: Governor module that facilitates the execution of crosschain operations through CrosschainRemoteExecutors and ERC-7786 gateways. From a727e82b52582da7768f2bdb509e9ac0819d6770 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 12 Feb 2026 17:20:15 +0100 Subject: [PATCH 07/16] Merge CrosschainRemoteController into GovernorCrosschain --- .../crosschain/CrosschainRemoteController.sol | 90 ------------------- .../extensions/GovernorCrosschain.sol | 86 +++++++++++++++++- 2 files changed, 84 insertions(+), 92 deletions(-) delete mode 100644 contracts/crosschain/CrosschainRemoteController.sol diff --git a/contracts/crosschain/CrosschainRemoteController.sol b/contracts/crosschain/CrosschainRemoteController.sol deleted file mode 100644 index 285936a59e7..00000000000 --- a/contracts/crosschain/CrosschainRemoteController.sol +++ /dev/null @@ -1,90 +0,0 @@ -// SPDX-License-Identifier: MIT - -pragma solidity ^0.8.26; - -import {IERC7786GatewaySource} from "../interfaces/draft-IERC7786.sol"; -import {InteroperableAddress} from "../utils/draft-InteroperableAddress.sol"; -import {Bytes} from "../utils/Bytes.sol"; -import {ERC7786Recipient} from "./ERC7786Recipient.sol"; - -import {Mode} from "../account/utils/draft-ERC7579Utils.sol"; - -abstract contract CrosschainRemoteController { - using Bytes for bytes; - using InteroperableAddress for bytes; - - struct ExecutorDetails { - address gateway; - bytes executor; - } - mapping(bytes chain => ExecutorDetails) private _remoteExecutors; - - /** - * @dev Emitted when a new remote executor is registered. - * - * Note: the `executor` argument is a full InteroperableAddress (chain ref + address). - */ - event RemoteExecutorRegistered(address gateway, bytes executor); - - /** - * @dev Reverted when trying to register a link for a chain that is already registered. - * - * Note: the `chain` argument is a "chain-only" InteroperableAddress (empty address). - */ - error RemoteExecutorAlreadyRegistered(bytes chain); - - /** - * @dev Reverted when trying to send a crosschain instruction to a chain with no registered executor. - */ - error NoExecutorRegistered(bytes chain); - - /// @dev Send crosschain instruction to a the canonical remote executor of a given chain. - function _crosschainExecute(bytes memory chain, Mode mode, bytes memory executionCalldata) internal virtual { - (address gateway, bytes memory executor) = getRemoteExecutor(chain); - require(gateway != address(0), NoExecutorRegistered(chain)); - _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)); - } - - /** - * @dev Returns the ERC-7786 gateway used for sending and receiving cross-chain messages to a given chain. - * - * Note: The `chain` parameter is a "chain-only" InteroperableAddress (empty address) and the `counterpart` returns - * the full InteroperableAddress (chain ref + address) that is on `chain`. - */ - function getRemoteExecutor( - bytes memory chain - ) public view virtual returns (address gateway, bytes memory executor) { - ExecutorDetails storage self = _remoteExecutors[chain]; - return (self.gateway, self.executor); - } - - /** - * @dev Internal setter to change the ERC-7786 gateway and executor for a given chain. Called at construction. - * - * Note: The `executor` parameter is the full InteroperableAddress (chain ref + address). - */ - function _registerRemoteExecutor(address gateway, bytes memory executor, bool allowOverride) 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)); - - (bytes2 chainType, bytes memory chainReference, ) = executor.parseV1(); - bytes memory chain = InteroperableAddress.formatV1(chainType, chainReference, hex""); - if (allowOverride || _remoteExecutors[chain].gateway == address(0)) { - _remoteExecutors[chain] = ExecutorDetails(gateway, executor); - emit RemoteExecutorRegistered(gateway, executor); - } else { - revert RemoteExecutorAlreadyRegistered(chain); - } - } -} diff --git a/contracts/governance/extensions/GovernorCrosschain.sol b/contracts/governance/extensions/GovernorCrosschain.sol index b45390becab..6d2fc9aa947 100644 --- a/contracts/governance/extensions/GovernorCrosschain.sol +++ b/contracts/governance/extensions/GovernorCrosschain.sol @@ -4,10 +4,55 @@ pragma solidity ^0.8.24; import {Governor} from "../Governor.sol"; import {Mode} from "../../account/utils/draft-ERC7579Utils.sol"; -import {CrosschainRemoteController} from "../../crosschain/CrosschainRemoteController.sol"; +import {ERC7786Recipient} from "../../crosschain/ERC7786Recipient.sol"; +import {IERC7786GatewaySource} from "../../interfaces/draft-IERC7786.sol"; +import {Bytes} from "../../utils/Bytes.sol"; +import {InteroperableAddress} from "../../utils/draft-InteroperableAddress.sol"; /// @dev Extension of {Governor} for cross-chain governance through ERC-7786 gateways and {CrosschainRemoteExecutors}. -abstract contract GovernorCrosschain is Governor, CrosschainRemoteController { +abstract contract GovernorCrosschain is Governor { + using Bytes for bytes; + using InteroperableAddress for bytes; + + struct ExecutorDetails { + address gateway; + bytes executor; + } + + mapping(bytes chain => ExecutorDetails) private _remoteExecutors; + + /** + * @dev Emitted when a new remote executor is registered. + * + * Note: the `executor` argument is a full InteroperableAddress (chain ref + address). + */ + event RemoteExecutorRegistered(address gateway, bytes executor); + + /** + * @dev Reverted when trying to register a link for a chain that is already registered. + * + * Note: the `chain` argument is a "chain-only" InteroperableAddress (empty address). + */ + error RemoteExecutorAlreadyRegistered(bytes chain); + + /** + * @dev Reverted when trying to send a crosschain instruction to a chain with no registered executor. + */ + error NoExecutorRegistered(bytes chain); + + /** + * @dev Returns the ERC-7786 gateway used for sending and receiving cross-chain messages to a given chain. + * + * Note: The `chain` parameter is a "chain-only" InteroperableAddress (empty address) and the `counterpart` returns + * the full InteroperableAddress (chain ref + address) that is on `chain`. + */ + function getRemoteExecutor( + bytes memory chain + ) public view virtual returns (address gateway, bytes memory executor) { + ExecutorDetails storage self = _remoteExecutors[chain]; + return (self.gateway, self.executor); + } + /// @dev Send crosschain instruction to a the canonical remote executor of a given chain. function relayCrosschain( bytes memory chain, @@ -27,8 +72,45 @@ abstract contract GovernorCrosschain is Governor, CrosschainRemoteController { _crosschainExecute(gateway, executor, mode, executionCalldata); } + /// @dev Send crosschain instruction to a the canonical remote executor of a given chain. + function _crosschainExecute(bytes memory chain, Mode mode, bytes memory executionCalldata) internal virtual { + (address gateway, bytes memory executor) = getRemoteExecutor(chain); + require(gateway != address(0), NoExecutorRegistered(chain)); + _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)); + } + /// @dev Register the canonical remote executor for a given chain. function registerRemoteExecutor(address gateway, bytes memory executor) public virtual onlyGovernance { _registerRemoteExecutor(gateway, executor, true); } + + /** + * @dev Internal setter to change the ERC-7786 gateway and executor for a given chain. Called at construction. + * + * Note: The `executor` parameter is the full InteroperableAddress (chain ref + address). + */ + function _registerRemoteExecutor(address gateway, bytes memory executor, bool allowOverride) 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)); + + (bytes2 chainType, bytes memory chainReference, ) = executor.parseV1(); + bytes memory chain = InteroperableAddress.formatV1(chainType, chainReference, hex""); + if (allowOverride || _remoteExecutors[chain].gateway == address(0)) { + _remoteExecutors[chain] = ExecutorDetails(gateway, executor); + emit RemoteExecutorRegistered(gateway, executor); + } else { + revert RemoteExecutorAlreadyRegistered(chain); + } + } } From cef9e44288affb0e6c61c20f6d67b038552e7fd1 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 12 Feb 2026 17:38:00 +0100 Subject: [PATCH 08/16] doc --- .../crosschain/CrosschainRemoteExecutor.sol | 23 +++++++++++++++++++ contracts/crosschain/README.adoc | 5 +++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/contracts/crosschain/CrosschainRemoteExecutor.sol b/contracts/crosschain/CrosschainRemoteExecutor.sol index 6eebefbaeb2..2b397806dc7 100644 --- a/contracts/crosschain/CrosschainRemoteExecutor.sol +++ b/contracts/crosschain/CrosschainRemoteExecutor.sol @@ -7,33 +7,56 @@ 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). diff --git a/contracts/crosschain/README.adoc b/contracts/crosschain/README.adoc index 4f79f53eaff..ce59de88435 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. 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: @@ -18,6 +19,8 @@ Additionally there are multiple bridge constructions: {{CrosschainLinked}} +{{CrosschainRemoteExecutor}} + {{ERC7786Recipient}} == Bridges @@ -26,4 +29,4 @@ Additionally there are multiple bridge constructions: {{BridgeERC20}} -{{BridgeERC7802}} \ No newline at end of file +{{BridgeERC7802}} From e4d3bb9a0dc36e6a1e54df80e8cb4d0fe32af7b9 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 19 Feb 2026 17:53:15 +0100 Subject: [PATCH 09/16] update tests --- test/crosschain/CrosschainExecutor.test.js | 124 ++++----------------- 1 file changed, 24 insertions(+), 100 deletions(-) diff --git a/test/crosschain/CrosschainExecutor.test.js b/test/crosschain/CrosschainExecutor.test.js index 6ffdb4aa2d2..f957d5eee86 100644 --- a/test/crosschain/CrosschainExecutor.test.js +++ b/test/crosschain/CrosschainExecutor.test.js @@ -15,23 +15,18 @@ const { async function fixture() { const chain = await getLocalChain(); - const [eoa] = await ethers.getSigners(); + const [admin, other] = await ethers.getSigners(); const gateway = await ethers.deployContract('$ERC7786GatewayMock'); const target = await ethers.deployContract('CallReceiverMock'); - // Deploy controller - const controller = await ethers.deployContract('$CrosschainRemoteController'); - // Deploy executor - const executor = await ethers.deployContract('$CrosschainRemoteExecutor', [gateway, chain.toErc7930(controller)]); + const executor = await ethers.deployContract('$CrosschainRemoteExecutor', [gateway, chain.toErc7930(admin)]); - // Register controller - await expect(controller.$_registerRemoteExecutor(gateway, chain.toErc7930(executor), false)) - .to.emit(controller, 'RemoteExecutorRegistered') - .withArgs(gateway, chain.toErc7930(executor)); + const remoteExecute = (from, target, mode, data) => + gateway.connect(from).sendMessage(target, ethers.concat([mode, data]), []); - return { chain, eoa, gateway, target, controller, executor }; + return { chain, gateway, target, executor, admin, other, remoteExecute }; } describe('CrosschainRemoteController & CrosschainRemoteExecutor', function () { @@ -40,15 +35,8 @@ describe('CrosschainRemoteController & CrosschainRemoteExecutor', function () { }); it('setup', async function () { - // controller - await expect(this.controller.getRemoteExecutor(this.chain.erc7930)).to.eventually.deep.equal([ - this.gateway.target, - this.chain.toErc7930(this.executor), - ]); - - // executor await expect(this.executor.gateway()).to.eventually.equal(this.gateway); - await expect(this.executor.controller()).to.eventually.equal(this.chain.toErc7930(this.controller)); + await expect(this.executor.controller()).to.eventually.equal(this.chain.toErc7930(this.admin)); }); describe('crosschain operation', function () { @@ -56,22 +44,7 @@ describe('CrosschainRemoteController & CrosschainRemoteExecutor', function () { const mode = encodeMode({ callType: CALL_TYPE_SINGLE }); const data = encodeSingle(this.target, 0n, this.target.interface.encodeFunctionData('mockFunctionExtra')); - // Using arbitrary gateway + executor - await expect( - this.controller.getFunction('$_crosschainExecute(address,bytes,bytes32,bytes)')( - this.gateway, - this.chain.toErc7930(this.executor), - mode, - data, - ), - ) - .to.emit(this.target, 'MockFunctionCalledExtra') - .withArgs(this.executor, 0n); - - // Using registered executor - await expect( - this.controller.getFunction('$_crosschainExecute(bytes,bytes32,bytes)')(this.chain.erc7930, mode, data), - ) + await expect(this.remoteExecute(this.admin, this.chain.toErc7930(this.executor), mode, data)) .to.emit(this.target, 'MockFunctionCalledExtra') .withArgs(this.executor, 0n); }); @@ -83,24 +56,7 @@ describe('CrosschainRemoteController & CrosschainRemoteExecutor', function () { [this.target, 0n, this.target.interface.encodeFunctionData('mockFunctionExtra')], ); - // Using arbitrary gateway + executor - await expect( - this.controller.getFunction('$_crosschainExecute(address,bytes,bytes32,bytes)')( - this.gateway, - this.chain.toErc7930(this.executor), - mode, - data, - ), - ) - .to.emit(this.target, 'MockFunctionCalledWithArgs') - .withArgs(42, '0x1234') - .to.emit(this.target, 'MockFunctionCalledExtra') - .withArgs(this.executor, 0n); - - // Using registered executor - await expect( - this.controller.getFunction('$_crosschainExecute(bytes,bytes32,bytes)')(this.chain.erc7930, mode, data), - ) + 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') @@ -111,22 +67,7 @@ describe('CrosschainRemoteController & CrosschainRemoteExecutor', function () { const mode = encodeMode({ callType: CALL_TYPE_DELEGATE }); const data = encodeDelegate(this.target, this.target.interface.encodeFunctionData('mockFunctionExtra')); - // Using arbitrary gateway + executor - await expect( - this.controller.getFunction('$_crosschainExecute(address,bytes,bytes32,bytes)')( - this.gateway, - this.chain.toErc7930(this.executor), - mode, - data, - ), - ) - .to.emit(this.target.attach(this.executor.target), 'MockFunctionCalledExtra') - .withArgs(this.gateway, 0n); - - // Using registered executor - await expect( - this.controller.getFunction('$_crosschainExecute(bytes,bytes32,bytes)')(this.chain.erc7930, mode, data), - ) + 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); }); @@ -135,22 +76,7 @@ describe('CrosschainRemoteController & CrosschainRemoteExecutor', function () { const mode = encodeMode({ callType: '0x42' }); const data = '0x'; - // Using arbitrary gateway + executor - await expect( - this.controller.getFunction('$_crosschainExecute(address,bytes,bytes32,bytes)')( - this.gateway, - this.chain.toErc7930(this.executor), - mode, - data, - ), - ) - .to.be.revertedWithCustomError(this.executor, 'ERC7579UnsupportedCallType') - .withArgs('0x42'); - - // Using registered executor - await expect( - this.controller.getFunction('$_crosschainExecute(bytes,bytes32,bytes)')(this.chain.erc7930, mode, data), - ) + await expect(this.remoteExecute(this.admin, this.chain.toErc7930(this.executor), mode, data)) .to.be.revertedWithCustomError(this.executor, 'ERC7579UnsupportedCallType') .withArgs('0x42'); }); @@ -159,7 +85,6 @@ describe('CrosschainRemoteController & CrosschainRemoteExecutor', function () { describe('reconfigure', function () { beforeEach(async function () { this.newGateway = await ethers.deployContract('$ERC7786GatewayMock'); - this.newController = await ethers.deployContract('$CrosschainRemoteController'); }); it('through a crosschain call: success', async function () { @@ -169,30 +94,30 @@ describe('CrosschainRemoteController & CrosschainRemoteExecutor', function () { 0n, this.executor.interface.encodeFunctionData('reconfigure', [ this.newGateway.target, - this.chain.toErc7930(this.newController), + this.chain.toErc7930(this.other), ]), ); - await expect(this.controller.$_crosschainExecute(this.chain.erc7930, mode, data)) + 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.newController)); + .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.newController)); + 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.newController))) + await expect(this.executor.$_setup(this.newGateway, this.chain.toErc7930(this.other))) .to.emit(this.executor, 'CrosschainControllerSet') - .withArgs(this.newGateway, this.chain.toErc7930(this.newController)); + .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.newController)); + 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.eoa.address, this.chain.toErc7930(this.newController))).to.be.reverted; + 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 }); @@ -200,20 +125,19 @@ describe('CrosschainRemoteController & CrosschainRemoteExecutor', function () { this.executor, 0n, this.executor.interface.encodeFunctionData('reconfigure', [ - this.eoa.address, - this.chain.toErc7930(this.newController), + this.other.address, + this.chain.toErc7930(this.other), ]), ); - await expect(this.controller.$_crosschainExecute(this.chain.erc7930, mode, data)).to.be.revertedWithCustomError( - this.executor, - 'FailedCall', - ); + 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.newController)), + this.executor.reconfigure(this.newGateway, this.chain.toErc7930(this.other)), ).to.be.revertedWithCustomError(this.executor, 'AccessRestricted'); }); }); From 9e21544d96e8938f48704393e05bdb6b13557205 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 19 Feb 2026 21:12:58 +0100 Subject: [PATCH 10/16] pragma --- contracts/governance/extensions/GovernorCrosschain.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/governance/extensions/GovernorCrosschain.sol b/contracts/governance/extensions/GovernorCrosschain.sol index 6d2fc9aa947..2273ed43da9 100644 --- a/contracts/governance/extensions/GovernorCrosschain.sol +++ b/contracts/governance/extensions/GovernorCrosschain.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; +pragma solidity ^0.8.26; import {Governor} from "../Governor.sol"; import {Mode} from "../../account/utils/draft-ERC7579Utils.sol"; From 9b8f660d0fc6701e902561329f3f237105eac885 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 19 Feb 2026 22:21:57 +0100 Subject: [PATCH 11/16] pragma --- contracts/crosschain/CrosschainRemoteExecutor.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/crosschain/CrosschainRemoteExecutor.sol b/contracts/crosschain/CrosschainRemoteExecutor.sol index 2b397806dc7..17eb601862a 100644 --- a/contracts/crosschain/CrosschainRemoteExecutor.sol +++ b/contracts/crosschain/CrosschainRemoteExecutor.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.26; +pragma solidity ^0.8.27; import {IERC7786GatewaySource} from "../interfaces/draft-IERC7786.sol"; import {ERC7786Recipient} from "./ERC7786Recipient.sol"; From 7e1f23d6930f384e0898803bc4a500182e09ab4e Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Tue, 3 Mar 2026 15:52:52 +0100 Subject: [PATCH 12/16] remove executor registration --- .../extensions/GovernorCrosschain.sol | 85 ------------------- .../extensions/GovernorCrosschain.test.js | 78 +++-------------- 2 files changed, 10 insertions(+), 153 deletions(-) diff --git a/contracts/governance/extensions/GovernorCrosschain.sol b/contracts/governance/extensions/GovernorCrosschain.sol index 2273ed43da9..49c7e146f25 100644 --- a/contracts/governance/extensions/GovernorCrosschain.sol +++ b/contracts/governance/extensions/GovernorCrosschain.sol @@ -6,62 +6,9 @@ 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"; -import {Bytes} from "../../utils/Bytes.sol"; -import {InteroperableAddress} from "../../utils/draft-InteroperableAddress.sol"; /// @dev Extension of {Governor} for cross-chain governance through ERC-7786 gateways and {CrosschainRemoteExecutors}. abstract contract GovernorCrosschain is Governor { - using Bytes for bytes; - using InteroperableAddress for bytes; - - struct ExecutorDetails { - address gateway; - bytes executor; - } - - mapping(bytes chain => ExecutorDetails) private _remoteExecutors; - - /** - * @dev Emitted when a new remote executor is registered. - * - * Note: the `executor` argument is a full InteroperableAddress (chain ref + address). - */ - event RemoteExecutorRegistered(address gateway, bytes executor); - - /** - * @dev Reverted when trying to register a link for a chain that is already registered. - * - * Note: the `chain` argument is a "chain-only" InteroperableAddress (empty address). - */ - error RemoteExecutorAlreadyRegistered(bytes chain); - - /** - * @dev Reverted when trying to send a crosschain instruction to a chain with no registered executor. - */ - error NoExecutorRegistered(bytes chain); - - /** - * @dev Returns the ERC-7786 gateway used for sending and receiving cross-chain messages to a given chain. - * - * Note: The `chain` parameter is a "chain-only" InteroperableAddress (empty address) and the `counterpart` returns - * the full InteroperableAddress (chain ref + address) that is on `chain`. - */ - function getRemoteExecutor( - bytes memory chain - ) public view virtual returns (address gateway, bytes memory executor) { - ExecutorDetails storage self = _remoteExecutors[chain]; - return (self.gateway, self.executor); - } - - /// @dev Send crosschain instruction to a the canonical remote executor of a given chain. - function relayCrosschain( - bytes memory chain, - Mode mode, - bytes memory executionCalldata - ) public virtual onlyGovernance { - _crosschainExecute(chain, mode, executionCalldata); - } - /// @dev Send crosschain instruction to an arbitrary remote executor via an arbitrary ERC-7786 gateway. function relayCrosschain( address gateway, @@ -72,13 +19,6 @@ abstract contract GovernorCrosschain is Governor { _crosschainExecute(gateway, executor, mode, executionCalldata); } - /// @dev Send crosschain instruction to a the canonical remote executor of a given chain. - function _crosschainExecute(bytes memory chain, Mode mode, bytes memory executionCalldata) internal virtual { - (address gateway, bytes memory executor) = getRemoteExecutor(chain); - require(gateway != address(0), NoExecutorRegistered(chain)); - _crosschainExecute(gateway, executor, mode, executionCalldata); - } - /// @dev Send crosschain instruction to an arbitrary remote executor via an arbitrary ERC-7786 gateway. function _crosschainExecute( address gateway, @@ -88,29 +28,4 @@ abstract contract GovernorCrosschain is Governor { ) internal virtual { IERC7786GatewaySource(gateway).sendMessage(executor, abi.encodePacked(mode, executionCalldata), new bytes[](0)); } - - /// @dev Register the canonical remote executor for a given chain. - function registerRemoteExecutor(address gateway, bytes memory executor) public virtual onlyGovernance { - _registerRemoteExecutor(gateway, executor, true); - } - - /** - * @dev Internal setter to change the ERC-7786 gateway and executor for a given chain. Called at construction. - * - * Note: The `executor` parameter is the full InteroperableAddress (chain ref + address). - */ - function _registerRemoteExecutor(address gateway, bytes memory executor, bool allowOverride) 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)); - - (bytes2 chainType, bytes memory chainReference, ) = executor.parseV1(); - bytes memory chain = InteroperableAddress.formatV1(chainType, chainReference, hex""); - if (allowOverride || _remoteExecutors[chain].gateway == address(0)) { - _remoteExecutors[chain] = ExecutorDetails(gateway, executor); - emit RemoteExecutorRegistered(gateway, executor); - } else { - revert RemoteExecutorAlreadyRegistered(chain); - } - } } diff --git a/test/governance/extensions/GovernorCrosschain.test.js b/test/governance/extensions/GovernorCrosschain.test.js index 0a255d573ec..c974dad954c 100644 --- a/test/governance/extensions/GovernorCrosschain.test.js +++ b/test/governance/extensions/GovernorCrosschain.test.js @@ -68,7 +68,7 @@ describe('GovernorCrosschain', function () { Object.assign(this, await loadFixture(fixture)); }); - it('execute with unregistered executor', async function () { + it('execute with executor', async function () { this.helper.setProposal( [ { @@ -93,44 +93,15 @@ describe('GovernorCrosschain', function () { await expect(this.helper.execute()).to.emit(this.receiver, 'MockFunctionCalledExtra').withArgs(this.executor, 0n); }); - it('register executor and execute with registered executor', async function () { - this.helper.setProposal( - [ - { - target: this.governor.target, - data: this.governor.interface.encodeFunctionData('registerRemoteExecutor', [ - this.gateway.target, - this.chain.toErc7930(this.executor), - ]), - }, - { - target: this.governor.target, - data: this.governor.interface.encodeFunctionData('relayCrosschain(bytes,bytes32,bytes)', [ - this.chain.erc7930, - 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.governor, 'RemoteExecutorRegistered') - .withArgs(this.gateway, this.chain.toErc7930(this.executor)) - .to.emit(this.receiver, 'MockFunctionCalledExtra') - .withArgs(this.executor, 0n); - - await expect(this.governor.getRemoteExecutor(this.chain.erc7930)).to.eventually.deep.equal([ - this.gateway.target, - this.chain.toErc7930(this.executor), - ]); + 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 () { @@ -184,33 +155,4 @@ describe('GovernorCrosschain', function () { await expect(this.executor.gateway()).to.eventually.equal(this.gateway); await expect(this.executor.controller()).to.eventually.equal(this.chain.toErc7930(newGovernor)); }); - - describe('restricted', function () { - it('relayCrosschain with unregistered executor 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('relayCrosschain with registered executor is onlyGovernance', async function () { - await expect( - this.governor.getFunction('relayCrosschain(bytes,bytes32,bytes)')( - this.chain.erc7930, - encodeMode({ callType: CALL_TYPE_SINGLE }), - encodeSingle(this.receiver, 0n, this.receiver.interface.encodeFunctionData('mockFunctionExtra')), - ), - ).to.be.revertedWithCustomError(this.governor, 'GovernorOnlyExecutor'); - }); - - it('registerRemoteExecutor is onlyGovernance', async function () { - await expect( - this.governor.registerRemoteExecutor(this.gateway, this.chain.toErc7930(this.executor)), - ).to.be.revertedWithCustomError(this.governor, 'GovernorOnlyExecutor'); - }); - }); }); From 268daab6f3527cd6d830c1e449ee0199d67835a1 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Tue, 3 Mar 2026 21:14:28 +0100 Subject: [PATCH 13/16] Update contracts/governance/extensions/GovernorCrosschain.sol Co-authored-by: Francisco Giordano --- contracts/governance/extensions/GovernorCrosschain.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/governance/extensions/GovernorCrosschain.sol b/contracts/governance/extensions/GovernorCrosschain.sol index 49c7e146f25..1b1e907deac 100644 --- a/contracts/governance/extensions/GovernorCrosschain.sol +++ b/contracts/governance/extensions/GovernorCrosschain.sol @@ -7,7 +7,7 @@ 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}. +/// @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( From fd2a401ab8aa8180e586585cf566144e305a5628 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Tue, 3 Mar 2026 21:14:55 +0100 Subject: [PATCH 14/16] Update contracts/crosschain/README.adoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ernesto GarcĂ­a --- contracts/crosschain/README.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/crosschain/README.adoc b/contracts/crosschain/README.adoc index 6103e6f30b7..966523a276d 100644 --- a/contracts/crosschain/README.adoc +++ b/contracts/crosschain/README.adoc @@ -6,7 +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. +* {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: From 6c9793e4e850eaba4b5c6aea1545afe5d1102870 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Tue, 3 Mar 2026 21:35:41 +0100 Subject: [PATCH 15/16] add changeset for CrosschainRemoteExecutor --- .changeset/floppy-symbols-burn.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/floppy-symbols-burn.md 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. From 1ed3b79e3b4d6335885d7e9b8996897482bd3c15 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Tue, 3 Mar 2026 21:36:23 +0100 Subject: [PATCH 16/16] Update contracts/crosschain/CrosschainRemoteExecutor.sol --- contracts/crosschain/CrosschainRemoteExecutor.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/crosschain/CrosschainRemoteExecutor.sol b/contracts/crosschain/CrosschainRemoteExecutor.sol index 17eb601862a..c14e6c25698 100644 --- a/contracts/crosschain/CrosschainRemoteExecutor.sol +++ b/contracts/crosschain/CrosschainRemoteExecutor.sol @@ -59,7 +59,7 @@ contract CrosschainRemoteExecutor is ERC7786Recipient { /// @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). + // supportsAttribute returns data, accounts without code would fail that test (nothing returned). IERC7786GatewaySource(gateway_).supportsAttribute(bytes4(0)); _gateway = gateway_;