From 4a3394850d55ab905d60056dcf29989e30111dfd Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 28 Aug 2025 16:34:29 +0200 Subject: [PATCH 01/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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 b486a812c325839882b87b3ecc002e6bc2259adf Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Fri, 29 Aug 2025 18:14:11 +0200 Subject: [PATCH 08/19] Add CrosschainBridgeERC20, CrosschainBridgeERC20Custodial, CrosschainBridgeERC20Bridgeable and ERC20Crosschain --- .../bridges/CrosschainBridgeERC20.sol | 163 ++++++++++++++ .../CrosschainBridgeERC20Bridgeable.sol | 36 ++++ .../CrosschainBridgeERC20Custodial.sol | 38 ++++ contracts/mocks/token/ERC20BridgeableMock.sol | 6 +- .../ERC20/extensions/ERC20Crosschain.sol | 34 +++ test/crosschain/CrosschainBridgeERC20.test.js | 204 ++++++++++++++++++ test/helpers/account.js | 10 +- 7 files changed, 486 insertions(+), 5 deletions(-) create mode 100644 contracts/crosschain/bridges/CrosschainBridgeERC20.sol create mode 100644 contracts/crosschain/bridges/CrosschainBridgeERC20Bridgeable.sol create mode 100644 contracts/crosschain/bridges/CrosschainBridgeERC20Custodial.sol create mode 100644 contracts/token/ERC20/extensions/ERC20Crosschain.sol create mode 100644 test/crosschain/CrosschainBridgeERC20.test.js diff --git a/contracts/crosschain/bridges/CrosschainBridgeERC20.sol b/contracts/crosschain/bridges/CrosschainBridgeERC20.sol new file mode 100644 index 00000000000..81f52421d40 --- /dev/null +++ b/contracts/crosschain/bridges/CrosschainBridgeERC20.sol @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {ERC7786Recipient} from "../ERC7786Recipient.sol"; +import {IERC7786GatewaySource} from "../../interfaces/draft-IERC7786.sol"; +import {InteroperableAddress} from "../../utils/draft-InteroperableAddress.sol"; +import {BitMaps} from "../../utils/structs/BitMaps.sol"; +import {Bytes} from "../../utils/Bytes.sol"; + +/** + * @dev Base contract for bridging ERC-20 between chains using an ERC-7786 gateway. + * + * In order to use this contract, two function must be implemented to link it to the token: + * * {lock}: called when a crosschain transfer is going out. Must take the sender tokens or revert. + * * {unlock}: called when a crosschain transfer is coming it. Must give tokens to the receiver. + * + * This base contract is used by the {CrosschainBridgeERC20Custodial}, which interfaces with legacy ERC-20 tokens. + * It is also used by the {ERC20Crosschain} extension, which embeds the bridge logic directly in the token contract. + */ +abstract contract CrosschainBridgeERC20 is ERC7786Recipient { + using BitMaps for BitMaps.BitMap; + using InteroperableAddress for bytes; + + address private _gateway; + mapping(bytes chain => bytes) private _remoteTokens; + mapping(address gateway => BitMaps.BitMap) private _received; + + event GatewayChange(address oldGateway, address newGateway); + event RemoteTokenRegistered(bytes remote); + event CrossChainTransferSent(bytes32 indexed sendId, address indexed from, bytes to, uint256 amount); + event CrossChainTransferReceived(bytes32 indexed receiveId, bytes from, address indexed to, uint256 amount); + + error InvalidGateway(); + error InvalidSender(bytes sender); + error MessageAlreadyProcessed(address gateway, bytes32 receiveId); + error RemoteAlreadyRegistered(bytes remote); + + constructor(address initialGateway, bytes[] memory remoteTokens) { + _setGateway(initialGateway); + for (uint256 i = 0; i < remoteTokens.length; ++i) { + _registerRemote(remoteTokens[i], false); + } + } + + /// @dev Returns the ERC-7786 gateway used for sending and receiving cross-chain messages + function gateway() public view virtual returns (address) { + return _gateway; + } + + /// @dev Returns the interoperable address of the corresponding token on a given chain + function remote(bytes memory chain) public view virtual returns (bytes memory) { + return _remoteTokens[chain]; + } + + /// @dev Internal setter to change the ERC-7786 gateway. Called at construction. + function _setGateway(address newGateway) internal virtual { + // Sanity check, this should revert if newGateway is not an ERC-7786 implementation. Note that since + // supportsAttribute returns data, an EOA would fail that test (nothing returned). + IERC7786GatewaySource(newGateway).supportsAttribute(bytes4(0)); + + address oldGateway = _gateway; + _gateway = newGateway; + + emit GatewayChange(oldGateway, newGateway); + } + + /// @dev Internal setter to change the ERC-7786 gateway. Called at construction. + function _registerRemote(bytes memory remoteToken, bool allowOverride) internal virtual { + (bytes memory chain, ) = _extractChain(remoteToken); + if (allowOverride || _remoteTokens[chain].length == 0) { + _remoteTokens[chain] = remoteToken; + emit RemoteTokenRegistered(remoteToken); + } else { + revert RemoteAlreadyRegistered(_remoteTokens[chain]); + } + } + + /** + * @dev Transfer `amount` tokens to a crosschain receiver. + * + * This is a variant of {crosschainTransfer-bytes-uint256-bytes[]} with an empty attribute list. + * + * NOTE: This function is not virtual and should not be overriden. Consider overriding + * {crosschainTransfer-bytes-uint256-bytes[]} instead. + */ + function crosschainTransfer(bytes memory to, uint256 amount) public returns (bytes32) { + return crosschainTransfer(to, amount, new bytes[](0)); + } + + /// @dev Transfer `amount` tokens to a crosschain receiver. + function crosschainTransfer( + bytes memory to, + uint256 amount, + bytes[] memory attributes + ) public virtual returns (bytes32) { + return _crosschainTransfer(msg.sender, to, amount, attributes); + } + + /// @dev Internal crosschain transfer function. + function _crosschainTransfer( + address from, + bytes memory to, + uint256 amount, + bytes[] memory attributes + ) internal virtual returns (bytes32) { + _lock(from, amount); + + (bytes memory chain, bytes memory addr) = _extractChain(to); + bytes32 sendId = IERC7786GatewaySource(gateway()).sendMessage( + remote(chain), + abi.encode(InteroperableAddress.formatEvmV1(block.chainid, from), addr, amount), + attributes + ); + + emit CrossChainTransferSent(sendId, from, to, amount); + + return sendId; + } + + /// @inheritdoc ERC7786Recipient + function _isKnownGateway(address instance) internal view virtual override returns (bool) { + return instance == gateway(); + } + + /// @inheritdoc ERC7786Recipient + function _processMessage( + address gateway_, + bytes32 receiveId, + bytes calldata sender, + bytes calldata payload + ) internal virtual override { + // Check the sender is the remiote for that chain + (bytes memory chain, ) = _extractChain(sender); + require(Bytes.equal(remote(chain), sender), InvalidSender(sender)); + + // Check the message was not processed yet + require(!_received[gateway_].get(uint256(receiveId)), MessageAlreadyProcessed(gateway_, receiveId)); + _received[gateway_].set(uint256(receiveId)); + + // split payload + (bytes memory from, bytes memory toBinary, uint256 amount) = abi.decode(payload, (bytes, bytes, uint256)); + address to = address(bytes20(toBinary)); + + _unlock(to, amount); + + emit CrossChainTransferReceived(receiveId, from, to, amount); + } + + /// @dev Virtual function: implementation is required to handle token being burnt or locked on the source chain. + function _lock(address from, uint256 amount) internal virtual; + + /// @dev Virtual function: implementation is required to handle token being minted or unlocked on the destination chain. + function _unlock(address to, uint256 amount) internal virtual; + + function _extractChain(bytes memory self) private pure returns (bytes memory chain, bytes memory addr) { + bytes2 chainType; + bytes memory chainReference; + + (chainType, chainReference, addr) = self.parseV1(); + chain = InteroperableAddress.formatV1(chainType, chainReference, hex""); + } +} diff --git a/contracts/crosschain/bridges/CrosschainBridgeERC20Bridgeable.sol b/contracts/crosschain/bridges/CrosschainBridgeERC20Bridgeable.sol new file mode 100644 index 00000000000..2c2dc3c72d8 --- /dev/null +++ b/contracts/crosschain/bridges/CrosschainBridgeERC20Bridgeable.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {IERC7802} from "../../interfaces/draft-IERC7802.sol"; +import {CrosschainBridgeERC20} from "./CrosschainBridgeERC20.sol"; + +/** + * @dev This is a variant of {CrosschainBridgeERC20} that implements the bridge logic for ERC-7802 compliant tokens. + */ +abstract contract CrosschainBridgeERC20Bridgeable is CrosschainBridgeERC20 { + IERC7802 private immutable _token; + + constructor( + IERC7802 token_, + address initialGateway, + bytes[] memory remoteTokens + ) CrosschainBridgeERC20(initialGateway, remoteTokens) { + _token = token_; + } + + /// @dev Return the address of the ERC20 token this bridge operates on. + function token() public view virtual returns (IERC7802) { + return _token; + } + + /// @dev "Locking" tokens using an ERC-7802 crosschain burn + function _lock(address from, uint256 amount) internal virtual override { + token().crosschainBurn(from, amount); + } + + /// @dev "Unlocking" tokens using an ERC-7802 crosschain burn + function _unlock(address to, uint256 amount) internal virtual override { + token().crosschainMint(to, amount); + } +} diff --git a/contracts/crosschain/bridges/CrosschainBridgeERC20Custodial.sol b/contracts/crosschain/bridges/CrosschainBridgeERC20Custodial.sol new file mode 100644 index 00000000000..fa7eadeac82 --- /dev/null +++ b/contracts/crosschain/bridges/CrosschainBridgeERC20Custodial.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {IERC20, SafeERC20} from "../../token/ERC20/utils/SafeERC20.sol"; +import {CrosschainBridgeERC20} from "./CrosschainBridgeERC20.sol"; + +/** + * @dev This is a variant of {CrosschainBridgeERC20} that implements the bridge logic for existing ERC-20 tokens. + */ +abstract contract CrosschainBridgeERC20Custodial is CrosschainBridgeERC20 { + using SafeERC20 for IERC20; + + IERC20 private immutable _token; + + constructor( + IERC20 token_, + address initialGateway, + bytes[] memory remoteTokens + ) CrosschainBridgeERC20(initialGateway, remoteTokens) { + _token = token_; + } + + /// @dev Return the address of the ERC20 token this bridge operates on. + function token() public view virtual returns (IERC20) { + return _token; + } + + /// @dev "Locking" tokens is done by taking custody + function _lock(address from, uint256 amount) internal virtual override { + token().safeTransferFrom(from, address(this), amount); + } + + /// @dev "Unlocking" tokens is done by releasing custody + function _unlock(address to, uint256 amount) internal virtual override { + token().safeTransfer(to, amount); + } +} diff --git a/contracts/mocks/token/ERC20BridgeableMock.sol b/contracts/mocks/token/ERC20BridgeableMock.sol index ef99f2e9d80..38abbd2cef6 100644 --- a/contracts/mocks/token/ERC20BridgeableMock.sol +++ b/contracts/mocks/token/ERC20BridgeableMock.sol @@ -10,7 +10,11 @@ abstract contract ERC20BridgeableMock is ERC20Bridgeable { error OnlyTokenBridge(); event OnlyTokenBridgeFnCalled(address caller); - constructor(address bridge) { + constructor(address initialBridge) { + _setBridge(initialBridge); + } + + function _setBridge(address bridge) internal { _bridge = bridge; } diff --git a/contracts/token/ERC20/extensions/ERC20Crosschain.sol b/contracts/token/ERC20/extensions/ERC20Crosschain.sol new file mode 100644 index 00000000000..908378be0a3 --- /dev/null +++ b/contracts/token/ERC20/extensions/ERC20Crosschain.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {ERC20} from "../ERC20.sol"; +import {CrosschainBridgeERC20} from "../../../crosschain/bridges/CrosschainBridgeERC20.sol"; + +abstract contract ERC20Crosschain is ERC20, CrosschainBridgeERC20 { + /// @dev TransferFrom variant of {crosschainTransferFrom-bytes-uint256}, using ERC20 allowance from the sender to the caller. + function crosschainTransferFrom(address from, bytes memory to, uint256 amount) public returns (bytes32) { + return crosschainTransferFrom(from, to, amount, new bytes[](0)); + } + + /// @dev TransferFrom variant of {crosschainTransferFrom-bytes-uint256-bytes[]}, using ERC20 allowance from the sender to the caller. + function crosschainTransferFrom( + address from, + bytes memory to, + uint256 amount, + bytes[] memory attributes + ) public virtual returns (bytes32) { + _spendAllowance(from, msg.sender, amount); + return _crosschainTransfer(from, to, amount, attributes); + } + + /// @dev "Locking" tokens is achieved through burning + function _lock(address from, uint256 amount) internal virtual override { + _burn(from, amount); + } + + /// @dev "Unlocking" tokens is achieved through minting + function _unlock(address to, uint256 amount) internal virtual override { + _mint(to, amount); + } +} diff --git a/test/crosschain/CrosschainBridgeERC20.test.js b/test/crosschain/CrosschainBridgeERC20.test.js new file mode 100644 index 00000000000..3ea9683ab20 --- /dev/null +++ b/test/crosschain/CrosschainBridgeERC20.test.js @@ -0,0 +1,204 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const { anyValue } = require('@nomicfoundation/hardhat-chai-matchers/withArgs'); + +const { impersonate } = require('../helpers/account'); +const { getLocalChain } = require('../helpers/chains'); + +const amount = 100n; + +async function fixture() { + const chain = await getLocalChain(); + const accounts = await ethers.getSigners(); + + // Mock gateway + const gateway = await ethers.deployContract('$ERC7786GatewayMock'); + const gatewayAsEOA = await impersonate(gateway); + + // Chain A: legacy ERC20 with bridge + const tokenA = await ethers.deployContract('$ERC20', ['Token1', 'T1']); + const bridgeA = await ethers.deployContract('$CrosschainBridgeERC20Custodial', [tokenA, gateway, []]); + + // Chain B: ERC7802 with bridge + const tokenB = await ethers.deployContract('$ERC20BridgeableMock', ['Token2', 'T2', ethers.ZeroAddress]); + const bridgeB = await ethers.deployContract('$CrosschainBridgeERC20Bridgeable', [tokenB, gateway, []]); + + // deployment check + remote setup + await expect(bridgeA.deploymentTransaction()).to.emit(bridgeA, 'GatewayChange').withArgs(ethers.ZeroAddress, gateway); + await expect(bridgeB.deploymentTransaction()).to.emit(bridgeB, 'GatewayChange').withArgs(ethers.ZeroAddress, gateway); + await expect(bridgeA.$_registerRemote(chain.toErc7930(bridgeB), false)) + .to.emit(bridgeA, 'RemoteTokenRegistered') + .withArgs(chain.toErc7930(bridgeB)); + await expect(bridgeB.$_registerRemote(chain.toErc7930(bridgeA), false)) + .to.emit(bridgeB, 'RemoteTokenRegistered') + .withArgs(chain.toErc7930(bridgeA)); + await tokenB.$_setBridge(bridgeB); + + // helper + const encodePayload = (from, to, amount) => + ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes', 'bytes', 'uint256'], + [chain.toErc7930(from), to.target ?? to.address ?? to, amount], + ); + + return { chain, accounts, gateway, gatewayAsEOA, tokenA, tokenB, bridgeA, bridgeB, encodePayload }; +} + +describe('CrosschainBridgeERC20', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + it('initial setup', async function () { + await expect(this.bridgeA.token()).to.eventually.equal(this.tokenA); + await expect(this.bridgeA.gateway()).to.eventually.equal(this.gateway); + await expect(this.bridgeA.remote(this.chain.erc7930)).to.eventually.equal(this.chain.toErc7930(this.bridgeB)); + await expect(this.bridgeB.token()).to.eventually.equal(this.tokenB); + await expect(this.bridgeB.gateway()).to.eventually.equal(this.gateway); + await expect(this.bridgeB.remote(this.chain.erc7930)).to.eventually.equal(this.chain.toErc7930(this.bridgeA)); + }); + + it('crosschain send', async function () { + const [alice, bruce, chris] = this.accounts; + + await this.tokenA.$_mint(alice, amount); + await this.tokenA.connect(alice).approve(this.bridgeA, ethers.MaxUint256); + + await expect(this.tokenA.totalSupply()).to.eventually.equal(amount); + await expect(this.tokenA.balanceOf(this.bridgeA)).to.eventually.equal(0n); + await expect(this.tokenB.totalSupply()).to.eventually.equal(0n); + + // Alice sends tokens from chain A to Bruce on chain B. + await expect(this.bridgeA.connect(alice).crosschainTransfer(this.chain.toErc7930(bruce), amount)) + // bridge on chain A takes custody of the funds + .to.emit(this.tokenA, 'Transfer') + .withArgs(alice, this.bridgeA, amount) + // crosschain transfer sent + .to.emit(this.bridgeA, 'CrossChainTransferSent') + .withArgs(anyValue, alice, this.chain.toErc7930(bruce), amount) + // ERC-7786 event + .to.emit(this.gateway, 'MessageSent') + // crosschain transfer received + .to.emit(this.bridgeB, 'CrossChainTransferReceived') + .withArgs(anyValue, this.chain.toErc7930(alice), bruce, amount) + // crosschain mint event + .to.emit(this.tokenB, 'CrosschainMint') + .withArgs(bruce, amount, this.bridgeB) + // tokens are minted on chain B + .to.emit(this.tokenB, 'Transfer') + .withArgs(ethers.ZeroAddress, bruce, amount); + + await expect(this.tokenA.totalSupply()).to.eventually.equal(amount); + await expect(this.tokenA.balanceOf(this.bridgeA)).to.eventually.equal(amount); + await expect(this.tokenB.totalSupply()).to.eventually.equal(amount); + await expect(this.tokenB.balanceOf(bruce)).to.eventually.equal(amount); + + // Bruce sends tokens from chain B to Chris on chain A. + await expect(this.bridgeB.connect(bruce).crosschainTransfer(this.chain.toErc7930(chris), amount)) + // tokens are burned on chain B + .to.emit(this.tokenB, 'Transfer') + .withArgs(bruce, ethers.ZeroAddress, amount) + // crosschain burn event + .to.emit(this.tokenB, 'CrosschainBurn') + .withArgs(bruce, amount, this.bridgeB) + // crosschain transfer sent + .to.emit(this.bridgeB, 'CrossChainTransferSent') + .withArgs(anyValue, bruce, this.chain.toErc7930(chris), amount) + // ERC-7786 event + .to.emit(this.gateway, 'MessageSent') + // crosschain transfer received + .to.emit(this.bridgeA, 'CrossChainTransferReceived') + .withArgs(anyValue, this.chain.toErc7930(bruce), chris, amount) + // bridge on chain A releases custody of the funds + .to.emit(this.tokenA, 'Transfer') + .withArgs(this.bridgeA, chris, amount); + + await expect(this.tokenA.totalSupply()).to.eventually.equal(amount); + await expect(this.tokenA.balanceOf(chris)).to.eventually.equal(amount); + await expect(this.tokenB.totalSupply()).to.eventually.equal(0n); + }); + + describe('crosschain operations', function () { + beforeEach(async function () { + await this.tokenA.$_mint(this.bridgeA, 1_000_000_000n); + }); + + it('only gateway can relay messages', async function () { + const [notGateway] = this.accounts; + + await expect( + this.bridgeA + .connect(notGateway) + .receiveMessage( + ethers.ZeroHash, + this.chain.toErc7930(this.tokenB), + this.encodePayload(notGateway, notGateway, amount), + ), + ) + .to.be.revertedWithCustomError(this.bridgeA, 'ERC7786RecipientInvalidGateway') + .withArgs(notGateway); + }); + + it('only remote can send a crosschain message', async function () { + const [notRemote] = this.accounts; + + await expect( + this.gateway + .connect(notRemote) + .sendMessage(this.chain.toErc7930(this.bridgeA), this.encodePayload(notRemote, notRemote, amount), []), + ) + .to.be.revertedWithCustomError(this.bridgeA, 'InvalidSender') + .withArgs(this.chain.toErc7930(notRemote)); + }); + + it('cannot replay message', async function () { + const [from, to] = this.accounts; + + const id = ethers.ZeroHash; + const payload = this.encodePayload(from, to, amount); + + // first time works + await expect( + this.bridgeA.connect(this.gatewayAsEOA).receiveMessage(id, this.chain.toErc7930(this.bridgeB), payload), + ).to.emit(this.bridgeA, 'CrossChainTransferReceived'); + + // second time fails + await expect( + this.bridgeA.connect(this.gatewayAsEOA).receiveMessage(id, this.chain.toErc7930(this.bridgeB), payload), + ) + .to.be.revertedWithCustomError(this.bridgeA, 'MessageAlreadyProcessed') + .withArgs(this.gateway, id); + }); + }); + + describe('administration', function () { + it('updating the gateway emits an event', async function () { + const newGateway = await ethers.deployContract('$ERC7786GatewayMock'); + + await expect(this.bridgeA.$_setGateway(newGateway)) + .to.emit(this.bridgeA, 'GatewayChange') + .withArgs(this.gateway, newGateway); + + await expect(this.bridgeA.gateway()).to.eventually.equal(newGateway); + }); + + it('updating a remote emits an event', async function () { + const newRemote = this.chain.toErc7930(this.accounts[0]); + + await expect(this.bridgeA.$_registerRemote(newRemote, true)) + .to.emit(this.bridgeA, 'RemoteTokenRegistered') + .withArgs(newRemote); + + await expect(this.bridgeA.remote(this.chain.erc7930)).to.eventually.equal(newRemote); + }); + + it('remote update protection', async function () { + const newRemote = this.chain.toErc7930(this.accounts[0]); + + await expect(this.bridgeA.$_registerRemote(newRemote, false)) + .to.be.revertedWithCustomError(this.bridgeA, 'RemoteAlreadyRegistered') + .withArgs(this.chain.toErc7930(this.bridgeB)); + }); + }); +}); diff --git a/test/helpers/account.js b/test/helpers/account.js index 96874b16b75..f3753098586 100644 --- a/test/helpers/account.js +++ b/test/helpers/account.js @@ -4,10 +4,12 @@ const { impersonateAccount, setBalance } = require('@nomicfoundation/hardhat-net // Hardhat default balance const DEFAULT_BALANCE = 10000n * ethers.WeiPerEther; -const impersonate = (account, balance = DEFAULT_BALANCE) => - impersonateAccount(account) - .then(() => setBalance(account, balance)) - .then(() => ethers.getSigner(account)); +const impersonate = (account, balance = DEFAULT_BALANCE) => { + const address = account.target ?? account.address ?? account; + return impersonateAccount(address) + .then(() => setBalance(address, balance)) + .then(() => ethers.getSigner(address)); +}; module.exports = { impersonate, From 50be369454615e2cde3fde0d80c053d4d737a61c Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Fri, 29 Aug 2025 20:58:52 +0200 Subject: [PATCH 09/19] refactor bridges --- contracts/crosschain/bridges/BridgeCore.sol | 101 +++++++++++ contracts/crosschain/bridges/BridgeERC20.sol | 84 +++++++++ ...idgeable.sol => BridgeERC20Bridgeable.sol} | 12 +- ...Custodial.sol => BridgeERC20Custodial.sol} | 12 +- .../bridges/CrosschainBridgeERC20.sol | 163 ------------------ .../ERC20/extensions/ERC20Crosschain.sol | 4 +- ...ridgeERC20.test.js => BridgeERC20.test.js} | 12 +- 7 files changed, 201 insertions(+), 187 deletions(-) create mode 100644 contracts/crosschain/bridges/BridgeCore.sol create mode 100644 contracts/crosschain/bridges/BridgeERC20.sol rename contracts/crosschain/bridges/{CrosschainBridgeERC20Bridgeable.sol => BridgeERC20Bridgeable.sol} (63%) rename contracts/crosschain/bridges/{CrosschainBridgeERC20Custodial.sol => BridgeERC20Custodial.sol} (65%) delete mode 100644 contracts/crosschain/bridges/CrosschainBridgeERC20.sol rename test/crosschain/{CrosschainBridgeERC20.test.js => BridgeERC20.test.js} (94%) diff --git a/contracts/crosschain/bridges/BridgeCore.sol b/contracts/crosschain/bridges/BridgeCore.sol new file mode 100644 index 00000000000..c2dbbcb6127 --- /dev/null +++ b/contracts/crosschain/bridges/BridgeCore.sol @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {IERC7786GatewaySource, IERC7786Recipient} from "../../interfaces/draft-IERC7786.sol"; +import {InteroperableAddress} from "../../utils/draft-InteroperableAddress.sol"; +import {BitMaps} from "../../utils/structs/BitMaps.sol"; +import {Bytes} from "../../utils/Bytes.sol"; + +abstract contract BridgeCore is IERC7786Recipient { + using BitMaps for BitMaps.BitMap; + using InteroperableAddress for bytes; + + address private _gateway; + mapping(bytes chain => bytes) private _remotes; + mapping(address gateway => BitMaps.BitMap) private _received; + + event GatewayChange(address oldGateway, address newGateway); + event RemoteRegistered(bytes remote); + + error InvalidGateway(address gateway); + error InvalidSender(bytes sender); + error MessageAlreadyProcessed(address gateway, bytes32 receiveId); + error RemoteAlreadyRegistered(bytes remote); + + constructor(address initialGateway, bytes[] memory initialRemotes) { + _setGateway(initialGateway); + for (uint256 i = 0; i < initialRemotes.length; ++i) { + _registerRemote(initialRemotes[i], false); + } + } + + /// @dev Returns the ERC-7786 gateway used for sending and receiving cross-chain messages + function gateway() public view virtual returns (address) { + return _gateway; + } + + /// @dev Returns the interoperable address of the corresponding token on a given chain + function remote(bytes memory chain) public view virtual returns (bytes memory) { + return _remotes[chain]; + } + + /// @dev Internal setter to change the ERC-7786 gateway. Called at construction. + function _setGateway(address newGateway) internal virtual { + // Sanity check, this should revert if newGateway is not an ERC-7786 implementation. Note that since + // supportsAttribute returns data, an EOA would fail that test (nothing returned). + IERC7786GatewaySource(newGateway).supportsAttribute(bytes4(0)); + + address oldGateway = _gateway; + _gateway = newGateway; + + emit GatewayChange(oldGateway, newGateway); + } + + /// @dev Internal setter to change the ERC-7786 gateway. Called at construction. + function _registerRemote(bytes memory remoteToken, bool allowOverride) internal virtual { + bytes memory chain = _extractChain(remoteToken); + if (allowOverride || _remotes[chain].length == 0) { + _remotes[chain] = remoteToken; + emit RemoteRegistered(remoteToken); + } else { + revert RemoteAlreadyRegistered(_remotes[chain]); + } + } + + /// @dev Internal messaging function. + function _sendMessage( + bytes memory chain, + bytes memory payload, + bytes[] memory attributes + ) internal virtual returns (bytes32) { + return IERC7786GatewaySource(gateway()).sendMessage(remote(chain), payload, attributes); + } + + /// @inheritdoc IERC7786Recipient + function receiveMessage( + bytes32 receiveId, + bytes calldata sender, // Binary Interoperable Address + bytes calldata payload + ) public payable virtual returns (bytes4) { + // Security restriction: + // - sender must be the remote for that chain + // - message was not processed yet + require(msg.sender == gateway(), InvalidGateway(msg.sender)); + require(Bytes.equal(remote(_extractChain(sender)), sender), InvalidSender(sender)); + require(!_received[msg.sender].get(uint256(receiveId)), MessageAlreadyProcessed(msg.sender, receiveId)); + _received[msg.sender].set(uint256(receiveId)); + + _processMessage(receiveId, payload); + + return IERC7786Recipient.receiveMessage.selector; + } + + /// @dev Virtual function that should contain the logic to execute when a cross-chain message is received. + function _processMessage(bytes32 receiveId, bytes calldata payload) internal virtual; + + function _extractChain(bytes memory self) private pure returns (bytes memory) { + (bytes2 chainType, bytes memory chainReference, ) = self.parseV1(); + return InteroperableAddress.formatV1(chainType, chainReference, hex""); + } +} diff --git a/contracts/crosschain/bridges/BridgeERC20.sol b/contracts/crosschain/bridges/BridgeERC20.sol new file mode 100644 index 00000000000..0013f8a9300 --- /dev/null +++ b/contracts/crosschain/bridges/BridgeERC20.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {InteroperableAddress} from "../../utils/draft-InteroperableAddress.sol"; +import {BridgeCore} from "./BridgeCore.sol"; + +/** + * @dev Base contract for bridging ERC-20 between chains using an ERC-7786 gateway. + * + * In order to use this contract, two function must be implemented to link it to the token: + * * {lock}: called when a crosschain transfer is going out. Must take the sender tokens or revert. + * * {unlock}: called when a crosschain transfer is coming it. Must give tokens to the receiver. + * + * This base contract is used by the {BridgeERC20Custodial}, which interfaces with legacy ERC-20 tokens. + * It is also used by the {ERC20Crosschain} extension, which embeds the bridge logic directly in the token contract. + */ +abstract contract BridgeERC20 is BridgeCore { + using InteroperableAddress for bytes; + + event CrossChainTransferSent(bytes32 indexed sendId, address indexed from, bytes to, uint256 amount); + event CrossChainTransferReceived(bytes32 indexed receiveId, bytes from, address indexed to, uint256 amount); + + /** + * @dev Transfer `amount` tokens to a crosschain receiver. + * + * This is a variant of {crosschainTransfer-bytes-uint256-bytes[]} with an empty attribute list. + * + * NOTE: This function is not virtual and should not be overriden. Consider overriding + * {crosschainTransfer-bytes-uint256-bytes[]} instead. + */ + function crosschainTransfer(bytes memory to, uint256 amount) public returns (bytes32) { + return crosschainTransfer(to, amount, new bytes[](0)); + } + + /// @dev Transfer `amount` tokens to a crosschain receiver. + function crosschainTransfer( + bytes memory to, + uint256 amount, + bytes[] memory attributes + ) public virtual returns (bytes32) { + return _crosschainTransfer(msg.sender, to, amount, attributes); + } + + /// @dev Internal crosschain transfer function. + function _crosschainTransfer( + address from, + bytes memory to, + uint256 amount, + bytes[] memory attributes + ) internal virtual returns (bytes32) { + _lock(from, amount); + + (bytes2 chainType, bytes memory chainReference, bytes memory addr) = to.parseV1(); + bytes memory chain = InteroperableAddress.formatV1(chainType, chainReference, hex""); + + bytes32 sendId = _sendMessage( + chain, + abi.encode(InteroperableAddress.formatEvmV1(block.chainid, from), addr, amount), + attributes + ); + + emit CrossChainTransferSent(sendId, from, to, amount); + + return sendId; + } + + /// @inheritdoc BridgeCore + function _processMessage(bytes32 receiveId, bytes calldata payload) internal virtual override { + // split payload + (bytes memory from, bytes memory toBinary, uint256 amount) = abi.decode(payload, (bytes, bytes, uint256)); + address to = address(bytes20(toBinary)); + + _unlock(to, amount); + + emit CrossChainTransferReceived(receiveId, from, to, amount); + } + + /// @dev Virtual function: implementation is required to handle token being burnt or locked on the source chain. + function _lock(address from, uint256 amount) internal virtual; + + /// @dev Virtual function: implementation is required to handle token being minted or unlocked on the destination chain. + function _unlock(address to, uint256 amount) internal virtual; +} diff --git a/contracts/crosschain/bridges/CrosschainBridgeERC20Bridgeable.sol b/contracts/crosschain/bridges/BridgeERC20Bridgeable.sol similarity index 63% rename from contracts/crosschain/bridges/CrosschainBridgeERC20Bridgeable.sol rename to contracts/crosschain/bridges/BridgeERC20Bridgeable.sol index 2c2dc3c72d8..3eeab9d84ba 100644 --- a/contracts/crosschain/bridges/CrosschainBridgeERC20Bridgeable.sol +++ b/contracts/crosschain/bridges/BridgeERC20Bridgeable.sol @@ -3,19 +3,15 @@ pragma solidity ^0.8.24; import {IERC7802} from "../../interfaces/draft-IERC7802.sol"; -import {CrosschainBridgeERC20} from "./CrosschainBridgeERC20.sol"; +import {BridgeERC20} from "./BridgeERC20.sol"; /** - * @dev This is a variant of {CrosschainBridgeERC20} that implements the bridge logic for ERC-7802 compliant tokens. + * @dev This is a variant of {BridgeERC20} that implements the bridge logic for ERC-7802 compliant tokens. */ -abstract contract CrosschainBridgeERC20Bridgeable is CrosschainBridgeERC20 { +abstract contract BridgeERC20Bridgeable is BridgeERC20 { IERC7802 private immutable _token; - constructor( - IERC7802 token_, - address initialGateway, - bytes[] memory remoteTokens - ) CrosschainBridgeERC20(initialGateway, remoteTokens) { + constructor(IERC7802 token_) { _token = token_; } diff --git a/contracts/crosschain/bridges/CrosschainBridgeERC20Custodial.sol b/contracts/crosschain/bridges/BridgeERC20Custodial.sol similarity index 65% rename from contracts/crosschain/bridges/CrosschainBridgeERC20Custodial.sol rename to contracts/crosschain/bridges/BridgeERC20Custodial.sol index fa7eadeac82..91c1c798ca8 100644 --- a/contracts/crosschain/bridges/CrosschainBridgeERC20Custodial.sol +++ b/contracts/crosschain/bridges/BridgeERC20Custodial.sol @@ -3,21 +3,17 @@ pragma solidity ^0.8.24; import {IERC20, SafeERC20} from "../../token/ERC20/utils/SafeERC20.sol"; -import {CrosschainBridgeERC20} from "./CrosschainBridgeERC20.sol"; +import {BridgeERC20} from "./BridgeERC20.sol"; /** - * @dev This is a variant of {CrosschainBridgeERC20} that implements the bridge logic for existing ERC-20 tokens. + * @dev This is a variant of {BridgeERC20} that implements the bridge logic for existing ERC-20 tokens. */ -abstract contract CrosschainBridgeERC20Custodial is CrosschainBridgeERC20 { +abstract contract BridgeERC20Custodial is BridgeERC20 { using SafeERC20 for IERC20; IERC20 private immutable _token; - constructor( - IERC20 token_, - address initialGateway, - bytes[] memory remoteTokens - ) CrosschainBridgeERC20(initialGateway, remoteTokens) { + constructor(IERC20 token_) { _token = token_; } diff --git a/contracts/crosschain/bridges/CrosschainBridgeERC20.sol b/contracts/crosschain/bridges/CrosschainBridgeERC20.sol deleted file mode 100644 index 81f52421d40..00000000000 --- a/contracts/crosschain/bridges/CrosschainBridgeERC20.sol +++ /dev/null @@ -1,163 +0,0 @@ -// SPDX-License-Identifier: MIT - -pragma solidity ^0.8.24; - -import {ERC7786Recipient} from "../ERC7786Recipient.sol"; -import {IERC7786GatewaySource} from "../../interfaces/draft-IERC7786.sol"; -import {InteroperableAddress} from "../../utils/draft-InteroperableAddress.sol"; -import {BitMaps} from "../../utils/structs/BitMaps.sol"; -import {Bytes} from "../../utils/Bytes.sol"; - -/** - * @dev Base contract for bridging ERC-20 between chains using an ERC-7786 gateway. - * - * In order to use this contract, two function must be implemented to link it to the token: - * * {lock}: called when a crosschain transfer is going out. Must take the sender tokens or revert. - * * {unlock}: called when a crosschain transfer is coming it. Must give tokens to the receiver. - * - * This base contract is used by the {CrosschainBridgeERC20Custodial}, which interfaces with legacy ERC-20 tokens. - * It is also used by the {ERC20Crosschain} extension, which embeds the bridge logic directly in the token contract. - */ -abstract contract CrosschainBridgeERC20 is ERC7786Recipient { - using BitMaps for BitMaps.BitMap; - using InteroperableAddress for bytes; - - address private _gateway; - mapping(bytes chain => bytes) private _remoteTokens; - mapping(address gateway => BitMaps.BitMap) private _received; - - event GatewayChange(address oldGateway, address newGateway); - event RemoteTokenRegistered(bytes remote); - event CrossChainTransferSent(bytes32 indexed sendId, address indexed from, bytes to, uint256 amount); - event CrossChainTransferReceived(bytes32 indexed receiveId, bytes from, address indexed to, uint256 amount); - - error InvalidGateway(); - error InvalidSender(bytes sender); - error MessageAlreadyProcessed(address gateway, bytes32 receiveId); - error RemoteAlreadyRegistered(bytes remote); - - constructor(address initialGateway, bytes[] memory remoteTokens) { - _setGateway(initialGateway); - for (uint256 i = 0; i < remoteTokens.length; ++i) { - _registerRemote(remoteTokens[i], false); - } - } - - /// @dev Returns the ERC-7786 gateway used for sending and receiving cross-chain messages - function gateway() public view virtual returns (address) { - return _gateway; - } - - /// @dev Returns the interoperable address of the corresponding token on a given chain - function remote(bytes memory chain) public view virtual returns (bytes memory) { - return _remoteTokens[chain]; - } - - /// @dev Internal setter to change the ERC-7786 gateway. Called at construction. - function _setGateway(address newGateway) internal virtual { - // Sanity check, this should revert if newGateway is not an ERC-7786 implementation. Note that since - // supportsAttribute returns data, an EOA would fail that test (nothing returned). - IERC7786GatewaySource(newGateway).supportsAttribute(bytes4(0)); - - address oldGateway = _gateway; - _gateway = newGateway; - - emit GatewayChange(oldGateway, newGateway); - } - - /// @dev Internal setter to change the ERC-7786 gateway. Called at construction. - function _registerRemote(bytes memory remoteToken, bool allowOverride) internal virtual { - (bytes memory chain, ) = _extractChain(remoteToken); - if (allowOverride || _remoteTokens[chain].length == 0) { - _remoteTokens[chain] = remoteToken; - emit RemoteTokenRegistered(remoteToken); - } else { - revert RemoteAlreadyRegistered(_remoteTokens[chain]); - } - } - - /** - * @dev Transfer `amount` tokens to a crosschain receiver. - * - * This is a variant of {crosschainTransfer-bytes-uint256-bytes[]} with an empty attribute list. - * - * NOTE: This function is not virtual and should not be overriden. Consider overriding - * {crosschainTransfer-bytes-uint256-bytes[]} instead. - */ - function crosschainTransfer(bytes memory to, uint256 amount) public returns (bytes32) { - return crosschainTransfer(to, amount, new bytes[](0)); - } - - /// @dev Transfer `amount` tokens to a crosschain receiver. - function crosschainTransfer( - bytes memory to, - uint256 amount, - bytes[] memory attributes - ) public virtual returns (bytes32) { - return _crosschainTransfer(msg.sender, to, amount, attributes); - } - - /// @dev Internal crosschain transfer function. - function _crosschainTransfer( - address from, - bytes memory to, - uint256 amount, - bytes[] memory attributes - ) internal virtual returns (bytes32) { - _lock(from, amount); - - (bytes memory chain, bytes memory addr) = _extractChain(to); - bytes32 sendId = IERC7786GatewaySource(gateway()).sendMessage( - remote(chain), - abi.encode(InteroperableAddress.formatEvmV1(block.chainid, from), addr, amount), - attributes - ); - - emit CrossChainTransferSent(sendId, from, to, amount); - - return sendId; - } - - /// @inheritdoc ERC7786Recipient - function _isKnownGateway(address instance) internal view virtual override returns (bool) { - return instance == gateway(); - } - - /// @inheritdoc ERC7786Recipient - function _processMessage( - address gateway_, - bytes32 receiveId, - bytes calldata sender, - bytes calldata payload - ) internal virtual override { - // Check the sender is the remiote for that chain - (bytes memory chain, ) = _extractChain(sender); - require(Bytes.equal(remote(chain), sender), InvalidSender(sender)); - - // Check the message was not processed yet - require(!_received[gateway_].get(uint256(receiveId)), MessageAlreadyProcessed(gateway_, receiveId)); - _received[gateway_].set(uint256(receiveId)); - - // split payload - (bytes memory from, bytes memory toBinary, uint256 amount) = abi.decode(payload, (bytes, bytes, uint256)); - address to = address(bytes20(toBinary)); - - _unlock(to, amount); - - emit CrossChainTransferReceived(receiveId, from, to, amount); - } - - /// @dev Virtual function: implementation is required to handle token being burnt or locked on the source chain. - function _lock(address from, uint256 amount) internal virtual; - - /// @dev Virtual function: implementation is required to handle token being minted or unlocked on the destination chain. - function _unlock(address to, uint256 amount) internal virtual; - - function _extractChain(bytes memory self) private pure returns (bytes memory chain, bytes memory addr) { - bytes2 chainType; - bytes memory chainReference; - - (chainType, chainReference, addr) = self.parseV1(); - chain = InteroperableAddress.formatV1(chainType, chainReference, hex""); - } -} diff --git a/contracts/token/ERC20/extensions/ERC20Crosschain.sol b/contracts/token/ERC20/extensions/ERC20Crosschain.sol index 908378be0a3..208fdeb1bc2 100644 --- a/contracts/token/ERC20/extensions/ERC20Crosschain.sol +++ b/contracts/token/ERC20/extensions/ERC20Crosschain.sol @@ -3,9 +3,9 @@ pragma solidity ^0.8.24; import {ERC20} from "../ERC20.sol"; -import {CrosschainBridgeERC20} from "../../../crosschain/bridges/CrosschainBridgeERC20.sol"; +import {BridgeERC20} from "../../../crosschain/bridges/BridgeERC20.sol"; -abstract contract ERC20Crosschain is ERC20, CrosschainBridgeERC20 { +abstract contract ERC20Crosschain is ERC20, BridgeERC20 { /// @dev TransferFrom variant of {crosschainTransferFrom-bytes-uint256}, using ERC20 allowance from the sender to the caller. function crosschainTransferFrom(address from, bytes memory to, uint256 amount) public returns (bytes32) { return crosschainTransferFrom(from, to, amount, new bytes[](0)); diff --git a/test/crosschain/CrosschainBridgeERC20.test.js b/test/crosschain/BridgeERC20.test.js similarity index 94% rename from test/crosschain/CrosschainBridgeERC20.test.js rename to test/crosschain/BridgeERC20.test.js index 3ea9683ab20..1149dcbc217 100644 --- a/test/crosschain/CrosschainBridgeERC20.test.js +++ b/test/crosschain/BridgeERC20.test.js @@ -18,20 +18,20 @@ async function fixture() { // Chain A: legacy ERC20 with bridge const tokenA = await ethers.deployContract('$ERC20', ['Token1', 'T1']); - const bridgeA = await ethers.deployContract('$CrosschainBridgeERC20Custodial', [tokenA, gateway, []]); + const bridgeA = await ethers.deployContract('$BridgeERC20Custodial', [gateway, [], tokenA]); // Chain B: ERC7802 with bridge const tokenB = await ethers.deployContract('$ERC20BridgeableMock', ['Token2', 'T2', ethers.ZeroAddress]); - const bridgeB = await ethers.deployContract('$CrosschainBridgeERC20Bridgeable', [tokenB, gateway, []]); + const bridgeB = await ethers.deployContract('$BridgeERC20Bridgeable', [gateway, [], tokenB]); // deployment check + remote setup await expect(bridgeA.deploymentTransaction()).to.emit(bridgeA, 'GatewayChange').withArgs(ethers.ZeroAddress, gateway); await expect(bridgeB.deploymentTransaction()).to.emit(bridgeB, 'GatewayChange').withArgs(ethers.ZeroAddress, gateway); await expect(bridgeA.$_registerRemote(chain.toErc7930(bridgeB), false)) - .to.emit(bridgeA, 'RemoteTokenRegistered') + .to.emit(bridgeA, 'RemoteRegistered') .withArgs(chain.toErc7930(bridgeB)); await expect(bridgeB.$_registerRemote(chain.toErc7930(bridgeA), false)) - .to.emit(bridgeB, 'RemoteTokenRegistered') + .to.emit(bridgeB, 'RemoteRegistered') .withArgs(chain.toErc7930(bridgeA)); await tokenB.$_setBridge(bridgeB); @@ -136,7 +136,7 @@ describe('CrosschainBridgeERC20', function () { this.encodePayload(notGateway, notGateway, amount), ), ) - .to.be.revertedWithCustomError(this.bridgeA, 'ERC7786RecipientInvalidGateway') + .to.be.revertedWithCustomError(this.bridgeA, 'InvalidGateway') .withArgs(notGateway); }); @@ -187,7 +187,7 @@ describe('CrosschainBridgeERC20', function () { const newRemote = this.chain.toErc7930(this.accounts[0]); await expect(this.bridgeA.$_registerRemote(newRemote, true)) - .to.emit(this.bridgeA, 'RemoteTokenRegistered') + .to.emit(this.bridgeA, 'RemoteRegistered') .withArgs(newRemote); await expect(this.bridgeA.remote(this.chain.erc7930)).to.eventually.equal(newRemote); From 4cf52d0fdb873be132f3597209402573701fdaf7 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Fri, 29 Aug 2025 21:08:33 +0200 Subject: [PATCH 10/19] comments --- contracts/crosschain/bridges/BridgeCore.sol | 18 ++++++++++++++++-- contracts/crosschain/bridges/BridgeERC20.sol | 5 +++-- .../bridges/BridgeERC20Custodial.sol | 3 ++- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/contracts/crosschain/bridges/BridgeCore.sol b/contracts/crosschain/bridges/BridgeCore.sol index c2dbbcb6127..e0f547d422a 100644 --- a/contracts/crosschain/bridges/BridgeCore.sol +++ b/contracts/crosschain/bridges/BridgeCore.sol @@ -7,6 +7,16 @@ import {InteroperableAddress} from "../../utils/draft-InteroperableAddress.sol"; import {BitMaps} from "../../utils/structs/BitMaps.sol"; import {Bytes} from "../../utils/Bytes.sol"; +/** + * @dev Core bridging mechanism. + * + * This contract contains the logic to register and send messages to counterparts on remote chains using an ERC-7786 + * gateway. It ensure received message originate from for a counterpart. This is the code of token bridges such as + * {BridgeERC20}. + * + * Contract that inherit from this contract can use the internal {_senMessage} to send messages to their conterpart + * on a foreign chain. They must implement the {_processMessage} to handle the message that have been verified. + */ abstract contract BridgeCore is IERC7786Recipient { using BitMaps for BitMaps.BitMap; using InteroperableAddress for bytes; @@ -75,7 +85,7 @@ abstract contract BridgeCore is IERC7786Recipient { /// @inheritdoc IERC7786Recipient function receiveMessage( bytes32 receiveId, - bytes calldata sender, // Binary Interoperable Address + bytes calldata sender, bytes calldata payload ) public payable virtual returns (bytes4) { // Security restriction: @@ -91,7 +101,11 @@ abstract contract BridgeCore is IERC7786Recipient { return IERC7786Recipient.receiveMessage.selector; } - /// @dev Virtual function that should contain the logic to execute when a cross-chain message is received. + /** + * @dev Virtual function that should contain the logic to execute when a cross-chain message is received. + * + * Replay protection is already enabled in {receiveMessage}. + */ function _processMessage(bytes32 receiveId, bytes calldata payload) internal virtual; function _extractChain(bytes memory self) private pure returns (bytes memory) { diff --git a/contracts/crosschain/bridges/BridgeERC20.sol b/contracts/crosschain/bridges/BridgeERC20.sol index 0013f8a9300..9c328bceecf 100644 --- a/contracts/crosschain/bridges/BridgeERC20.sol +++ b/contracts/crosschain/bridges/BridgeERC20.sol @@ -12,8 +12,9 @@ import {BridgeCore} from "./BridgeCore.sol"; * * {lock}: called when a crosschain transfer is going out. Must take the sender tokens or revert. * * {unlock}: called when a crosschain transfer is coming it. Must give tokens to the receiver. * - * This base contract is used by the {BridgeERC20Custodial}, which interfaces with legacy ERC-20 tokens. - * It is also used by the {ERC20Crosschain} extension, which embeds the bridge logic directly in the token contract. + * This base contract is used by the {BridgeERC20Custodial}, which interfaces with legacy ERC-20 tokens, and + * {BrdigeERC20Bridgeable}, which interface with ERC-7802 to provide an approve-free user experience. It is also used + * by the {ERC20Crosschain} extension, which embeds the bridge logic directly in the token contract. */ abstract contract BridgeERC20 is BridgeCore { using InteroperableAddress for bytes; diff --git a/contracts/crosschain/bridges/BridgeERC20Custodial.sol b/contracts/crosschain/bridges/BridgeERC20Custodial.sol index 91c1c798ca8..e53361b9d34 100644 --- a/contracts/crosschain/bridges/BridgeERC20Custodial.sol +++ b/contracts/crosschain/bridges/BridgeERC20Custodial.sol @@ -6,7 +6,8 @@ import {IERC20, SafeERC20} from "../../token/ERC20/utils/SafeERC20.sol"; import {BridgeERC20} from "./BridgeERC20.sol"; /** - * @dev This is a variant of {BridgeERC20} that implements the bridge logic for existing ERC-20 tokens. + * @dev This is a variant of {BridgeERC20} that implements the bridge logic for ERC-20 tokens that do not expose mint + * and burn mechanism. Instead it takes custody of bridged assets. */ abstract contract BridgeERC20Custodial is BridgeERC20 { using SafeERC20 for IERC20; From bd4095cf0f4d7cf432fdbe5c43fbbc8aba823cca Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Mon, 1 Sep 2025 16:53:53 +0200 Subject: [PATCH 11/19] remove the "with attributes" variant of crosschainTransfer + testing --- contracts/crosschain/bridges/BridgeERC20.sol | 29 +-- .../ERC20/extensions/ERC20Crosschain.sol | 16 +- .../ERC20/extensions/ERC20Crosschain.test.js | 202 ++++++++++++++++++ 3 files changed, 209 insertions(+), 38 deletions(-) create mode 100644 test/token/ERC20/extensions/ERC20Crosschain.test.js diff --git a/contracts/crosschain/bridges/BridgeERC20.sol b/contracts/crosschain/bridges/BridgeERC20.sol index 9c328bceecf..dba69b1ddb3 100644 --- a/contracts/crosschain/bridges/BridgeERC20.sol +++ b/contracts/crosschain/bridges/BridgeERC20.sol @@ -22,34 +22,13 @@ abstract contract BridgeERC20 is BridgeCore { event CrossChainTransferSent(bytes32 indexed sendId, address indexed from, bytes to, uint256 amount); event CrossChainTransferReceived(bytes32 indexed receiveId, bytes from, address indexed to, uint256 amount); - /** - * @dev Transfer `amount` tokens to a crosschain receiver. - * - * This is a variant of {crosschainTransfer-bytes-uint256-bytes[]} with an empty attribute list. - * - * NOTE: This function is not virtual and should not be overriden. Consider overriding - * {crosschainTransfer-bytes-uint256-bytes[]} instead. - */ - function crosschainTransfer(bytes memory to, uint256 amount) public returns (bytes32) { - return crosschainTransfer(to, amount, new bytes[](0)); - } - /// @dev Transfer `amount` tokens to a crosschain receiver. - function crosschainTransfer( - bytes memory to, - uint256 amount, - bytes[] memory attributes - ) public virtual returns (bytes32) { - return _crosschainTransfer(msg.sender, to, amount, attributes); + function crosschainTransfer(bytes memory to, uint256 amount) public virtual returns (bytes32) { + return _crosschainTransfer(msg.sender, to, amount); } /// @dev Internal crosschain transfer function. - function _crosschainTransfer( - address from, - bytes memory to, - uint256 amount, - bytes[] memory attributes - ) internal virtual returns (bytes32) { + function _crosschainTransfer(address from, bytes memory to, uint256 amount) internal virtual returns (bytes32) { _lock(from, amount); (bytes2 chainType, bytes memory chainReference, bytes memory addr) = to.parseV1(); @@ -58,7 +37,7 @@ abstract contract BridgeERC20 is BridgeCore { bytes32 sendId = _sendMessage( chain, abi.encode(InteroperableAddress.formatEvmV1(block.chainid, from), addr, amount), - attributes + new bytes[](0) ); emit CrossChainTransferSent(sendId, from, to, amount); diff --git a/contracts/token/ERC20/extensions/ERC20Crosschain.sol b/contracts/token/ERC20/extensions/ERC20Crosschain.sol index 208fdeb1bc2..1984c542f4a 100644 --- a/contracts/token/ERC20/extensions/ERC20Crosschain.sol +++ b/contracts/token/ERC20/extensions/ERC20Crosschain.sol @@ -6,20 +6,10 @@ import {ERC20} from "../ERC20.sol"; import {BridgeERC20} from "../../../crosschain/bridges/BridgeERC20.sol"; abstract contract ERC20Crosschain is ERC20, BridgeERC20 { - /// @dev TransferFrom variant of {crosschainTransferFrom-bytes-uint256}, using ERC20 allowance from the sender to the caller. - function crosschainTransferFrom(address from, bytes memory to, uint256 amount) public returns (bytes32) { - return crosschainTransferFrom(from, to, amount, new bytes[](0)); - } - - /// @dev TransferFrom variant of {crosschainTransferFrom-bytes-uint256-bytes[]}, using ERC20 allowance from the sender to the caller. - function crosschainTransferFrom( - address from, - bytes memory to, - uint256 amount, - bytes[] memory attributes - ) public virtual returns (bytes32) { + /// @dev TransferFrom variant of {crosschainTransferFrom}, using ERC20 allowance from the sender to the caller. + function crosschainTransferFrom(address from, bytes memory to, uint256 amount) public virtual returns (bytes32) { _spendAllowance(from, msg.sender, amount); - return _crosschainTransfer(from, to, amount, attributes); + return _crosschainTransfer(from, to, amount); } /// @dev "Locking" tokens is achieved through burning diff --git a/test/token/ERC20/extensions/ERC20Crosschain.test.js b/test/token/ERC20/extensions/ERC20Crosschain.test.js new file mode 100644 index 00000000000..71c54e9fd7e --- /dev/null +++ b/test/token/ERC20/extensions/ERC20Crosschain.test.js @@ -0,0 +1,202 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const { anyValue } = require('@nomicfoundation/hardhat-chai-matchers/withArgs'); + +const { impersonate } = require('../../../helpers/account'); +const { getLocalChain } = require('../../../helpers/chains'); + +const amount = 100n; + +async function fixture() { + const chain = await getLocalChain(); + const accounts = await ethers.getSigners(); + + // Mock gateway + const gateway = await ethers.deployContract('$ERC7786GatewayMock'); + const gatewayAsEOA = await impersonate(gateway); + + // Chain A: legacy ERC20 with bridge + const tokenA = await ethers.deployContract('$ERC20Crosschain', ['Token1', 'T1', gateway, []]); + const bridgeA = tokenA; // self bridge + + // Chain B: ERC7802 with bridge + const tokenB = await ethers.deployContract('$ERC20BridgeableMock', ['Token2', 'T2', ethers.ZeroAddress]); + const bridgeB = await ethers.deployContract('$BridgeERC20Bridgeable', [gateway, [], tokenB]); + + // deployment check + remote setup + await expect(bridgeA.deploymentTransaction()).to.emit(bridgeA, 'GatewayChange').withArgs(ethers.ZeroAddress, gateway); + await expect(bridgeB.deploymentTransaction()).to.emit(bridgeB, 'GatewayChange').withArgs(ethers.ZeroAddress, gateway); + await expect(bridgeA.$_registerRemote(chain.toErc7930(bridgeB), false)) + .to.emit(bridgeA, 'RemoteRegistered') + .withArgs(chain.toErc7930(bridgeB)); + await expect(bridgeB.$_registerRemote(chain.toErc7930(bridgeA), false)) + .to.emit(bridgeB, 'RemoteRegistered') + .withArgs(chain.toErc7930(bridgeA)); + await tokenB.$_setBridge(bridgeB); + + // helper + const encodePayload = (from, to, amount) => + ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes', 'bytes', 'uint256'], + [chain.toErc7930(from), to.target ?? to.address ?? to, amount], + ); + + return { chain, accounts, gateway, gatewayAsEOA, tokenA, tokenB, bridgeA, bridgeB, encodePayload }; +} + +describe('CrosschainBridgeERC20', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + it('initial setup', async function () { + await expect(this.bridgeA.gateway()).to.eventually.equal(this.gateway); + await expect(this.bridgeA.remote(this.chain.erc7930)).to.eventually.equal(this.chain.toErc7930(this.bridgeB)); + await expect(this.bridgeB.token()).to.eventually.equal(this.tokenB); + await expect(this.bridgeB.gateway()).to.eventually.equal(this.gateway); + await expect(this.bridgeB.remote(this.chain.erc7930)).to.eventually.equal(this.chain.toErc7930(this.bridgeA)); + }); + + it('crosschain send', async function () { + const [alice, bruce, chris] = this.accounts; + + await this.tokenA.$_mint(alice, amount); + await this.tokenA.connect(alice).approve(this.bridgeA, ethers.MaxUint256); + + await expect(this.tokenA.totalSupply()).to.eventually.equal(amount); + await expect(this.tokenA.balanceOf(this.bridgeA)).to.eventually.equal(0n); + await expect(this.tokenB.totalSupply()).to.eventually.equal(0n); + + // Alice sends tokens from chain A to Bruce on chain B. + await expect(this.bridgeA.connect(alice).crosschainTransfer(this.chain.toErc7930(bruce), amount)) + // bridge on chain A takes custody of the funds + .to.emit(this.tokenA, 'Transfer') + .withArgs(alice, ethers.ZeroAddress, amount) + // crosschain transfer sent + .to.emit(this.bridgeA, 'CrossChainTransferSent') + .withArgs(anyValue, alice, this.chain.toErc7930(bruce), amount) + // ERC-7786 event + .to.emit(this.gateway, 'MessageSent') + // crosschain transfer received + .to.emit(this.bridgeB, 'CrossChainTransferReceived') + .withArgs(anyValue, this.chain.toErc7930(alice), bruce, amount) + // crosschain mint event + .to.emit(this.tokenB, 'CrosschainMint') + .withArgs(bruce, amount, this.bridgeB) + // tokens are minted on chain B + .to.emit(this.tokenB, 'Transfer') + .withArgs(ethers.ZeroAddress, bruce, amount); + + await expect(this.tokenA.totalSupply()).to.eventually.equal(0n); + await expect(this.tokenB.totalSupply()).to.eventually.equal(amount); + await expect(this.tokenB.balanceOf(bruce)).to.eventually.equal(amount); + + // Bruce sends tokens from chain B to Chris on chain A. + await expect(this.bridgeB.connect(bruce).crosschainTransfer(this.chain.toErc7930(chris), amount)) + // tokens are burned on chain B + .to.emit(this.tokenB, 'Transfer') + .withArgs(bruce, ethers.ZeroAddress, amount) + // crosschain burn event + .to.emit(this.tokenB, 'CrosschainBurn') + .withArgs(bruce, amount, this.bridgeB) + // crosschain transfer sent + .to.emit(this.bridgeB, 'CrossChainTransferSent') + .withArgs(anyValue, bruce, this.chain.toErc7930(chris), amount) + // ERC-7786 event + .to.emit(this.gateway, 'MessageSent') + // crosschain transfer received + .to.emit(this.bridgeA, 'CrossChainTransferReceived') + .withArgs(anyValue, this.chain.toErc7930(bruce), chris, amount) + // bridge on chain A releases custody of the funds + .to.emit(this.tokenA, 'Transfer') + .withArgs(ethers.ZeroAddress, chris, amount); + + await expect(this.tokenA.totalSupply()).to.eventually.equal(amount); + await expect(this.tokenA.balanceOf(chris)).to.eventually.equal(amount); + await expect(this.tokenB.totalSupply()).to.eventually.equal(0n); + }); + + describe('crosschain operations', function () { + beforeEach(async function () { + await this.tokenA.$_mint(this.bridgeA, 1_000_000_000n); + }); + + it('only gateway can relay messages', async function () { + const [notGateway] = this.accounts; + + await expect( + this.bridgeA + .connect(notGateway) + .receiveMessage( + ethers.ZeroHash, + this.chain.toErc7930(this.tokenB), + this.encodePayload(notGateway, notGateway, amount), + ), + ) + .to.be.revertedWithCustomError(this.bridgeA, 'InvalidGateway') + .withArgs(notGateway); + }); + + it('only remote can send a crosschain message', async function () { + const [notRemote] = this.accounts; + + await expect( + this.gateway + .connect(notRemote) + .sendMessage(this.chain.toErc7930(this.bridgeA), this.encodePayload(notRemote, notRemote, amount), []), + ) + .to.be.revertedWithCustomError(this.bridgeA, 'InvalidSender') + .withArgs(this.chain.toErc7930(notRemote)); + }); + + it('cannot replay message', async function () { + const [from, to] = this.accounts; + + const id = ethers.ZeroHash; + const payload = this.encodePayload(from, to, amount); + + // first time works + await expect( + this.bridgeA.connect(this.gatewayAsEOA).receiveMessage(id, this.chain.toErc7930(this.bridgeB), payload), + ).to.emit(this.bridgeA, 'CrossChainTransferReceived'); + + // second time fails + await expect( + this.bridgeA.connect(this.gatewayAsEOA).receiveMessage(id, this.chain.toErc7930(this.bridgeB), payload), + ) + .to.be.revertedWithCustomError(this.bridgeA, 'MessageAlreadyProcessed') + .withArgs(this.gateway, id); + }); + }); + + describe('administration', function () { + it('updating the gateway emits an event', async function () { + const newGateway = await ethers.deployContract('$ERC7786GatewayMock'); + + await expect(this.bridgeA.$_setGateway(newGateway)) + .to.emit(this.bridgeA, 'GatewayChange') + .withArgs(this.gateway, newGateway); + + await expect(this.bridgeA.gateway()).to.eventually.equal(newGateway); + }); + + it('updating a remote emits an event', async function () { + const newRemote = this.chain.toErc7930(this.accounts[0]); + + await expect(this.bridgeA.$_registerRemote(newRemote, true)) + .to.emit(this.bridgeA, 'RemoteRegistered') + .withArgs(newRemote); + + await expect(this.bridgeA.remote(this.chain.erc7930)).to.eventually.equal(newRemote); + }); + + it('remote update protection', async function () { + const newRemote = this.chain.toErc7930(this.accounts[0]); + + await expect(this.bridgeA.$_registerRemote(newRemote, false)) + .to.be.revertedWithCustomError(this.bridgeA, 'RemoteAlreadyRegistered') + .withArgs(this.chain.toErc7930(this.bridgeB)); + }); + }); +}); From e6e5d79264bb1921fef22973268daf076fb3813a Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Mon, 1 Sep 2025 21:15:59 +0200 Subject: [PATCH 12/19] update Bridge: use chain specific gateway --- contracts/crosschain/bridges/BridgeCore.sol | 76 ++++---- test/crosschain/BridgeERC20.behavior.js | 160 ++++++++++++++++ test/crosschain/BridgeERC20.test.js | 175 +---------------- .../ERC20/extensions/ERC20Crosschain.test.js | 178 +----------------- 4 files changed, 215 insertions(+), 374 deletions(-) create mode 100644 test/crosschain/BridgeERC20.behavior.js diff --git a/contracts/crosschain/bridges/BridgeCore.sol b/contracts/crosschain/bridges/BridgeCore.sol index e0f547d422a..3e2bd06c27d 100644 --- a/contracts/crosschain/bridges/BridgeCore.sol +++ b/contracts/crosschain/bridges/BridgeCore.sol @@ -10,8 +10,8 @@ import {Bytes} from "../../utils/Bytes.sol"; /** * @dev Core bridging mechanism. * - * This contract contains the logic to register and send messages to counterparts on remote chains using an ERC-7786 - * gateway. It ensure received message originate from for a counterpart. This is the code of token bridges such as + * This contract contains the logic to register and send messages to counterparts on remote chains using ERC-7786 + * gateways. It ensure received message originate from for a counterpart. This is the code of token bridges such as * {BridgeERC20}. * * Contract that inherit from this contract can use the internal {_senMessage} to send messages to their conterpart @@ -19,57 +19,47 @@ import {Bytes} from "../../utils/Bytes.sol"; */ abstract contract BridgeCore is IERC7786Recipient { using BitMaps for BitMaps.BitMap; + using Bytes for bytes; using InteroperableAddress for bytes; - address private _gateway; - mapping(bytes chain => bytes) private _remotes; + struct Link { + address gateway; + bytes remote; + } + mapping(bytes chain => Link) private _links; mapping(address gateway => BitMaps.BitMap) private _received; - event GatewayChange(address oldGateway, address newGateway); - event RemoteRegistered(bytes remote); + event RemoteRegistered(address gateway, bytes remote); - error InvalidGateway(address gateway); - error InvalidSender(bytes sender); + error InvalidGatewayForChain(address gateway, bytes chain); + error InvalidRemoteForChain(bytes remote, bytes chain); + error RemoteAlreadyRegistered(bytes chain); error MessageAlreadyProcessed(address gateway, bytes32 receiveId); - error RemoteAlreadyRegistered(bytes remote); - constructor(address initialGateway, bytes[] memory initialRemotes) { - _setGateway(initialGateway); - for (uint256 i = 0; i < initialRemotes.length; ++i) { - _registerRemote(initialRemotes[i], false); + constructor(Link[] memory links) { + for (uint256 i = 0; i < links.length; ++i) { + _setLink(links[0].gateway, links[0].remote, false); } } - /// @dev Returns the ERC-7786 gateway used for sending and receiving cross-chain messages - function gateway() public view virtual returns (address) { - return _gateway; - } - - /// @dev Returns the interoperable address of the corresponding token on a given chain - function remote(bytes memory chain) public view virtual returns (bytes memory) { - return _remotes[chain]; + /// @dev Returns the ERC-7786 gateway used for sending and receiving cross-chain messages to a given chain + function link(bytes memory chain) public view virtual returns (address gateway, bytes memory remote) { + Link storage self = _links[chain]; + return (self.gateway, self.remote); } - /// @dev Internal setter to change the ERC-7786 gateway. Called at construction. - function _setGateway(address newGateway) internal virtual { - // Sanity check, this should revert if newGateway is not an ERC-7786 implementation. Note that since + /// @dev Internal setter to change the ERC-7786 gateway and remote for a given chain. Called at construction. + function _setLink(address gateway, bytes memory remote, 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(newGateway).supportsAttribute(bytes4(0)); - - address oldGateway = _gateway; - _gateway = newGateway; + IERC7786GatewaySource(gateway).supportsAttribute(bytes4(0)); - emit GatewayChange(oldGateway, newGateway); - } - - /// @dev Internal setter to change the ERC-7786 gateway. Called at construction. - function _registerRemote(bytes memory remoteToken, bool allowOverride) internal virtual { - bytes memory chain = _extractChain(remoteToken); - if (allowOverride || _remotes[chain].length == 0) { - _remotes[chain] = remoteToken; - emit RemoteRegistered(remoteToken); + bytes memory chain = _extractChain(remote); + if (allowOverride || _links[chain].gateway == address(0)) { + _links[chain] = Link(gateway, remote); + emit RemoteRegistered(gateway, remote); } else { - revert RemoteAlreadyRegistered(_remotes[chain]); + revert RemoteAlreadyRegistered(chain); } } @@ -79,7 +69,8 @@ abstract contract BridgeCore is IERC7786Recipient { bytes memory payload, bytes[] memory attributes ) internal virtual returns (bytes32) { - return IERC7786GatewaySource(gateway()).sendMessage(remote(chain), payload, attributes); + (address gateway, bytes memory remote) = link(chain); + return IERC7786GatewaySource(gateway).sendMessage(remote, payload, attributes); } /// @inheritdoc IERC7786Recipient @@ -88,11 +79,14 @@ abstract contract BridgeCore is IERC7786Recipient { bytes calldata sender, bytes calldata payload ) public payable virtual returns (bytes4) { + bytes memory chain = _extractChain(sender); + (address gateway, bytes memory router) = link(chain); + // Security restriction: // - sender must be the remote for that chain // - message was not processed yet - require(msg.sender == gateway(), InvalidGateway(msg.sender)); - require(Bytes.equal(remote(_extractChain(sender)), sender), InvalidSender(sender)); + require(msg.sender == gateway, InvalidGatewayForChain(msg.sender, chain)); + require(sender.equal(router), InvalidRemoteForChain(sender, chain)); require(!_received[msg.sender].get(uint256(receiveId)), MessageAlreadyProcessed(msg.sender, receiveId)); _received[msg.sender].set(uint256(receiveId)); diff --git a/test/crosschain/BridgeERC20.behavior.js b/test/crosschain/BridgeERC20.behavior.js new file mode 100644 index 00000000000..c061e884105 --- /dev/null +++ b/test/crosschain/BridgeERC20.behavior.js @@ -0,0 +1,160 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { anyValue } = require('@nomicfoundation/hardhat-chai-matchers/withArgs'); + +const amount = 100n; + +function shouldBehaveLikeBridgeERC20({ chainAIsCustodial = false, chainBIsCustodial = false } = {}) { + beforeEach(function () { + // helper + this.encodePayload = (from, to, amount) => + ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes', 'bytes', 'uint256'], + [this.chain.toErc7930(from), to.target ?? to.address ?? to, amount], + ); + }); + + it('bridge setup', async function () { + await expect(this.bridgeA.link(this.chain.erc7930)).to.eventually.deep.equal([ + this.gateway.target, + this.chain.toErc7930(this.bridgeB), + ]); + await expect(this.bridgeB.link(this.chain.erc7930)).to.eventually.deep.equal([ + this.gateway.target, + this.chain.toErc7930(this.bridgeA), + ]); + }); + + it('crosschain send (both direction)', async function () { + const [alice, bruce, chris] = this.accounts; + + await this.tokenA.$_mint(alice, amount); + await this.tokenA.connect(alice).approve(this.bridgeA, ethers.MaxUint256); + + // Alice sends tokens from chain A to Bruce on chain B. + await expect(this.bridgeA.connect(alice).crosschainTransfer(this.chain.toErc7930(bruce), amount)) + // bridge on chain A takes custody of the funds + .to.emit(this.tokenA, 'Transfer') + .withArgs(alice, chainAIsCustodial ? this.bridgeA : ethers.ZeroAddress, amount) + // crosschain transfer sent + .to.emit(this.bridgeA, 'CrossChainTransferSent') + .withArgs(anyValue, alice, this.chain.toErc7930(bruce), amount) + // ERC-7786 event + .to.emit(this.gateway, 'MessageSent') + // crosschain transfer received + .to.emit(this.bridgeB, 'CrossChainTransferReceived') + .withArgs(anyValue, this.chain.toErc7930(alice), bruce, amount) + // crosschain mint event + .to.emit(this.tokenB, 'CrosschainMint') + .withArgs(bruce, amount, this.bridgeB) + // tokens are minted on chain B + .to.emit(this.tokenB, 'Transfer') + .withArgs(chainBIsCustodial ? this.bridgeB : ethers.ZeroAddress, bruce, amount); + + // Bruce sends tokens from chain B to Chris on chain A. + await expect(this.bridgeB.connect(bruce).crosschainTransfer(this.chain.toErc7930(chris), amount)) + // tokens are burned on chain B + .to.emit(this.tokenB, 'Transfer') + .withArgs(bruce, chainBIsCustodial ? this.bridgeB : ethers.ZeroAddress, amount) + // crosschain burn event + .to.emit(this.tokenB, 'CrosschainBurn') + .withArgs(bruce, amount, this.bridgeB) + // crosschain transfer sent + .to.emit(this.bridgeB, 'CrossChainTransferSent') + .withArgs(anyValue, bruce, this.chain.toErc7930(chris), amount) + // ERC-7786 event + .to.emit(this.gateway, 'MessageSent') + // crosschain transfer received + .to.emit(this.bridgeA, 'CrossChainTransferReceived') + .withArgs(anyValue, this.chain.toErc7930(bruce), chris, amount) + // bridge on chain A releases custody of the funds + .to.emit(this.tokenA, 'Transfer') + .withArgs(chainAIsCustodial ? this.bridgeA : ethers.ZeroAddress, chris, amount); + }); + + describe('restrictions', function () { + beforeEach(async function () { + await this.tokenA.$_mint(this.bridgeA, 1_000_000_000n); + }); + + it('only gateway can relay messages', async function () { + const [notGateway] = this.accounts; + + await expect( + this.bridgeA + .connect(notGateway) + .receiveMessage( + ethers.ZeroHash, + this.chain.toErc7930(this.tokenB), + this.encodePayload(notGateway, notGateway, amount), + ), + ) + .to.be.revertedWithCustomError(this.bridgeA, 'InvalidGatewayForChain') + .withArgs(notGateway, this.chain.erc7930); + }); + + it('only remote can send a crosschain message', async function () { + const [notRemote] = this.accounts; + + await expect( + this.gateway + .connect(notRemote) + .sendMessage(this.chain.toErc7930(this.bridgeA), this.encodePayload(notRemote, notRemote, amount), []), + ) + .to.be.revertedWithCustomError(this.bridgeA, 'InvalidRemoteForChain') + .withArgs(this.chain.toErc7930(notRemote), this.chain.erc7930); + }); + + it('cannot replay message', async function () { + const [from, to] = this.accounts; + + const id = ethers.ZeroHash; + const payload = this.encodePayload(from, to, amount); + + // first time works + await expect( + this.bridgeA.connect(this.gatewayAsEOA).receiveMessage(id, this.chain.toErc7930(this.bridgeB), payload), + ).to.emit(this.bridgeA, 'CrossChainTransferReceived'); + + // second time fails + await expect( + this.bridgeA.connect(this.gatewayAsEOA).receiveMessage(id, this.chain.toErc7930(this.bridgeB), payload), + ) + .to.be.revertedWithCustomError(this.bridgeA, 'MessageAlreadyProcessed') + .withArgs(this.gateway, id); + }); + }); + + describe('reconfiguration', function () { + it('updating a link emits an event', async function () { + const newGateway = await ethers.deployContract('$ERC7786GatewayMock'); + const newRemote = this.chain.toErc7930(this.accounts[0]); + + await expect(this.bridgeA.$_setLink(newGateway, newRemote, true)) + .to.emit(this.bridgeA, 'RemoteRegistered') + .withArgs(newGateway, newRemote); + + await expect(this.bridgeA.link(this.chain.erc7930)).to.eventually.deep.equal([newGateway.target, newRemote]); + }); + + it('cannot override configuration is "allowOverride" is false', async function () { + const newGateway = await ethers.deployContract('$ERC7786GatewayMock'); + const newRemote = this.chain.toErc7930(this.accounts[0]); + + await expect(this.bridgeA.$_setLink(newGateway, newRemote, false)) + .to.be.revertedWithCustomError(this.bridgeA, 'RemoteAlreadyRegistered') + .withArgs(this.chain.erc7930); + }); + + it('reject invalid gateway', async function () { + const notAGateway = this.accounts[0]; + const newRemote = this.chain.toErc7930(this.accounts[0]); + + await expect(this.bridgeA.$_setLink(notAGateway, newRemote, false)).to.be.revertedWithoutReason(); + }); + }); +} + +module.exports = { + shouldBehaveLikeBridgeERC20, +}; diff --git a/test/crosschain/BridgeERC20.test.js b/test/crosschain/BridgeERC20.test.js index 1149dcbc217..173dba8beca 100644 --- a/test/crosschain/BridgeERC20.test.js +++ b/test/crosschain/BridgeERC20.test.js @@ -1,12 +1,11 @@ const { ethers } = require('hardhat'); const { expect } = require('chai'); const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); -const { anyValue } = require('@nomicfoundation/hardhat-chai-matchers/withArgs'); const { impersonate } = require('../helpers/account'); const { getLocalChain } = require('../helpers/chains'); -const amount = 100n; +const { shouldBehaveLikeBridgeERC20 } = require('./BridgeERC20.behavior'); async function fixture() { const chain = await getLocalChain(); @@ -18,31 +17,22 @@ async function fixture() { // Chain A: legacy ERC20 with bridge const tokenA = await ethers.deployContract('$ERC20', ['Token1', 'T1']); - const bridgeA = await ethers.deployContract('$BridgeERC20Custodial', [gateway, [], tokenA]); + const bridgeA = await ethers.deployContract('$BridgeERC20Custodial', [[], tokenA]); // Chain B: ERC7802 with bridge const tokenB = await ethers.deployContract('$ERC20BridgeableMock', ['Token2', 'T2', ethers.ZeroAddress]); - const bridgeB = await ethers.deployContract('$BridgeERC20Bridgeable', [gateway, [], tokenB]); + const bridgeB = await ethers.deployContract('$BridgeERC20Bridgeable', [[], tokenB]); // deployment check + remote setup - await expect(bridgeA.deploymentTransaction()).to.emit(bridgeA, 'GatewayChange').withArgs(ethers.ZeroAddress, gateway); - await expect(bridgeB.deploymentTransaction()).to.emit(bridgeB, 'GatewayChange').withArgs(ethers.ZeroAddress, gateway); - await expect(bridgeA.$_registerRemote(chain.toErc7930(bridgeB), false)) + await expect(bridgeA.$_setLink(gateway, chain.toErc7930(bridgeB), false)) .to.emit(bridgeA, 'RemoteRegistered') - .withArgs(chain.toErc7930(bridgeB)); - await expect(bridgeB.$_registerRemote(chain.toErc7930(bridgeA), false)) + .withArgs(gateway, chain.toErc7930(bridgeB)); + await expect(bridgeB.$_setLink(gateway, chain.toErc7930(bridgeA), false)) .to.emit(bridgeB, 'RemoteRegistered') - .withArgs(chain.toErc7930(bridgeA)); + .withArgs(gateway, chain.toErc7930(bridgeA)); await tokenB.$_setBridge(bridgeB); - // helper - const encodePayload = (from, to, amount) => - ethers.AbiCoder.defaultAbiCoder().encode( - ['bytes', 'bytes', 'uint256'], - [chain.toErc7930(from), to.target ?? to.address ?? to, amount], - ); - - return { chain, accounts, gateway, gatewayAsEOA, tokenA, tokenB, bridgeA, bridgeB, encodePayload }; + return { chain, accounts, gateway, gatewayAsEOA, tokenA, tokenB, bridgeA, bridgeB }; } describe('CrosschainBridgeERC20', function () { @@ -50,155 +40,10 @@ describe('CrosschainBridgeERC20', function () { Object.assign(this, await loadFixture(fixture)); }); - it('initial setup', async function () { + it('token getters', async function () { await expect(this.bridgeA.token()).to.eventually.equal(this.tokenA); - await expect(this.bridgeA.gateway()).to.eventually.equal(this.gateway); - await expect(this.bridgeA.remote(this.chain.erc7930)).to.eventually.equal(this.chain.toErc7930(this.bridgeB)); await expect(this.bridgeB.token()).to.eventually.equal(this.tokenB); - await expect(this.bridgeB.gateway()).to.eventually.equal(this.gateway); - await expect(this.bridgeB.remote(this.chain.erc7930)).to.eventually.equal(this.chain.toErc7930(this.bridgeA)); - }); - - it('crosschain send', async function () { - const [alice, bruce, chris] = this.accounts; - - await this.tokenA.$_mint(alice, amount); - await this.tokenA.connect(alice).approve(this.bridgeA, ethers.MaxUint256); - - await expect(this.tokenA.totalSupply()).to.eventually.equal(amount); - await expect(this.tokenA.balanceOf(this.bridgeA)).to.eventually.equal(0n); - await expect(this.tokenB.totalSupply()).to.eventually.equal(0n); - - // Alice sends tokens from chain A to Bruce on chain B. - await expect(this.bridgeA.connect(alice).crosschainTransfer(this.chain.toErc7930(bruce), amount)) - // bridge on chain A takes custody of the funds - .to.emit(this.tokenA, 'Transfer') - .withArgs(alice, this.bridgeA, amount) - // crosschain transfer sent - .to.emit(this.bridgeA, 'CrossChainTransferSent') - .withArgs(anyValue, alice, this.chain.toErc7930(bruce), amount) - // ERC-7786 event - .to.emit(this.gateway, 'MessageSent') - // crosschain transfer received - .to.emit(this.bridgeB, 'CrossChainTransferReceived') - .withArgs(anyValue, this.chain.toErc7930(alice), bruce, amount) - // crosschain mint event - .to.emit(this.tokenB, 'CrosschainMint') - .withArgs(bruce, amount, this.bridgeB) - // tokens are minted on chain B - .to.emit(this.tokenB, 'Transfer') - .withArgs(ethers.ZeroAddress, bruce, amount); - - await expect(this.tokenA.totalSupply()).to.eventually.equal(amount); - await expect(this.tokenA.balanceOf(this.bridgeA)).to.eventually.equal(amount); - await expect(this.tokenB.totalSupply()).to.eventually.equal(amount); - await expect(this.tokenB.balanceOf(bruce)).to.eventually.equal(amount); - - // Bruce sends tokens from chain B to Chris on chain A. - await expect(this.bridgeB.connect(bruce).crosschainTransfer(this.chain.toErc7930(chris), amount)) - // tokens are burned on chain B - .to.emit(this.tokenB, 'Transfer') - .withArgs(bruce, ethers.ZeroAddress, amount) - // crosschain burn event - .to.emit(this.tokenB, 'CrosschainBurn') - .withArgs(bruce, amount, this.bridgeB) - // crosschain transfer sent - .to.emit(this.bridgeB, 'CrossChainTransferSent') - .withArgs(anyValue, bruce, this.chain.toErc7930(chris), amount) - // ERC-7786 event - .to.emit(this.gateway, 'MessageSent') - // crosschain transfer received - .to.emit(this.bridgeA, 'CrossChainTransferReceived') - .withArgs(anyValue, this.chain.toErc7930(bruce), chris, amount) - // bridge on chain A releases custody of the funds - .to.emit(this.tokenA, 'Transfer') - .withArgs(this.bridgeA, chris, amount); - - await expect(this.tokenA.totalSupply()).to.eventually.equal(amount); - await expect(this.tokenA.balanceOf(chris)).to.eventually.equal(amount); - await expect(this.tokenB.totalSupply()).to.eventually.equal(0n); }); - describe('crosschain operations', function () { - beforeEach(async function () { - await this.tokenA.$_mint(this.bridgeA, 1_000_000_000n); - }); - - it('only gateway can relay messages', async function () { - const [notGateway] = this.accounts; - - await expect( - this.bridgeA - .connect(notGateway) - .receiveMessage( - ethers.ZeroHash, - this.chain.toErc7930(this.tokenB), - this.encodePayload(notGateway, notGateway, amount), - ), - ) - .to.be.revertedWithCustomError(this.bridgeA, 'InvalidGateway') - .withArgs(notGateway); - }); - - it('only remote can send a crosschain message', async function () { - const [notRemote] = this.accounts; - - await expect( - this.gateway - .connect(notRemote) - .sendMessage(this.chain.toErc7930(this.bridgeA), this.encodePayload(notRemote, notRemote, amount), []), - ) - .to.be.revertedWithCustomError(this.bridgeA, 'InvalidSender') - .withArgs(this.chain.toErc7930(notRemote)); - }); - - it('cannot replay message', async function () { - const [from, to] = this.accounts; - - const id = ethers.ZeroHash; - const payload = this.encodePayload(from, to, amount); - - // first time works - await expect( - this.bridgeA.connect(this.gatewayAsEOA).receiveMessage(id, this.chain.toErc7930(this.bridgeB), payload), - ).to.emit(this.bridgeA, 'CrossChainTransferReceived'); - - // second time fails - await expect( - this.bridgeA.connect(this.gatewayAsEOA).receiveMessage(id, this.chain.toErc7930(this.bridgeB), payload), - ) - .to.be.revertedWithCustomError(this.bridgeA, 'MessageAlreadyProcessed') - .withArgs(this.gateway, id); - }); - }); - - describe('administration', function () { - it('updating the gateway emits an event', async function () { - const newGateway = await ethers.deployContract('$ERC7786GatewayMock'); - - await expect(this.bridgeA.$_setGateway(newGateway)) - .to.emit(this.bridgeA, 'GatewayChange') - .withArgs(this.gateway, newGateway); - - await expect(this.bridgeA.gateway()).to.eventually.equal(newGateway); - }); - - it('updating a remote emits an event', async function () { - const newRemote = this.chain.toErc7930(this.accounts[0]); - - await expect(this.bridgeA.$_registerRemote(newRemote, true)) - .to.emit(this.bridgeA, 'RemoteRegistered') - .withArgs(newRemote); - - await expect(this.bridgeA.remote(this.chain.erc7930)).to.eventually.equal(newRemote); - }); - - it('remote update protection', async function () { - const newRemote = this.chain.toErc7930(this.accounts[0]); - - await expect(this.bridgeA.$_registerRemote(newRemote, false)) - .to.be.revertedWithCustomError(this.bridgeA, 'RemoteAlreadyRegistered') - .withArgs(this.chain.toErc7930(this.bridgeB)); - }); - }); + shouldBehaveLikeBridgeERC20({ chainAIsCustodial: true }); }); diff --git a/test/token/ERC20/extensions/ERC20Crosschain.test.js b/test/token/ERC20/extensions/ERC20Crosschain.test.js index 71c54e9fd7e..0bc06349821 100644 --- a/test/token/ERC20/extensions/ERC20Crosschain.test.js +++ b/test/token/ERC20/extensions/ERC20Crosschain.test.js @@ -1,12 +1,11 @@ const { ethers } = require('hardhat'); const { expect } = require('chai'); const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); -const { anyValue } = require('@nomicfoundation/hardhat-chai-matchers/withArgs'); const { impersonate } = require('../../../helpers/account'); const { getLocalChain } = require('../../../helpers/chains'); -const amount = 100n; +const { shouldBehaveLikeBridgeERC20 } = require('../../../crosschain/BridgeERC20.behavior'); async function fixture() { const chain = await getLocalChain(); @@ -17,186 +16,29 @@ async function fixture() { const gatewayAsEOA = await impersonate(gateway); // Chain A: legacy ERC20 with bridge - const tokenA = await ethers.deployContract('$ERC20Crosschain', ['Token1', 'T1', gateway, []]); + const tokenA = await ethers.deployContract('$ERC20Crosschain', ['Token1', 'T1', []]); const bridgeA = tokenA; // self bridge // Chain B: ERC7802 with bridge const tokenB = await ethers.deployContract('$ERC20BridgeableMock', ['Token2', 'T2', ethers.ZeroAddress]); - const bridgeB = await ethers.deployContract('$BridgeERC20Bridgeable', [gateway, [], tokenB]); + const bridgeB = await ethers.deployContract('$BridgeERC20Bridgeable', [[], tokenB]); // deployment check + remote setup - await expect(bridgeA.deploymentTransaction()).to.emit(bridgeA, 'GatewayChange').withArgs(ethers.ZeroAddress, gateway); - await expect(bridgeB.deploymentTransaction()).to.emit(bridgeB, 'GatewayChange').withArgs(ethers.ZeroAddress, gateway); - await expect(bridgeA.$_registerRemote(chain.toErc7930(bridgeB), false)) + await expect(bridgeA.$_setLink(gateway, chain.toErc7930(bridgeB), false)) .to.emit(bridgeA, 'RemoteRegistered') - .withArgs(chain.toErc7930(bridgeB)); - await expect(bridgeB.$_registerRemote(chain.toErc7930(bridgeA), false)) + .withArgs(gateway, chain.toErc7930(bridgeB)); + await expect(bridgeB.$_setLink(gateway, chain.toErc7930(bridgeA), false)) .to.emit(bridgeB, 'RemoteRegistered') - .withArgs(chain.toErc7930(bridgeA)); + .withArgs(gateway, chain.toErc7930(bridgeA)); await tokenB.$_setBridge(bridgeB); - // helper - const encodePayload = (from, to, amount) => - ethers.AbiCoder.defaultAbiCoder().encode( - ['bytes', 'bytes', 'uint256'], - [chain.toErc7930(from), to.target ?? to.address ?? to, amount], - ); - - return { chain, accounts, gateway, gatewayAsEOA, tokenA, tokenB, bridgeA, bridgeB, encodePayload }; + return { chain, accounts, gateway, gatewayAsEOA, tokenA, tokenB, bridgeA, bridgeB }; } -describe('CrosschainBridgeERC20', function () { +describe('ERC20Crosschain', function () { beforeEach(async function () { Object.assign(this, await loadFixture(fixture)); }); - it('initial setup', async function () { - await expect(this.bridgeA.gateway()).to.eventually.equal(this.gateway); - await expect(this.bridgeA.remote(this.chain.erc7930)).to.eventually.equal(this.chain.toErc7930(this.bridgeB)); - await expect(this.bridgeB.token()).to.eventually.equal(this.tokenB); - await expect(this.bridgeB.gateway()).to.eventually.equal(this.gateway); - await expect(this.bridgeB.remote(this.chain.erc7930)).to.eventually.equal(this.chain.toErc7930(this.bridgeA)); - }); - - it('crosschain send', async function () { - const [alice, bruce, chris] = this.accounts; - - await this.tokenA.$_mint(alice, amount); - await this.tokenA.connect(alice).approve(this.bridgeA, ethers.MaxUint256); - - await expect(this.tokenA.totalSupply()).to.eventually.equal(amount); - await expect(this.tokenA.balanceOf(this.bridgeA)).to.eventually.equal(0n); - await expect(this.tokenB.totalSupply()).to.eventually.equal(0n); - - // Alice sends tokens from chain A to Bruce on chain B. - await expect(this.bridgeA.connect(alice).crosschainTransfer(this.chain.toErc7930(bruce), amount)) - // bridge on chain A takes custody of the funds - .to.emit(this.tokenA, 'Transfer') - .withArgs(alice, ethers.ZeroAddress, amount) - // crosschain transfer sent - .to.emit(this.bridgeA, 'CrossChainTransferSent') - .withArgs(anyValue, alice, this.chain.toErc7930(bruce), amount) - // ERC-7786 event - .to.emit(this.gateway, 'MessageSent') - // crosschain transfer received - .to.emit(this.bridgeB, 'CrossChainTransferReceived') - .withArgs(anyValue, this.chain.toErc7930(alice), bruce, amount) - // crosschain mint event - .to.emit(this.tokenB, 'CrosschainMint') - .withArgs(bruce, amount, this.bridgeB) - // tokens are minted on chain B - .to.emit(this.tokenB, 'Transfer') - .withArgs(ethers.ZeroAddress, bruce, amount); - - await expect(this.tokenA.totalSupply()).to.eventually.equal(0n); - await expect(this.tokenB.totalSupply()).to.eventually.equal(amount); - await expect(this.tokenB.balanceOf(bruce)).to.eventually.equal(amount); - - // Bruce sends tokens from chain B to Chris on chain A. - await expect(this.bridgeB.connect(bruce).crosschainTransfer(this.chain.toErc7930(chris), amount)) - // tokens are burned on chain B - .to.emit(this.tokenB, 'Transfer') - .withArgs(bruce, ethers.ZeroAddress, amount) - // crosschain burn event - .to.emit(this.tokenB, 'CrosschainBurn') - .withArgs(bruce, amount, this.bridgeB) - // crosschain transfer sent - .to.emit(this.bridgeB, 'CrossChainTransferSent') - .withArgs(anyValue, bruce, this.chain.toErc7930(chris), amount) - // ERC-7786 event - .to.emit(this.gateway, 'MessageSent') - // crosschain transfer received - .to.emit(this.bridgeA, 'CrossChainTransferReceived') - .withArgs(anyValue, this.chain.toErc7930(bruce), chris, amount) - // bridge on chain A releases custody of the funds - .to.emit(this.tokenA, 'Transfer') - .withArgs(ethers.ZeroAddress, chris, amount); - - await expect(this.tokenA.totalSupply()).to.eventually.equal(amount); - await expect(this.tokenA.balanceOf(chris)).to.eventually.equal(amount); - await expect(this.tokenB.totalSupply()).to.eventually.equal(0n); - }); - - describe('crosschain operations', function () { - beforeEach(async function () { - await this.tokenA.$_mint(this.bridgeA, 1_000_000_000n); - }); - - it('only gateway can relay messages', async function () { - const [notGateway] = this.accounts; - - await expect( - this.bridgeA - .connect(notGateway) - .receiveMessage( - ethers.ZeroHash, - this.chain.toErc7930(this.tokenB), - this.encodePayload(notGateway, notGateway, amount), - ), - ) - .to.be.revertedWithCustomError(this.bridgeA, 'InvalidGateway') - .withArgs(notGateway); - }); - - it('only remote can send a crosschain message', async function () { - const [notRemote] = this.accounts; - - await expect( - this.gateway - .connect(notRemote) - .sendMessage(this.chain.toErc7930(this.bridgeA), this.encodePayload(notRemote, notRemote, amount), []), - ) - .to.be.revertedWithCustomError(this.bridgeA, 'InvalidSender') - .withArgs(this.chain.toErc7930(notRemote)); - }); - - it('cannot replay message', async function () { - const [from, to] = this.accounts; - - const id = ethers.ZeroHash; - const payload = this.encodePayload(from, to, amount); - - // first time works - await expect( - this.bridgeA.connect(this.gatewayAsEOA).receiveMessage(id, this.chain.toErc7930(this.bridgeB), payload), - ).to.emit(this.bridgeA, 'CrossChainTransferReceived'); - - // second time fails - await expect( - this.bridgeA.connect(this.gatewayAsEOA).receiveMessage(id, this.chain.toErc7930(this.bridgeB), payload), - ) - .to.be.revertedWithCustomError(this.bridgeA, 'MessageAlreadyProcessed') - .withArgs(this.gateway, id); - }); - }); - - describe('administration', function () { - it('updating the gateway emits an event', async function () { - const newGateway = await ethers.deployContract('$ERC7786GatewayMock'); - - await expect(this.bridgeA.$_setGateway(newGateway)) - .to.emit(this.bridgeA, 'GatewayChange') - .withArgs(this.gateway, newGateway); - - await expect(this.bridgeA.gateway()).to.eventually.equal(newGateway); - }); - - it('updating a remote emits an event', async function () { - const newRemote = this.chain.toErc7930(this.accounts[0]); - - await expect(this.bridgeA.$_registerRemote(newRemote, true)) - .to.emit(this.bridgeA, 'RemoteRegistered') - .withArgs(newRemote); - - await expect(this.bridgeA.remote(this.chain.erc7930)).to.eventually.equal(newRemote); - }); - - it('remote update protection', async function () { - const newRemote = this.chain.toErc7930(this.accounts[0]); - - await expect(this.bridgeA.$_registerRemote(newRemote, false)) - .to.be.revertedWithCustomError(this.bridgeA, 'RemoteAlreadyRegistered') - .withArgs(this.chain.toErc7930(this.bridgeB)); - }); - }); + shouldBehaveLikeBridgeERC20(); }); From c0c421cdbd07411b31638e11800f0e7be458c4c7 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 4 Sep 2025 10:01:42 +0200 Subject: [PATCH 13/19] 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 14/19] 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 15/19] 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 610362df530e8605ff1ec2c723656566e9ce7a3b Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 4 Sep 2025 10:37:37 +0200 Subject: [PATCH 16/19] update to match new ERC7786Recipient --- contracts/crosschain/bridges/BridgeCore.sol | 47 +++++--------------- contracts/crosschain/bridges/BridgeERC20.sol | 10 ++++- test/crosschain/BridgeERC20.behavior.js | 10 ++--- 3 files changed, 24 insertions(+), 43 deletions(-) diff --git a/contracts/crosschain/bridges/BridgeCore.sol b/contracts/crosschain/bridges/BridgeCore.sol index 3e2bd06c27d..0f8a2a099e9 100644 --- a/contracts/crosschain/bridges/BridgeCore.sol +++ b/contracts/crosschain/bridges/BridgeCore.sol @@ -2,10 +2,10 @@ pragma solidity ^0.8.24; -import {IERC7786GatewaySource, IERC7786Recipient} from "../../interfaces/draft-IERC7786.sol"; +import {IERC7786GatewaySource} from "../../interfaces/draft-IERC7786.sol"; import {InteroperableAddress} from "../../utils/draft-InteroperableAddress.sol"; -import {BitMaps} from "../../utils/structs/BitMaps.sol"; import {Bytes} from "../../utils/Bytes.sol"; +import {ERC7786Recipient} from "../ERC7786Recipient.sol"; /** * @dev Core bridging mechanism. @@ -15,10 +15,9 @@ import {Bytes} from "../../utils/Bytes.sol"; * {BridgeERC20}. * * Contract that inherit from this contract can use the internal {_senMessage} to send messages to their conterpart - * on a foreign chain. They must implement the {_processMessage} to handle the message that have been verified. + * on a foreign chain. They must override the {_processMessage} function to handle the message that have been verified. */ -abstract contract BridgeCore is IERC7786Recipient { - using BitMaps for BitMaps.BitMap; +abstract contract BridgeCore is ERC7786Recipient { using Bytes for bytes; using InteroperableAddress for bytes; @@ -27,14 +26,10 @@ abstract contract BridgeCore is IERC7786Recipient { bytes remote; } mapping(bytes chain => Link) private _links; - mapping(address gateway => BitMaps.BitMap) private _received; event RemoteRegistered(address gateway, bytes remote); - error InvalidGatewayForChain(address gateway, bytes chain); - error InvalidRemoteForChain(bytes remote, bytes chain); error RemoteAlreadyRegistered(bytes chain); - error MessageAlreadyProcessed(address gateway, bytes32 receiveId); constructor(Link[] memory links) { for (uint256 i = 0; i < links.length; ++i) { @@ -73,35 +68,15 @@ abstract contract BridgeCore is IERC7786Recipient { return IERC7786GatewaySource(gateway).sendMessage(remote, payload, attributes); } - /// @inheritdoc IERC7786Recipient - function receiveMessage( - bytes32 receiveId, - bytes calldata sender, - bytes calldata payload - ) public payable virtual returns (bytes4) { - bytes memory chain = _extractChain(sender); - (address gateway, bytes memory router) = link(chain); - - // Security restriction: - // - sender must be the remote for that chain - // - message was not processed yet - require(msg.sender == gateway, InvalidGatewayForChain(msg.sender, chain)); - require(sender.equal(router), InvalidRemoteForChain(sender, chain)); - require(!_received[msg.sender].get(uint256(receiveId)), MessageAlreadyProcessed(msg.sender, receiveId)); - _received[msg.sender].set(uint256(receiveId)); - - _processMessage(receiveId, payload); - - return IERC7786Recipient.receiveMessage.selector; + /// @inheritdoc ERC7786Recipient + function _isAuthorizedGateway( + address instance, + bytes calldata sender + ) internal view virtual override returns (bool) { + (address gateway, bytes memory router) = link(_extractChain(sender)); + return instance == gateway && sender.equal(router); } - /** - * @dev Virtual function that should contain the logic to execute when a cross-chain message is received. - * - * Replay protection is already enabled in {receiveMessage}. - */ - function _processMessage(bytes32 receiveId, bytes calldata payload) internal virtual; - function _extractChain(bytes memory self) private pure returns (bytes memory) { (bytes2 chainType, bytes memory chainReference, ) = self.parseV1(); return InteroperableAddress.formatV1(chainType, chainReference, hex""); diff --git a/contracts/crosschain/bridges/BridgeERC20.sol b/contracts/crosschain/bridges/BridgeERC20.sol index dba69b1ddb3..f12867c2c1e 100644 --- a/contracts/crosschain/bridges/BridgeERC20.sol +++ b/contracts/crosschain/bridges/BridgeERC20.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.24; import {InteroperableAddress} from "../../utils/draft-InteroperableAddress.sol"; +import {ERC7786Recipient} from "../ERC7786Recipient.sol"; import {BridgeCore} from "./BridgeCore.sol"; /** @@ -45,8 +46,13 @@ abstract contract BridgeERC20 is BridgeCore { return sendId; } - /// @inheritdoc BridgeCore - function _processMessage(bytes32 receiveId, bytes calldata payload) internal virtual override { + /// @inheritdoc ERC7786Recipient + function _processMessage( + address /*gateway*/, + bytes32 receiveId, + bytes calldata /*sender*/, + bytes calldata payload + ) internal virtual override { // split payload (bytes memory from, bytes memory toBinary, uint256 amount) = abi.decode(payload, (bytes, bytes, uint256)); address to = address(bytes20(toBinary)); diff --git a/test/crosschain/BridgeERC20.behavior.js b/test/crosschain/BridgeERC20.behavior.js index c061e884105..f54e7eec22e 100644 --- a/test/crosschain/BridgeERC20.behavior.js +++ b/test/crosschain/BridgeERC20.behavior.js @@ -89,8 +89,8 @@ function shouldBehaveLikeBridgeERC20({ chainAIsCustodial = false, chainBIsCustod this.encodePayload(notGateway, notGateway, amount), ), ) - .to.be.revertedWithCustomError(this.bridgeA, 'InvalidGatewayForChain') - .withArgs(notGateway, this.chain.erc7930); + .to.be.revertedWithCustomError(this.bridgeA, 'ERC7786RecipientInvalidGateway') + .withArgs(notGateway); }); it('only remote can send a crosschain message', async function () { @@ -101,8 +101,8 @@ function shouldBehaveLikeBridgeERC20({ chainAIsCustodial = false, chainBIsCustod .connect(notRemote) .sendMessage(this.chain.toErc7930(this.bridgeA), this.encodePayload(notRemote, notRemote, amount), []), ) - .to.be.revertedWithCustomError(this.bridgeA, 'InvalidRemoteForChain') - .withArgs(this.chain.toErc7930(notRemote), this.chain.erc7930); + .to.be.revertedWithCustomError(this.bridgeA, 'ERC7786RecipientInvalidGateway') + .withArgs(this.gateway); }); it('cannot replay message', async function () { @@ -120,7 +120,7 @@ function shouldBehaveLikeBridgeERC20({ chainAIsCustodial = false, chainBIsCustod await expect( this.bridgeA.connect(this.gatewayAsEOA).receiveMessage(id, this.chain.toErc7930(this.bridgeB), payload), ) - .to.be.revertedWithCustomError(this.bridgeA, 'MessageAlreadyProcessed') + .to.be.revertedWithCustomError(this.bridgeA, 'ERC7786RecipientMessageAlreadyProcessed') .withArgs(this.gateway, id); }); }); From e0a3fd0bf6f53b72b1538bf3d361750c3b2339a1 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Tue, 9 Sep 2025 15:55:25 +0200 Subject: [PATCH 17/19] Update contracts/crosschain/bridges/BridgeCore.sol --- contracts/crosschain/bridges/BridgeCore.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/crosschain/bridges/BridgeCore.sol b/contracts/crosschain/bridges/BridgeCore.sol index 0f8a2a099e9..fea962eb9ce 100644 --- a/contracts/crosschain/bridges/BridgeCore.sol +++ b/contracts/crosschain/bridges/BridgeCore.sol @@ -14,7 +14,7 @@ import {ERC7786Recipient} from "../ERC7786Recipient.sol"; * gateways. It ensure received message originate from for a counterpart. This is the code of token bridges such as * {BridgeERC20}. * - * Contract that inherit from this contract can use the internal {_senMessage} to send messages to their conterpart + * Contract that inherit from this contract can use the internal {_senMessage} to send messages to their counterpart * on a foreign chain. They must override the {_processMessage} function to handle the message that have been verified. */ abstract contract BridgeCore is ERC7786Recipient { From c2a6d4084103c0478890be0beb3efea3004e2a41 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Fri, 3 Oct 2025 15:05:20 +0200 Subject: [PATCH 18/19] Apply suggestions from code review --- contracts/crosschain/bridges/BridgeCore.sol | 6 +++--- contracts/crosschain/bridges/BridgeERC20.sol | 2 +- contracts/crosschain/bridges/BridgeERC20Bridgeable.sol | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/contracts/crosschain/bridges/BridgeCore.sol b/contracts/crosschain/bridges/BridgeCore.sol index fea962eb9ce..ea91c8a81e7 100644 --- a/contracts/crosschain/bridges/BridgeCore.sol +++ b/contracts/crosschain/bridges/BridgeCore.sol @@ -11,10 +11,10 @@ import {ERC7786Recipient} from "../ERC7786Recipient.sol"; * @dev Core bridging mechanism. * * This contract contains the logic to register and send messages to counterparts on remote chains using ERC-7786 - * gateways. It ensure received message originate from for a counterpart. This is the code of token bridges such as + * gateways. It ensure received messages originate from a counterpart. This is the base of token bridges such as``` * {BridgeERC20}. * - * Contract that inherit from this contract can use the internal {_senMessage} to send messages to their counterpart + * Contract that inherit from this contract can use the internal {_sendMessage} to send messages to their counterpart``` * on a foreign chain. They must override the {_processMessage} function to handle the message that have been verified. */ abstract contract BridgeCore is ERC7786Recipient { @@ -33,7 +33,7 @@ abstract contract BridgeCore is ERC7786Recipient { constructor(Link[] memory links) { for (uint256 i = 0; i < links.length; ++i) { - _setLink(links[0].gateway, links[0].remote, false); + _setLink(links[i].gateway, links[i].remote, false); } } diff --git a/contracts/crosschain/bridges/BridgeERC20.sol b/contracts/crosschain/bridges/BridgeERC20.sol index f12867c2c1e..e3b754f745b 100644 --- a/contracts/crosschain/bridges/BridgeERC20.sol +++ b/contracts/crosschain/bridges/BridgeERC20.sol @@ -14,7 +14,7 @@ import {BridgeCore} from "./BridgeCore.sol"; * * {unlock}: called when a crosschain transfer is coming it. Must give tokens to the receiver. * * This base contract is used by the {BridgeERC20Custodial}, which interfaces with legacy ERC-20 tokens, and - * {BrdigeERC20Bridgeable}, which interface with ERC-7802 to provide an approve-free user experience. It is also used + * {BridgeERC20Bridgeable}, which interface with ERC-7802 to provide an approve-free user experience. It is also used``` * by the {ERC20Crosschain} extension, which embeds the bridge logic directly in the token contract. */ abstract contract BridgeERC20 is BridgeCore { diff --git a/contracts/crosschain/bridges/BridgeERC20Bridgeable.sol b/contracts/crosschain/bridges/BridgeERC20Bridgeable.sol index 3eeab9d84ba..4d593ae2c97 100644 --- a/contracts/crosschain/bridges/BridgeERC20Bridgeable.sol +++ b/contracts/crosschain/bridges/BridgeERC20Bridgeable.sol @@ -25,7 +25,7 @@ abstract contract BridgeERC20Bridgeable is BridgeERC20 { token().crosschainBurn(from, amount); } - /// @dev "Unlocking" tokens using an ERC-7802 crosschain burn + /// @dev "Unlocking" tokens using an ERC-7802 crosschain mint function _unlock(address to, uint256 amount) internal virtual override { token().crosschainMint(to, amount); } From 984b8970f8bbb51922164c4e974252cb2af63cf1 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Fri, 3 Oct 2025 17:53:01 +0200 Subject: [PATCH 19/19] fix tests --- test/crosschain/BridgeERC20.behavior.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/crosschain/BridgeERC20.behavior.js b/test/crosschain/BridgeERC20.behavior.js index f54e7eec22e..372bc442c5b 100644 --- a/test/crosschain/BridgeERC20.behavior.js +++ b/test/crosschain/BridgeERC20.behavior.js @@ -89,8 +89,8 @@ function shouldBehaveLikeBridgeERC20({ chainAIsCustodial = false, chainBIsCustod this.encodePayload(notGateway, notGateway, amount), ), ) - .to.be.revertedWithCustomError(this.bridgeA, 'ERC7786RecipientInvalidGateway') - .withArgs(notGateway); + .to.be.revertedWithCustomError(this.bridgeA, 'ERC7786RecipientUnauthorizedGateway') + .withArgs(notGateway, this.chain.toErc7930(this.tokenB)); }); it('only remote can send a crosschain message', async function () { @@ -101,8 +101,8 @@ function shouldBehaveLikeBridgeERC20({ chainAIsCustodial = false, chainBIsCustod .connect(notRemote) .sendMessage(this.chain.toErc7930(this.bridgeA), this.encodePayload(notRemote, notRemote, amount), []), ) - .to.be.revertedWithCustomError(this.bridgeA, 'ERC7786RecipientInvalidGateway') - .withArgs(this.gateway); + .to.be.revertedWithCustomError(this.bridgeA, 'ERC7786RecipientUnauthorizedGateway') + .withArgs(this.gateway, this.chain.toErc7930(notRemote)); }); it('cannot replay message', async function () {