Skip to content
Merged
Show file tree
Hide file tree
Changes from 44 commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
4a33948
Migrate ERC7786Receiver from community
Amxx Aug 28, 2025
fdb2e77
add documentation
Amxx Aug 28, 2025
3651638
Apply suggestion from @ernestognw
ernestognw Aug 28, 2025
f2abf9f
Update .changeset/silent-zebras-press.md
Amxx Aug 28, 2025
ab9643f
Apply suggestions from code review
Amxx Aug 28, 2025
d3e2223
rename ERC7786Receiver into ERC7786Recipient
Amxx Aug 28, 2025
9b015d3
Update .changeset/silent-zebras-press.md
Amxx Aug 29, 2025
b486a81
Add CrosschainBridgeERC20, CrosschainBridgeERC20Custodial, Crosschain…
Amxx Aug 29, 2025
50be369
refactor bridges
Amxx Aug 29, 2025
4cf52d0
comments
Amxx Aug 29, 2025
bd4095c
remove the "with attributes" variant of crosschainTransfer + testing
Amxx Sep 1, 2025
e6e5d79
update Bridge: use chain specific gateway
Amxx Sep 1, 2025
c0c421c
refactor permission
Amxx Sep 4, 2025
a051b91
Merge branch 'crosschain/erc7786receiver' into crosschain/erc20bridge
Amxx Sep 4, 2025
c938d68
calldata
Amxx Sep 4, 2025
69d43a7
Merge branch 'crosschain/erc7786receiver' into crosschain/erc20bridge
Amxx Sep 4, 2025
d39c18e
prevent message replay at the receiver level
Amxx Sep 4, 2025
3cc4acf
Merge branch 'crosschain/erc7786receiver' into crosschain/erc20bridge
Amxx Sep 4, 2025
610362d
update to match new ERC7786Recipient
Amxx Sep 4, 2025
e0a3fd0
Update contracts/crosschain/bridges/BridgeCore.sol
Amxx Sep 9, 2025
434ee70
Merge branch 'master' into crosschain/erc20bridge
Amxx Sep 30, 2025
c2a6d40
Apply suggestions from code review
Amxx Oct 3, 2025
42b450c
Merge branch 'master' into crosschain/erc20bridge
Amxx Oct 3, 2025
b8cc512
Merge remote-tracking branch 'amxx/crosschain/erc20bridge' into cross…
Amxx Oct 3, 2025
984b897
fix tests
Amxx Oct 3, 2025
6a8e8f9
pragma consistency
Amxx Oct 9, 2025
172b02d
pragma consistency
Amxx Oct 9, 2025
0673a8a
Merge branch 'master' into crosschain/erc20bridge
Amxx Oct 10, 2025
aef394e
Apply suggestions from code review
Amxx Nov 6, 2025
7df0623
rename BridgeCore.link -> getLink
Amxx Nov 6, 2025
f88e636
rename BridgeCore._sendMessage -> _sendMessageToRemote
Amxx Nov 6, 2025
509163f
fix BridgeERC20 doc
Amxx Nov 6, 2025
4f97141
rename BridgeCore -> CrosschainLinks
Amxx Nov 6, 2025
79fff25
rename BridgeERC20Bridgeable -> BridgeERC7802
Amxx Nov 6, 2025
4811775
rename BridgeERC20 -> BridgeERC20Core
Amxx Nov 6, 2025
deb6870
rename BridgeERC20Custodial -> BridgeERC20
Amxx Nov 6, 2025
7f3c7e5
coverage
Amxx Nov 6, 2025
02078c3
rename _lock/_unlock -> _onSend/_onReceive
Amxx Nov 6, 2025
9215f69
overall rename: remote -> counterpart
Amxx Nov 6, 2025
7a9339f
document ERC20Crosschain
Amxx Nov 6, 2025
6521f01
documentation
Amxx Nov 6, 2025
99bf379
Document bytes objects that are interoperable addresses
Amxx Nov 6, 2025
43da2d0
try fix slither locked-ether
Amxx Nov 6, 2025
7a040dc
add changesets
Amxx Nov 7, 2025
771068d
Update contracts/crosschain/CrosschainLinks.sol
Amxx Nov 10, 2025
3c922a3
Rename CrosschainLinks -> CrosschainLinked
Amxx Nov 25, 2025
ff790ef
move file to finish renaming
Amxx Nov 25, 2025
63ef13b
Apply suggestions from code review
Amxx Nov 26, 2025
fc0066e
rename events
Amxx Nov 27, 2025
123eb19
Update contracts/crosschain/CrosschainLinked.sol
Amxx Nov 27, 2025
b5331da
Update contracts/crosschain/README.adoc
Amxx Nov 27, 2025
941db67
Apply suggestions from code review
Amxx Nov 27, 2025
File filter

