-
Notifications
You must be signed in to change notification settings - Fork 12.4k
Add CrosschainRemoteExecutor and GovernorCrosschain #6272
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
d3dbb62
2786db2
5003f4e
68384a5
415f5d8
4d79c70
a727e82
cef9e44
088a054
e4d3bb9
143da1d
9e21544
9b8f660
3438517
7e1f23d
268daab
fd2a401
6c9793e
1ed3b79
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| // 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); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
ernestognw marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| bytes private _controller; | ||
|
|
||
| event CrosschainControllerSet(address gateway, bytes controller); | ||
ernestognw marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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). | ||
Amxx marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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(); | ||
ernestognw marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,220 @@ | ||
| 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('$CrosschainRemoteController'); | ||
|
|
||
| // Deploy executor | ||
| const executor = await ethers.deployContract('$CrosschainRemoteExecutor', [gateway, chain.toErc7930(controller)]); | ||
|
|
||
| // 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 }; | ||
| } | ||
|
|
||
| describe('CrosschainRemoteController & CrosschainRemoteExecutor', function () { | ||
| beforeEach(async function () { | ||
| Object.assign(this, await loadFixture(fixture)); | ||
| }); | ||
|
|
||
| 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)); | ||
| }); | ||
|
|
||
| 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')); | ||
|
|
||
| // 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); | ||
| }); | ||
|
|
||
| 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')], | ||
| ); | ||
|
|
||
| // 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') | ||
| .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')); | ||
|
|
||
| // 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); | ||
| }); | ||
|
|
||
| it('revert when mode is invalid', async 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), | ||
| ) | ||
| .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('$CrosschainRemoteController'); | ||
| }); | ||
|
|
||
| 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'); | ||
| }); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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