From 4a3394850d55ab905d60056dcf29989e30111dfd Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 28 Aug 2025 16:34:29 +0200 Subject: [PATCH 01/13] Migrate ERC7786Receiver from community --- .changeset/silent-zebras-press.md | 5 ++ contracts/crosschain/ERC7786Receiver.sol | 43 ++++++++++++++ contracts/interfaces/draft-IERC7786.sol | 2 +- .../mocks/crosschain/ERC7786GatewayMock.sol | 56 +++++++++++++++++++ .../mocks/crosschain/ERC7786ReceiverMock.sol | 28 ++++++++++ test/crosschain/ERC7786Receiver.test.js | 45 +++++++++++++++ 6 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 .changeset/silent-zebras-press.md create mode 100644 contracts/crosschain/ERC7786Receiver.sol create mode 100644 contracts/mocks/crosschain/ERC7786GatewayMock.sol create mode 100644 contracts/mocks/crosschain/ERC7786ReceiverMock.sol create mode 100644 test/crosschain/ERC7786Receiver.test.js diff --git a/.changeset/silent-zebras-press.md b/.changeset/silent-zebras-press.md new file mode 100644 index 00000000000..e72370192f2 --- /dev/null +++ b/.changeset/silent-zebras-press.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`ERC7786Receiver`: Boilerplate contract for receiving ERC-7786 crosschain messages. diff --git a/contracts/crosschain/ERC7786Receiver.sol b/contracts/crosschain/ERC7786Receiver.sol new file mode 100644 index 00000000000..a38296ea214 --- /dev/null +++ b/contracts/crosschain/ERC7786Receiver.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {IERC7786Receiver} from "../interfaces/draft-IERC7786.sol"; + +/** + * @dev Base implementation of an ERC-7786 compliant cross-chain message receiver. + * + * This abstract contract exposes the `receiveMessage` function that is used for communication with (one or multiple) + * destination gateways. This contract leaves two functions unimplemented: + * + * {_isKnownGateway}, an internal getter used to verify whether an address is recognised by the contract as a valid + * ERC-7786 destination gateway. One or multiple gateway can be supported. Note that any malicious address for which + * this function returns true would be able to impersonate any account on any other chain sending any message. + * + * {_processMessage}, the internal function that will be called with any message that has been validated. + */ +abstract contract ERC7786Receiver is IERC7786Receiver { + error ERC7786ReceiverInvalidGateway(address gateway); + + /// @inheritdoc IERC7786Receiver + function receiveMessage( + bytes32 receiveId, + bytes calldata sender, // Binary Interoperable Address + bytes calldata payload + ) public payable virtual returns (bytes4) { + require(_isKnownGateway(msg.sender), ERC7786ReceiverInvalidGateway(msg.sender)); + _processMessage(msg.sender, receiveId, sender, payload); + return IERC7786Receiver.receiveMessage.selector; + } + + /// @dev Virtual getter that returns whether an address is a valid ERC-7786 gateway. + function _isKnownGateway(address instance) internal view virtual returns (bool); + + /// @dev Virtual function that should contain the logic to execute when a cross-chain message is received. + function _processMessage( + address gateway, + bytes32 receiveId, + bytes calldata sender, + bytes calldata payload + ) internal virtual; +} diff --git a/contracts/interfaces/draft-IERC7786.sol b/contracts/interfaces/draft-IERC7786.sol index 6e15bf4cbd3..064279f8d71 100644 --- a/contracts/interfaces/draft-IERC7786.sol +++ b/contracts/interfaces/draft-IERC7786.sol @@ -15,7 +15,7 @@ interface IERC7786GatewaySource { event MessageSent( bytes32 indexed sendId, bytes sender, // Binary Interoperable Address - bytes receiver, // Binary Interoperable Address + bytes recipient, // Binary Interoperable Address bytes payload, uint256 value, bytes[] attributes diff --git a/contracts/mocks/crosschain/ERC7786GatewayMock.sol b/contracts/mocks/crosschain/ERC7786GatewayMock.sol new file mode 100644 index 00000000000..4334f2c7682 --- /dev/null +++ b/contracts/mocks/crosschain/ERC7786GatewayMock.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {IERC7786GatewaySource, IERC7786Receiver} from "../../interfaces/draft-IERC7786.sol"; +import {InteroperableAddress} from "../../utils/draft-InteroperableAddress.sol"; + +abstract contract ERC7786GatewayMock is IERC7786GatewaySource { + using InteroperableAddress for bytes; + + error InvalidDestination(); + error ReceiverError(); + + uint256 private _lastReceiveId; + + /// @inheritdoc IERC7786GatewaySource + function supportsAttribute(bytes4 /*selector*/) public view virtual returns (bool) { + return false; + } + + /// @inheritdoc IERC7786GatewaySource + function sendMessage( + bytes calldata recipient, + bytes calldata payload, + bytes[] calldata attributes + ) public payable virtual returns (bytes32 sendId) { + // attributes are not supported + if (attributes.length > 0) { + revert UnsupportedAttribute(bytes4(attributes[0])); + } + + // parse recipient + (bool success, uint256 chainid, address target) = recipient.tryParseEvmV1Calldata(); + require(success && chainid == block.chainid, InvalidDestination()); + + // perform call + bytes4 magic = IERC7786Receiver(target).receiveMessage{value: msg.value}( + bytes32(++_lastReceiveId), + InteroperableAddress.formatEvmV1(block.chainid, msg.sender), + payload + ); + require(magic == IERC7786Receiver.receiveMessage.selector, ReceiverError()); + + // emit standard event + emit MessageSent( + bytes32(0), + InteroperableAddress.formatEvmV1(block.chainid, msg.sender), + recipient, + payload, + msg.value, + attributes + ); + + return 0; + } +} diff --git a/contracts/mocks/crosschain/ERC7786ReceiverMock.sol b/contracts/mocks/crosschain/ERC7786ReceiverMock.sol new file mode 100644 index 00000000000..6355b5ef022 --- /dev/null +++ b/contracts/mocks/crosschain/ERC7786ReceiverMock.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {ERC7786Receiver} from "../../crosschain/ERC7786Receiver.sol"; + +contract ERC7786ReceiverMock is ERC7786Receiver { + address private immutable _gateway; + + event MessageReceived(address gateway, bytes32 receiveId, bytes sender, bytes payload, uint256 value); + + constructor(address gateway_) { + _gateway = gateway_; + } + + function _isKnownGateway(address instance) internal view virtual override returns (bool) { + return instance == _gateway; + } + + function _processMessage( + address gateway, + bytes32 receiveId, + bytes calldata sender, + bytes calldata payload + ) internal virtual override { + emit MessageReceived(gateway, receiveId, sender, payload, msg.value); + } +} diff --git a/test/crosschain/ERC7786Receiver.test.js b/test/crosschain/ERC7786Receiver.test.js new file mode 100644 index 00000000000..8be5bb9b797 --- /dev/null +++ b/test/crosschain/ERC7786Receiver.test.js @@ -0,0 +1,45 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { getLocalChain } = require('../helpers/chains'); +const { generators } = require('../helpers/random'); + +const value = 42n; +const payload = generators.hexBytes(128); +const attributes = []; + +async function fixture() { + const [sender, notAGateway] = await ethers.getSigners(); + const { toErc7930 } = await getLocalChain(); + + const gateway = await ethers.deployContract('$ERC7786GatewayMock'); + const receiver = await ethers.deployContract('$ERC7786ReceiverMock', [gateway]); + + return { sender, notAGateway, gateway, receiver, toErc7930 }; +} + +// NOTE: here we are only testing the receiver. Failures of the gateway itself (invalid attributes, ...) are out of scope. +describe('ERC7786Receiver', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + it('receives gateway relayed messages', async function () { + await expect( + this.gateway.connect(this.sender).sendMessage(this.toErc7930(this.receiver), payload, attributes, { value }), + ) + .to.emit(this.gateway, 'MessageSent') + .withArgs(ethers.ZeroHash, this.toErc7930(this.sender), this.toErc7930(this.receiver), payload, value, attributes) + .to.emit(this.receiver, 'MessageReceived') + .withArgs(this.gateway, ethers.toBeHex(1n, 32n), this.toErc7930(this.sender), payload, value); + }); + + it('unauthorized call', async function () { + await expect( + this.receiver.connect(this.notAGateway).receiveMessage(ethers.ZeroHash, this.toErc7930(this.sender), payload), + ) + .to.be.revertedWithCustomError(this.receiver, 'ERC7786ReceiverInvalidGateway') + .withArgs(this.notAGateway); + }); +}); From fdb2e77a7a43e700f7cc4b0b7c83b832cf3ef7a4 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 28 Aug 2025 16:38:43 +0200 Subject: [PATCH 02/13] add documentation --- contracts/crosschain/README.adoc | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 contracts/crosschain/README.adoc diff --git a/contracts/crosschain/README.adoc b/contracts/crosschain/README.adoc new file mode 100644 index 00000000000..56d75ee4145 --- /dev/null +++ b/contracts/crosschain/README.adoc @@ -0,0 +1,12 @@ += Cross chain interoperability + +[.readme-notice] +NOTE: This document is better viewed at https://docs.openzeppelin.com/contracts/api/crosschain + +This directory provides ways to contracts related to the sending and receiving of crosschain messages following the ERC-7786 standard. + +- {ERC7786Receiver} is a boilerplate contract for receiving crosschain messages through a ERC-7786 gateway. + +== Helpers + +{{ERC7786Receiver}} From 365163822428a57433d47d5c01f60f8e33acbbcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ernesto=20Garc=C3=ADa?= Date: Thu, 28 Aug 2025 05:50:05 -1000 Subject: [PATCH 03/13] Apply suggestion from @ernestognw --- 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 56d75ee4145..fc36ebe0536 100644 --- a/contracts/crosschain/README.adoc +++ b/contracts/crosschain/README.adoc @@ -3,7 +3,7 @@ [.readme-notice] NOTE: This document is better viewed at https://docs.openzeppelin.com/contracts/api/crosschain -This directory provides ways to contracts related to the sending and receiving of crosschain messages following the ERC-7786 standard. +This directory contains contracts for sending and receiving cross chain messages that follows the ERC-7786 standard. - {ERC7786Receiver} is a boilerplate contract for receiving crosschain messages through a ERC-7786 gateway. From f2abf9fccda178e22049aadf5833435b96beebf5 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 28 Aug 2025 18:49:44 +0200 Subject: [PATCH 04/13] Update .changeset/silent-zebras-press.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ernesto García --- .changeset/silent-zebras-press.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/silent-zebras-press.md b/.changeset/silent-zebras-press.md index e72370192f2..638a76f1d74 100644 --- a/.changeset/silent-zebras-press.md +++ b/.changeset/silent-zebras-press.md @@ -2,4 +2,4 @@ 'openzeppelin-solidity': minor --- -`ERC7786Receiver`: Boilerplate contract for receiving ERC-7786 crosschain messages. +`ERC7786Receiver`: ERC-7786 generic crosschain message receiver contract. From ab9643fa3007794de4db09a802a410bd5ffec2c9 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 28 Aug 2025 18:50:26 +0200 Subject: [PATCH 05/13] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ernesto García --- contracts/crosschain/ERC7786Receiver.sol | 6 +++--- contracts/crosschain/README.adoc | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/contracts/crosschain/ERC7786Receiver.sol b/contracts/crosschain/ERC7786Receiver.sol index a38296ea214..53e3ae3ebb6 100644 --- a/contracts/crosschain/ERC7786Receiver.sol +++ b/contracts/crosschain/ERC7786Receiver.sol @@ -10,13 +10,13 @@ import {IERC7786Receiver} from "../interfaces/draft-IERC7786.sol"; * This abstract contract exposes the `receiveMessage` function that is used for communication with (one or multiple) * destination gateways. This contract leaves two functions unimplemented: * - * {_isKnownGateway}, an internal getter used to verify whether an address is recognised by the contract as a valid + * * {_isKnownGateway}, an internal getter used to verify whether an address is recognised by the contract as a valid * ERC-7786 destination gateway. One or multiple gateway can be supported. Note that any malicious address for which * this function returns true would be able to impersonate any account on any other chain sending any message. * - * {_processMessage}, the internal function that will be called with any message that has been validated. + * * {_processMessage}, the internal function that will be called with any message that has been validated. */ -abstract contract ERC7786Receiver is IERC7786Receiver { +abstract contract ERC7786Recipient is IERC7786Recipient { error ERC7786ReceiverInvalidGateway(address gateway); /// @inheritdoc IERC7786Receiver diff --git a/contracts/crosschain/README.adoc b/contracts/crosschain/README.adoc index fc36ebe0536..5a2279bc13b 100644 --- a/contracts/crosschain/README.adoc +++ b/contracts/crosschain/README.adoc @@ -5,7 +5,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. -- {ERC7786Receiver} is a boilerplate contract for receiving crosschain messages through a ERC-7786 gateway. +- {ERC7786Receiver}: generic ERC-7786 crosschain contract that receives messages from a trusted gateway == Helpers From d3e2223e1f17f29bf993aa2aac567f46eb06e3ed Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 28 Aug 2025 18:53:56 +0200 Subject: [PATCH 06/13] rename ERC7786Receiver into ERC7786Recipient --- .../{ERC7786Receiver.sol => ERC7786Recipient.sol} | 10 +++++----- contracts/crosschain/README.adoc | 4 ++-- contracts/interfaces/draft-IERC7786.sol | 2 +- contracts/mocks/crosschain/ERC7786GatewayMock.sol | 6 +++--- ...RC7786ReceiverMock.sol => ERC7786RecipientMock.sol} | 4 ++-- ...RC7786Receiver.test.js => ERC7786Recipient.test.js} | 6 +++--- 6 files changed, 16 insertions(+), 16 deletions(-) rename contracts/crosschain/{ERC7786Receiver.sol => ERC7786Recipient.sol} (83%) rename contracts/mocks/crosschain/{ERC7786ReceiverMock.sol => ERC7786RecipientMock.sol} (84%) rename test/crosschain/{ERC7786Receiver.test.js => ERC7786Recipient.test.js} (88%) diff --git a/contracts/crosschain/ERC7786Receiver.sol b/contracts/crosschain/ERC7786Recipient.sol similarity index 83% rename from contracts/crosschain/ERC7786Receiver.sol rename to contracts/crosschain/ERC7786Recipient.sol index 53e3ae3ebb6..28603fc81a6 100644 --- a/contracts/crosschain/ERC7786Receiver.sol +++ b/contracts/crosschain/ERC7786Recipient.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.27; -import {IERC7786Receiver} from "../interfaces/draft-IERC7786.sol"; +import {IERC7786Recipient} from "../interfaces/draft-IERC7786.sol"; /** * @dev Base implementation of an ERC-7786 compliant cross-chain message receiver. @@ -17,17 +17,17 @@ import {IERC7786Receiver} from "../interfaces/draft-IERC7786.sol"; * * {_processMessage}, the internal function that will be called with any message that has been validated. */ abstract contract ERC7786Recipient is IERC7786Recipient { - error ERC7786ReceiverInvalidGateway(address gateway); + error ERC7786RecipientInvalidGateway(address gateway); - /// @inheritdoc IERC7786Receiver + /// @inheritdoc IERC7786Recipient function receiveMessage( bytes32 receiveId, bytes calldata sender, // Binary Interoperable Address bytes calldata payload ) public payable virtual returns (bytes4) { - require(_isKnownGateway(msg.sender), ERC7786ReceiverInvalidGateway(msg.sender)); + require(_isKnownGateway(msg.sender), ERC7786RecipientInvalidGateway(msg.sender)); _processMessage(msg.sender, receiveId, sender, payload); - return IERC7786Receiver.receiveMessage.selector; + return IERC7786Recipient.receiveMessage.selector; } /// @dev Virtual getter that returns whether an address is a valid ERC-7786 gateway. diff --git a/contracts/crosschain/README.adoc b/contracts/crosschain/README.adoc index 5a2279bc13b..308509cf9d6 100644 --- a/contracts/crosschain/README.adoc +++ b/contracts/crosschain/README.adoc @@ -5,8 +5,8 @@ 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. -- {ERC7786Receiver}: generic ERC-7786 crosschain contract that receives messages from a trusted gateway +- {IERC7786Recipient}: generic ERC-7786 crosschain contract that receives messages from a trusted gateway == Helpers -{{ERC7786Receiver}} +{{IERC7786Recipient}} diff --git a/contracts/interfaces/draft-IERC7786.sol b/contracts/interfaces/draft-IERC7786.sol index 064279f8d71..571633a5f3a 100644 --- a/contracts/interfaces/draft-IERC7786.sol +++ b/contracts/interfaces/draft-IERC7786.sol @@ -49,7 +49,7 @@ interface IERC7786GatewaySource { * * See ERC-7786 for more details */ -interface IERC7786Receiver { +interface IERC7786Recipient { /** * @dev Endpoint for receiving cross-chain message. * diff --git a/contracts/mocks/crosschain/ERC7786GatewayMock.sol b/contracts/mocks/crosschain/ERC7786GatewayMock.sol index 4334f2c7682..aa320be9590 100644 --- a/contracts/mocks/crosschain/ERC7786GatewayMock.sol +++ b/contracts/mocks/crosschain/ERC7786GatewayMock.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.27; -import {IERC7786GatewaySource, IERC7786Receiver} from "../../interfaces/draft-IERC7786.sol"; +import {IERC7786GatewaySource, IERC7786Recipient} from "../../interfaces/draft-IERC7786.sol"; import {InteroperableAddress} from "../../utils/draft-InteroperableAddress.sol"; abstract contract ERC7786GatewayMock is IERC7786GatewaySource { @@ -34,12 +34,12 @@ abstract contract ERC7786GatewayMock is IERC7786GatewaySource { require(success && chainid == block.chainid, InvalidDestination()); // perform call - bytes4 magic = IERC7786Receiver(target).receiveMessage{value: msg.value}( + bytes4 magic = IERC7786Recipient(target).receiveMessage{value: msg.value}( bytes32(++_lastReceiveId), InteroperableAddress.formatEvmV1(block.chainid, msg.sender), payload ); - require(magic == IERC7786Receiver.receiveMessage.selector, ReceiverError()); + require(magic == IERC7786Recipient.receiveMessage.selector, ReceiverError()); // emit standard event emit MessageSent( diff --git a/contracts/mocks/crosschain/ERC7786ReceiverMock.sol b/contracts/mocks/crosschain/ERC7786RecipientMock.sol similarity index 84% rename from contracts/mocks/crosschain/ERC7786ReceiverMock.sol rename to contracts/mocks/crosschain/ERC7786RecipientMock.sol index 6355b5ef022..f553d0913a5 100644 --- a/contracts/mocks/crosschain/ERC7786ReceiverMock.sol +++ b/contracts/mocks/crosschain/ERC7786RecipientMock.sol @@ -2,9 +2,9 @@ pragma solidity ^0.8.27; -import {ERC7786Receiver} from "../../crosschain/ERC7786Receiver.sol"; +import {ERC7786Recipient} from "../../crosschain/ERC7786Recipient.sol"; -contract ERC7786ReceiverMock is ERC7786Receiver { +contract ERC7786RecipientMock is ERC7786Recipient { address private immutable _gateway; event MessageReceived(address gateway, bytes32 receiveId, bytes sender, bytes payload, uint256 value); diff --git a/test/crosschain/ERC7786Receiver.test.js b/test/crosschain/ERC7786Recipient.test.js similarity index 88% rename from test/crosschain/ERC7786Receiver.test.js rename to test/crosschain/ERC7786Recipient.test.js index 8be5bb9b797..66156f78247 100644 --- a/test/crosschain/ERC7786Receiver.test.js +++ b/test/crosschain/ERC7786Recipient.test.js @@ -14,13 +14,13 @@ async function fixture() { const { toErc7930 } = await getLocalChain(); const gateway = await ethers.deployContract('$ERC7786GatewayMock'); - const receiver = await ethers.deployContract('$ERC7786ReceiverMock', [gateway]); + const receiver = await ethers.deployContract('$ERC7786RecipientMock', [gateway]); return { sender, notAGateway, gateway, receiver, toErc7930 }; } // NOTE: here we are only testing the receiver. Failures of the gateway itself (invalid attributes, ...) are out of scope. -describe('ERC7786Receiver', function () { +describe('ERC7786Recipient', function () { beforeEach(async function () { Object.assign(this, await loadFixture(fixture)); }); @@ -39,7 +39,7 @@ describe('ERC7786Receiver', function () { await expect( this.receiver.connect(this.notAGateway).receiveMessage(ethers.ZeroHash, this.toErc7930(this.sender), payload), ) - .to.be.revertedWithCustomError(this.receiver, 'ERC7786ReceiverInvalidGateway') + .to.be.revertedWithCustomError(this.receiver, 'ERC7786RecipientInvalidGateway') .withArgs(this.notAGateway); }); }); From 9b015d3d039a1e988c2ddc0df9a3a7cd85275434 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Fri, 29 Aug 2025 15:17:59 +0200 Subject: [PATCH 07/13] Update .changeset/silent-zebras-press.md --- .changeset/silent-zebras-press.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/silent-zebras-press.md b/.changeset/silent-zebras-press.md index 638a76f1d74..18db1470ef9 100644 --- a/.changeset/silent-zebras-press.md +++ b/.changeset/silent-zebras-press.md @@ -2,4 +2,4 @@ 'openzeppelin-solidity': minor --- -`ERC7786Receiver`: ERC-7786 generic crosschain message receiver contract. +`ERC7786Recipient`: Generic ERC-7786 cross-chain message recipient contract. From c0c421cdbd07411b31638e11800f0e7be458c4c7 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 4 Sep 2025 10:01:42 +0200 Subject: [PATCH 08/13] refactor permission --- contracts/crosschain/ERC7786Recipient.sol | 14 +++++++------- .../mocks/crosschain/ERC7786RecipientMock.sol | 5 ++++- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/contracts/crosschain/ERC7786Recipient.sol b/contracts/crosschain/ERC7786Recipient.sol index 28603fc81a6..e2685681fac 100644 --- a/contracts/crosschain/ERC7786Recipient.sol +++ b/contracts/crosschain/ERC7786Recipient.sol @@ -10,9 +10,9 @@ import {IERC7786Recipient} from "../interfaces/draft-IERC7786.sol"; * This abstract contract exposes the `receiveMessage` function that is used for communication with (one or multiple) * destination gateways. This contract leaves two functions unimplemented: * - * * {_isKnownGateway}, an internal getter used to verify whether an address is recognised by the contract as a valid - * ERC-7786 destination gateway. One or multiple gateway can be supported. Note that any malicious address for which - * this function returns true would be able to impersonate any account on any other chain sending any message. + * * {_isAuthorizedGateway}, an internal getter used to verify whether an address is recognised by the contract as a + * valid ERC-7786 destination gateway. One or multiple gateway can be supported. Note that any malicious address for + * which this function returns true would be able to impersonate any account on any other chain sending any message. * * * {_processMessage}, the internal function that will be called with any message that has been validated. */ @@ -24,14 +24,14 @@ abstract contract ERC7786Recipient is IERC7786Recipient { bytes32 receiveId, bytes calldata sender, // Binary Interoperable Address bytes calldata payload - ) public payable virtual returns (bytes4) { - require(_isKnownGateway(msg.sender), ERC7786RecipientInvalidGateway(msg.sender)); + ) external payable returns (bytes4) { + require(_isAuthorizedGateway(msg.sender, sender), ERC7786RecipientInvalidGateway(msg.sender)); _processMessage(msg.sender, receiveId, sender, payload); return IERC7786Recipient.receiveMessage.selector; } - /// @dev Virtual getter that returns whether an address is a valid ERC-7786 gateway. - function _isKnownGateway(address instance) internal view virtual returns (bool); + /// @dev Virtual getter that returns whether an address is a valid ERC-7786 gateway for a given sender. + function _isAuthorizedGateway(address instance, bytes memory sender) internal view virtual returns (bool); /// @dev Virtual function that should contain the logic to execute when a cross-chain message is received. function _processMessage( diff --git a/contracts/mocks/crosschain/ERC7786RecipientMock.sol b/contracts/mocks/crosschain/ERC7786RecipientMock.sol index f553d0913a5..ce604abf310 100644 --- a/contracts/mocks/crosschain/ERC7786RecipientMock.sol +++ b/contracts/mocks/crosschain/ERC7786RecipientMock.sol @@ -13,7 +13,10 @@ contract ERC7786RecipientMock is ERC7786Recipient { _gateway = gateway_; } - function _isKnownGateway(address instance) internal view virtual override returns (bool) { + function _isAuthorizedGateway( + address instance, + bytes memory /*sender*/ + ) internal view virtual override returns (bool) { return instance == _gateway; } From c938d685f85ced3e5a67fdbc6156fe97231cf9aa Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 4 Sep 2025 10:08:34 +0200 Subject: [PATCH 09/13] calldata --- contracts/crosschain/ERC7786Recipient.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/crosschain/ERC7786Recipient.sol b/contracts/crosschain/ERC7786Recipient.sol index e2685681fac..010e15213a8 100644 --- a/contracts/crosschain/ERC7786Recipient.sol +++ b/contracts/crosschain/ERC7786Recipient.sol @@ -31,7 +31,7 @@ abstract contract ERC7786Recipient is IERC7786Recipient { } /// @dev Virtual getter that returns whether an address is a valid ERC-7786 gateway for a given sender. - function _isAuthorizedGateway(address instance, bytes memory sender) internal view virtual returns (bool); + function _isAuthorizedGateway(address instance, bytes calldata sender) internal view virtual returns (bool); /// @dev Virtual function that should contain the logic to execute when a cross-chain message is received. function _processMessage( From d39c18e432901dc5289c833b90f34ee149906dcd Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 4 Sep 2025 10:32:11 +0200 Subject: [PATCH 10/13] prevent message replay at the receiver level --- contracts/crosschain/ERC7786Recipient.sol | 16 +++++++++++ .../mocks/crosschain/ERC7786RecipientMock.sol | 2 +- test/crosschain/ERC7786Recipient.test.js | 28 +++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/contracts/crosschain/ERC7786Recipient.sol b/contracts/crosschain/ERC7786Recipient.sol index 010e15213a8..e9cfb0e98fa 100644 --- a/contracts/crosschain/ERC7786Recipient.sol +++ b/contracts/crosschain/ERC7786Recipient.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.27; import {IERC7786Recipient} from "../interfaces/draft-IERC7786.sol"; +import {BitMaps} from "../utils/structs/BitMaps.sol"; /** * @dev Base implementation of an ERC-7786 compliant cross-chain message receiver. @@ -15,9 +16,17 @@ import {IERC7786Recipient} from "../interfaces/draft-IERC7786.sol"; * which this function returns true would be able to impersonate any account on any other chain sending any message. * * * {_processMessage}, the internal function that will be called with any message that has been validated. + * + * This contract implements replay protection, manning that if two messages are received from the same gateway with the + * same `receiveId`, then the second one will NOT be executed, regardless of the result of {_isAuthorizedGateway}. */ abstract contract ERC7786Recipient is IERC7786Recipient { + using BitMaps for BitMaps.BitMap; + + mapping(address gateway => BitMaps.BitMap) private _received; + error ERC7786RecipientInvalidGateway(address gateway); + error ERC7786RecipientMessageAlreadyProcessed(address gateway, bytes32 receiveId); /// @inheritdoc IERC7786Recipient function receiveMessage( @@ -26,7 +35,14 @@ abstract contract ERC7786Recipient is IERC7786Recipient { bytes calldata payload ) external payable returns (bytes4) { require(_isAuthorizedGateway(msg.sender, sender), ERC7786RecipientInvalidGateway(msg.sender)); + require( + !_received[msg.sender].get(uint256(receiveId)), + ERC7786RecipientMessageAlreadyProcessed(msg.sender, receiveId) + ); + _received[msg.sender].set(uint256(receiveId)); + _processMessage(msg.sender, receiveId, sender, payload); + return IERC7786Recipient.receiveMessage.selector; } diff --git a/contracts/mocks/crosschain/ERC7786RecipientMock.sol b/contracts/mocks/crosschain/ERC7786RecipientMock.sol index ce604abf310..fbbfaf898a9 100644 --- a/contracts/mocks/crosschain/ERC7786RecipientMock.sol +++ b/contracts/mocks/crosschain/ERC7786RecipientMock.sol @@ -15,7 +15,7 @@ contract ERC7786RecipientMock is ERC7786Recipient { function _isAuthorizedGateway( address instance, - bytes memory /*sender*/ + bytes calldata /*sender*/ ) internal view virtual override returns (bool) { return instance == _gateway; } diff --git a/test/crosschain/ERC7786Recipient.test.js b/test/crosschain/ERC7786Recipient.test.js index 66156f78247..761c718dfc1 100644 --- a/test/crosschain/ERC7786Recipient.test.js +++ b/test/crosschain/ERC7786Recipient.test.js @@ -3,6 +3,7 @@ const { expect } = require('chai'); const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); const { getLocalChain } = require('../helpers/chains'); +const { impersonate } = require('../helpers/account'); const { generators } = require('../helpers/random'); const value = 42n; @@ -35,6 +36,33 @@ describe('ERC7786Recipient', function () { .withArgs(this.gateway, ethers.toBeHex(1n, 32n), this.toErc7930(this.sender), payload, value); }); + it('receive multiple similar messages (with different receiveIds)', async function () { + for (let i = 1n; i < 5n; ++i) { + await expect( + this.gateway.connect(this.sender).sendMessage(this.toErc7930(this.receiver), payload, attributes, { value }), + ) + .to.emit(this.receiver, 'MessageReceived') + .withArgs(this.gateway, ethers.toBeHex(i, 32n), this.toErc7930(this.sender), payload, value); + } + }); + + it('multiple use of the same receiveId', async function () { + const gatewayAsEOA = await impersonate(this.gateway.target); + const receiveId = ethers.toBeHex(1n, 32n); + + await expect( + this.receiver.connect(gatewayAsEOA).receiveMessage(receiveId, this.toErc7930(this.sender), payload, { value }), + ) + .to.emit(this.receiver, 'MessageReceived') + .withArgs(this.gateway, receiveId, this.toErc7930(this.sender), payload, value); + + await expect( + this.receiver.connect(gatewayAsEOA).receiveMessage(receiveId, this.toErc7930(this.sender), payload, { value }), + ) + .to.be.revertedWithCustomError(this.receiver, 'ERC7786RecipientMessageAlreadyProcessed') + .withArgs(this.gateway, receiveId); + }); + it('unauthorized call', async function () { await expect( this.receiver.connect(this.notAGateway).receiveMessage(ethers.ZeroHash, this.toErc7930(this.sender), payload), From 9f63e4341de5be181ad40971522034e72e6aa928 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 4 Sep 2025 22:13:21 +0200 Subject: [PATCH 11/13] change custom error --- contracts/crosschain/ERC7786Recipient.sol | 16 ++++++++++------ test/crosschain/ERC7786Recipient.test.js | 4 ++-- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/contracts/crosschain/ERC7786Recipient.sol b/contracts/crosschain/ERC7786Recipient.sol index e9cfb0e98fa..667af436f6a 100644 --- a/contracts/crosschain/ERC7786Recipient.sol +++ b/contracts/crosschain/ERC7786Recipient.sol @@ -25,7 +25,7 @@ abstract contract ERC7786Recipient is IERC7786Recipient { mapping(address gateway => BitMaps.BitMap) private _received; - error ERC7786RecipientInvalidGateway(address gateway); + error ERC7786RecipientUnauthorizedGateway(address gateway, bytes sender); error ERC7786RecipientMessageAlreadyProcessed(address gateway, bytes32 receiveId); /// @inheritdoc IERC7786Recipient @@ -34,11 +34,15 @@ abstract contract ERC7786Recipient is IERC7786Recipient { bytes calldata sender, // Binary Interoperable Address bytes calldata payload ) external payable returns (bytes4) { - require(_isAuthorizedGateway(msg.sender, sender), ERC7786RecipientInvalidGateway(msg.sender)); - require( - !_received[msg.sender].get(uint256(receiveId)), - ERC7786RecipientMessageAlreadyProcessed(msg.sender, receiveId) - ); + // Check authorization + if (!_isAuthorizedGateway(msg.sender, sender)) { + revert ERC7786RecipientUnauthorizedGateway(msg.sender, sender); + } + + // Prevent duplicate execution + if (_received[msg.sender].get(uint256(receiveId))) { + revert ERC7786RecipientMessageAlreadyProcessed(msg.sender, receiveId); + } _received[msg.sender].set(uint256(receiveId)); _processMessage(msg.sender, receiveId, sender, payload); diff --git a/test/crosschain/ERC7786Recipient.test.js b/test/crosschain/ERC7786Recipient.test.js index 761c718dfc1..c8f651743b0 100644 --- a/test/crosschain/ERC7786Recipient.test.js +++ b/test/crosschain/ERC7786Recipient.test.js @@ -67,7 +67,7 @@ describe('ERC7786Recipient', function () { await expect( this.receiver.connect(this.notAGateway).receiveMessage(ethers.ZeroHash, this.toErc7930(this.sender), payload), ) - .to.be.revertedWithCustomError(this.receiver, 'ERC7786RecipientInvalidGateway') - .withArgs(this.notAGateway); + .to.be.revertedWithCustomError(this.receiver, 'ERC7786RecipientUnauthorizedGateway') + .withArgs(this.notAGateway, this.toErc7930(this.sender)); }); }); From 7be8e78ecf88925dafe8bf02546e125010c61f6d Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Sun, 7 Sep 2025 15:59:14 +0200 Subject: [PATCH 12/13] update --- contracts/crosschain/ERC7786Recipient.sol | 4 ++-- contracts/crosschain/README.adoc | 4 ++-- contracts/interfaces/README.adoc | 7 +++++-- contracts/mocks/crosschain/ERC7786RecipientMock.sol | 4 ++-- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/contracts/crosschain/ERC7786Recipient.sol b/contracts/crosschain/ERC7786Recipient.sol index 667af436f6a..acb88aab52e 100644 --- a/contracts/crosschain/ERC7786Recipient.sol +++ b/contracts/crosschain/ERC7786Recipient.sol @@ -17,7 +17,7 @@ import {BitMaps} from "../utils/structs/BitMaps.sol"; * * * {_processMessage}, the internal function that will be called with any message that has been validated. * - * This contract implements replay protection, manning that if two messages are received from the same gateway with the + * This contract implements replay protection, meaning that if two messages are received from the same gateway with the * same `receiveId`, then the second one will NOT be executed, regardless of the result of {_isAuthorizedGateway}. */ abstract contract ERC7786Recipient is IERC7786Recipient { @@ -51,7 +51,7 @@ abstract contract ERC7786Recipient is IERC7786Recipient { } /// @dev Virtual getter that returns whether an address is a valid ERC-7786 gateway for a given sender. - function _isAuthorizedGateway(address instance, bytes calldata sender) internal view virtual returns (bool); + function _isAuthorizedGateway(address gateway, bytes calldata sender) internal view virtual returns (bool); /// @dev Virtual function that should contain the logic to execute when a cross-chain message is received. function _processMessage( diff --git a/contracts/crosschain/README.adoc b/contracts/crosschain/README.adoc index 308509cf9d6..e7128b19f7b 100644 --- a/contracts/crosschain/README.adoc +++ b/contracts/crosschain/README.adoc @@ -5,8 +5,8 @@ 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. -- {IERC7786Recipient}: generic ERC-7786 crosschain contract that receives messages from a trusted gateway +- {ERC7786Recipient}: generic ERC-7786 crosschain contract that receives messages from a trusted gateway == Helpers -{{IERC7786Recipient}} +{{ERC7786Recipient}} diff --git a/contracts/interfaces/README.adoc b/contracts/interfaces/README.adoc index 42b10f89aa9..9a31408c56a 100644 --- a/contracts/interfaces/README.adoc +++ b/contracts/interfaces/README.adoc @@ -46,7 +46,8 @@ are useful to interact with third party contracts that implement them. - {IERC6909TokenSupply} - {IERC7674} - {IERC7751} -- {IERC7786} +- {IERC7786GatewaySource} +- {IERC7786Recipient} - {IERC7802} == Detailed ABI @@ -103,6 +104,8 @@ are useful to interact with third party contracts that implement them. {{IERC7751}} -{{IERC7786}} +{{IERC7786GatewaySource}} + +{{IERC7786Recipient}} {{IERC7802}} diff --git a/contracts/mocks/crosschain/ERC7786RecipientMock.sol b/contracts/mocks/crosschain/ERC7786RecipientMock.sol index fbbfaf898a9..b038ec53ba9 100644 --- a/contracts/mocks/crosschain/ERC7786RecipientMock.sol +++ b/contracts/mocks/crosschain/ERC7786RecipientMock.sol @@ -14,10 +14,10 @@ contract ERC7786RecipientMock is ERC7786Recipient { } function _isAuthorizedGateway( - address instance, + address gateway, bytes calldata /*sender*/ ) internal view virtual override returns (bool) { - return instance == _gateway; + return gateway == _gateway; } function _processMessage( From 4a12837f4bc8ee43279bdd2d59958f7147e71f71 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Tue, 9 Sep 2025 13:27:50 +0200 Subject: [PATCH 13/13] add details about sender in _isAuthorizedGateway --- contracts/crosschain/ERC7786Recipient.sol | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/contracts/crosschain/ERC7786Recipient.sol b/contracts/crosschain/ERC7786Recipient.sol index acb88aab52e..70f2c8d4a88 100644 --- a/contracts/crosschain/ERC7786Recipient.sol +++ b/contracts/crosschain/ERC7786Recipient.sol @@ -50,7 +50,13 @@ abstract contract ERC7786Recipient is IERC7786Recipient { return IERC7786Recipient.receiveMessage.selector; } - /// @dev Virtual getter that returns whether an address is a valid ERC-7786 gateway for a given sender. + /** + * @dev Virtual getter that returns whether an address is a valid ERC-7786 gateway for a given sender. + * + * The `sender` parameter is an interoperable address that include the source chain. The chain part can be + * extracted using the {InteroperableAddress} library to selectively authorize gateways based on the origin chain + * of a message. + */ function _isAuthorizedGateway(address gateway, bytes calldata sender) internal view virtual returns (bool); /// @dev Virtual function that should contain the logic to execute when a cross-chain message is received.