Filter by extension

Filter by extension

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

`ERC20Crosschain`: Added an ERC-20 extension to embed an ERC-7786 based crosschain bridge directly in the token contract.
5 changes: 5 additions & 0 deletions .changeset/grumpy-cats-brake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'openzeppelin-solidity': minor
---

`CrosschainLinks`: Added a new helper contract to facilitate communication between a contract on one chain and counterparts on remote chains through ERC-7786 gateways.
5 changes: 5 additions & 0 deletions .changeset/new-socks-deny.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'openzeppelin-solidity': minor
---

`BridgeERC20Core`, `BridgeERC20` and `BridgeERC7802`: Added bridge contracts to handle crosschain movements of ERC-20 (and ERC-7802) tokens.
108 changes: 108 additions & 0 deletions contracts/crosschain/CrosschainLinks.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.26;

import {IERC7786GatewaySource} from "../interfaces/draft-IERC7786.sol";
import {InteroperableAddress} from "../utils/draft-InteroperableAddress.sol";
import {Bytes} from "../utils/Bytes.sol";
import {ERC7786Recipient} from "./ERC7786Recipient.sol";

/**
* @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 messages originate from a counterpart. This is the base of token bridges such as
* {BridgeERC20Core}.
*
* Contract that inherit from this contract can use the internal {_sendMessageToCounterpart} 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 CrosschainLinks is ERC7786Recipient {
using Bytes for bytes;
using InteroperableAddress for bytes;

struct Link {
address gateway;
bytes counterpart; // Full InteroperableAddress (chain ref + address)
}
mapping(bytes chain => Link) private _links;

/**
* @dev Emitted when a new link is registered.
*
* Note: the `counterpart` argument is a full InteroperableAddress (chain ref + address).
*/
event LinkRegistered(address gateway, bytes counterpart);

/**
* @dev Reverted when trying to register a link for a chain that is already registered.
*
* Note: the `chain` argument is a "chain-only" InteroperableAddress (empty address).
*/
error LinkAlreadyRegistered(bytes chain);

constructor(Link[] memory links) {
for (uint256 i = 0; i < links.length; ++i) {
_setLink(links[i].gateway, links[i].counterpart, false);
}
}

/**
* @dev Returns the ERC-7786 gateway used for sending and receiving cross-chain messages to a given chain
*
* Note: The `chain` parameter is a "chain-only" InteroperableAddress (empty address) and the `counterpart` return
* the full InteroperableAddress (chain ref + address) that is on `chain`.
*/
function getLink(bytes memory chain) public view virtual returns (address gateway, bytes memory counterpart) {
Link storage self = _links[chain];
return (self.gateway, self.counterpart);
}

/**
* @dev Internal setter to change the ERC-7786 gateway and counterpart for a given chain. Called at construction.
*
* Note: The `counterpart` parameter is the full InteroperableAddress (chain ref + address).
*/
function _setLink(address gateway, bytes memory counterpart, bool allowOverride) internal virtual {
// Sanity check, this should revert if gateway is not an ERC-7786 implementation. Note that since
// supportsAttribute returns data, an EOA would fail that test (nothing returned).
IERC7786GatewaySource(gateway).supportsAttribute(bytes4(0));

bytes memory chain = _extractChain(counterpart);
if (allowOverride || _links[chain].gateway == address(0)) {
_links[chain] = Link(gateway, counterpart);
emit LinkRegistered(gateway, counterpart);
} else {
revert LinkAlreadyRegistered(chain);
}
}

/**
* @dev Internal messaging function
*
* Note: The `chain` parameter is a"chain-only" InteroperableAddress (empty address).
*/
function _sendMessageToCounterpart(
bytes memory chain,
bytes memory payload,
bytes[] memory attributes
) internal virtual returns (bytes32) {
(address gateway, bytes memory counterpart) = getLink(chain);
return IERC7786GatewaySource(gateway).sendMessage(counterpart, payload, attributes);
}

/// @inheritdoc ERC7786Recipient
function _isAuthorizedGateway(
address instance,
bytes calldata sender
) internal view virtual override returns (bool) {
(address gateway, bytes memory router) = getLink(_extractChain(sender));
return instance == gateway && sender.equal(router);
}

function _extractChain(bytes memory self) private pure returns (bytes memory) {
(bytes2 chainType, bytes memory chainReference, ) = self.parseV1();
return InteroperableAddress.formatV1(chainType, chainReference, hex"");
}
}
19 changes: 18 additions & 1 deletion contracts/crosschain/README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,25 @@ 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.

- {ERC7786Recipient}: generic ERC-7786 crosschain contract that receives messages from a trusted gateway
* {CrosschainLinks}: helper to facilitate communication between a contract on one chain and counterparts on remote chains through ERC-7786 gateways.
* {ERC7786Recipient}: generic ERC-7786 crosschain contract that receives messages from a trusted gateway.

Additionally there are multiple bridge constructions:

* {BridgeERC20Core}: Core bridging logic for crosschain ERC-20 transfer. Used by {BridgeERC20}, {BridgeERC7802} and {ERC20Crosschain},
* {BridgeERC20}: Standalone bridge contract to connect an ERC-20 token contract with other tokens on remote chains,
* {BridgeERC7802}: Standalone bridge contract to connect an ERC-7802 token contract with other tokens on remote chains.

== Helpers

{{CrosschainLinks}}

{{ERC7786Recipient}}

== Bridges

{{BridgeERC20Core}}

{{BridgeERC20}}

{{BridgeERC7802}}
36 changes: 36 additions & 0 deletions contracts/crosschain/bridges/BridgeERC20.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.26;

import {IERC20, SafeERC20} from "../../token/ERC20/utils/SafeERC20.sol";
import {BridgeERC20Core} from "./BridgeERC20Core.sol";

/**
* @dev This is a variant of {BridgeERC20Core} that implements the bridge logic for ERC-20 tokens that do not expose mint
* and burn mechanism. Instead it takes custody of bridged assets.
*/
// slither-disable-next-line locked-ether
abstract contract BridgeERC20 is BridgeERC20Core {
using SafeERC20 for IERC20;

IERC20 private immutable _token;

constructor(IERC20 token_) {
_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 _onSend(address from, uint256 amount) internal virtual override {
token().safeTransferFrom(from, address(this), amount);
}

/// @dev "Unlocking" tokens is done by releasing custody
function _onReceive(address to, uint256 amount) internal virtual override {
token().safeTransfer(to, amount);
}
}
78 changes: 78 additions & 0 deletions contracts/crosschain/bridges/BridgeERC20Core.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.26;

import {InteroperableAddress} from "../../utils/draft-InteroperableAddress.sol";
import {ERC7786Recipient} from "../ERC7786Recipient.sol";
import {CrosschainLinks} from "../CrosschainLinks.sol";

/**
* @dev Base contract for bridging ERC-20 between chains using an ERC-7786 gateway.
*
* In order to use this contract, two functions must be implemented to link it to the token:
* * {_onSend}: called when a crosschain transfer is going out. Must take the sender tokens or revert.
* * {_onReceive}: called when a crosschain transfer is coming it. Must give tokens to the receiver.
*
* This base contract is used by the {BridgeERC20}, which interfaces with legacy ERC-20 tokens, and {BridgeERC7802},
* 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 BridgeERC20Core is CrosschainLinks {
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.
*
* Note: The `to` parameter is the full InteroperableAddress (chain ref + address).
*/
function crosschainTransfer(bytes memory to, uint256 amount) public virtual returns (bytes32) {
return _crosschainTransfer(msg.sender, to, amount);
}

/**
* @dev Internal crosschain transfer function.
*
* Note: The `to` parameter is the full InteroperableAddress (chain ref + address).
*/
function _crosschainTransfer(address from, bytes memory to, uint256 amount) internal virtual returns (bytes32) {
_onSend(from, amount);

(bytes2 chainType, bytes memory chainReference, bytes memory addr) = to.parseV1();
bytes memory chain = InteroperableAddress.formatV1(chainType, chainReference, hex"");

bytes32 sendId = _sendMessageToCounterpart(
chain,
abi.encode(InteroperableAddress.formatEvmV1(block.chainid, from), addr, amount),
new bytes[](0)
);

emit CrossChainTransferSent(sendId, from, to, amount);

return sendId;
}

/// @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));

_onReceive(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 _onSend(address from, uint256 amount) internal virtual;

/// @dev Virtual function: implementation is required to handle token being minted or unlocked on the destination chain.
function _onReceive(address to, uint256 amount) internal virtual;
}
33 changes: 33 additions & 0 deletions contracts/crosschain/bridges/BridgeERC7802.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.26;

import {IERC7802} from "../../interfaces/draft-IERC7802.sol";
import {BridgeERC20Core} from "./BridgeERC20Core.sol";

/**
* @dev This is a variant of {BridgeERC20Core} that implements the bridge logic for ERC-7802 compliant tokens.
*/
// slither-disable-next-line locked-ether
abstract contract BridgeERC7802 is BridgeERC20Core {
IERC7802 private immutable _token;

constructor(IERC7802 token_) {
_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 _onSend(address from, uint256 amount) internal virtual override {
token().crosschainBurn(from, amount);
}

/// @dev "Unlocking" tokens using an ERC-7802 crosschain mint
function _onReceive(address to, uint256 amount) internal virtual override {
token().crosschainMint(to, amount);
}
}
6 changes: 5 additions & 1 deletion contracts/mocks/token/ERC20BridgeableMock.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
3 changes: 3 additions & 0 deletions contracts/token/ERC20/README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Additionally there are multiple custom extensions, including:
* {ERC20Bridgeable}: compatibility with crosschain bridges through ERC-7802.
* {ERC20Burnable}: destruction of own tokens.
* {ERC20Capped}: enforcement of a cap to the total supply when minting tokens.
* {ERC20Crosschain}: embedded {BridgeERC20Core} bridge, making the token crosschain through the use of ERC-7786 gateways.
* {ERC20Pausable}: ability to pause token transfers.
* {ERC20FlashMint}: token level support for flash loans through the minting and burning of ephemeral tokens (standardized as ERC-3156).
* {ERC20Votes}: support for voting and vote delegation. xref:governance.adoc#token[See the governance guide for a minimal example (with the required overrides when combining ERC20 + ERC20Permit + ERC20Votes)].
Expand Down Expand Up @@ -57,6 +58,8 @@ NOTE: This core set of contracts is designed to be unopinionated, allowing devel

{{ERC20Capped}}

{{ERC20Crosschain}}

{{ERC20Pausable}}

{{ERC20Votes}}
Expand Down
38 changes: 38 additions & 0 deletions contracts/token/ERC20/extensions/ERC20Crosschain.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.26;

import {ERC20} from "../ERC20.sol";
import {BridgeERC20Core} from "../../../crosschain/bridges/BridgeERC20Core.sol";

/**
* @dev Extension of {ERC20} that makes it natively cross-chain using the ERC-7786 based {BridgeERC20Core}.
*
* This extension makes the token compatible with:
* * {ERC20Crosschain} instances on other chains,
* * {ERC20} instances on other chains that are bridged using {BridgeERC20},
* * {ERC20Bridgeable} instances on other chains that are bridged using {BridgeERC7802}.
*
* It is mostly equivalent to inheriting from both {ERC20Bridgeable} and {BridgeERC7802}, and configuring then such
* that:
* * `token` (on the {BridgeERC7802} side) is `address(this)`,
* * `_checkTokenBridge` (on the {ERC20Bridgeable} side) is implemented such that it only accepts calls self-calls.
*/
// slither-disable-next-line locked-ether
abstract contract ERC20Crosschain is ERC20, BridgeERC20Core {
/// @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);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I think I'd remove this, to reduce surface, and because it's not standardized so I don't expect it to be used.

Copy link
Collaborator Author

@Amxx Amxx Nov 6, 2025

Choose a reason for hiding this comment

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

IMO its a nice to have.

Not generic, but if a projects knows its there on their own tokens, they might leverage it trhough their own UI/SDK

Copy link
Contributor

Choose a reason for hiding this comment

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

There's an easy workaround by doing transferFrom followed by crosschainTransfer. It's not a super strong opinion but I still feel I'd rather remove it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

its an easy work around, but even with multicall, that would still involves two storage update (for the balance of the intermediary account).


/// @dev "Locking" tokens is achieved through burning
function _onSend(address from, uint256 amount) internal virtual override {
_burn(from, amount);
}

/// @dev "Unlocking" tokens is achieved through minting
function _onReceive(address to, uint256 amount) internal virtual override {
_mint(to, amount);
}
}
Loading