Skip to content
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
25 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
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/silent-zebras-press.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'openzeppelin-solidity': minor
---

`ERC7786Recipient`: Generic ERC-7786 cross-chain message recipient contract.
59 changes: 59 additions & 0 deletions contracts/crosschain/ERC7786Recipient.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// SPDX-License-Identifier: MIT

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.
*
* This abstract contract exposes the `receiveMessage` function that is used for communication with (one or multiple)
* destination gateways. This contract leaves two functions unimplemented:
*
* * {_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.
*
* 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(
bytes32 receiveId,
bytes calldata sender, // Binary Interoperable Address
bytes calldata payload
) external payable returns (bytes4) {
require(_isAuthorizedGateway(msg.sender, sender), ERC7786RecipientInvalidGateway(msg.sender));
require(
!_received[msg.sender].get(uint256(receiveId)),
ERC7786RecipientMessageAlreadyProcessed(msg.sender, receiveId)
);
_received[msg.sender].set(uint256(receiveId));

_processMessage(msg.sender, receiveId, sender, payload);

return IERC7786Recipient.receiveMessage.selector;
}

/// @dev Virtual getter that returns whether an address is a valid ERC-7786 gateway for a given sender.
function _isAuthorizedGateway(address instance, bytes calldata sender) internal view virtual returns (bool);

/// @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;
}
12 changes: 12 additions & 0 deletions contracts/crosschain/README.adoc
Original file line number Diff line number Diff line change
@@ -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 contains contracts for sending and receiving cross chain messages that follows the ERC-7786 standard.

- {IERC7786Recipient}: generic ERC-7786 crosschain contract that receives messages from a trusted gateway

== Helpers

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

pragma solidity ^0.8.24;

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 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

Check failure on line 17 in contracts/crosschain/bridges/BridgeCore.sol

View workflow job for this annotation

GitHub Actions / codespell

conterpart ==> counterpart
* on a foreign chain. They must override the {_processMessage} function to handle the message that have been verified.
*/
abstract contract BridgeCore is ERC7786Recipient {
using Bytes for bytes;
using InteroperableAddress for bytes;

struct Link {
address gateway;
bytes remote;
}
mapping(bytes chain => Link) private _links;

event RemoteRegistered(address gateway, bytes remote);

error RemoteAlreadyRegistered(bytes chain);

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 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 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(gateway).supportsAttribute(bytes4(0));

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

/// @dev Internal messaging function.
function _sendMessage(
bytes memory chain,
bytes memory payload,
bytes[] memory attributes
) internal virtual returns (bytes32) {
(address gateway, bytes memory remote) = link(chain);
return IERC7786GatewaySource(gateway).sendMessage(remote, payload, attributes);
}

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

function _extractChain(bytes memory self) private pure returns (bytes memory) {
(bytes2 chainType, bytes memory chainReference, ) = self.parseV1();
return InteroperableAddress.formatV1(chainType, chainReference, hex"");
}
}
70 changes: 70 additions & 0 deletions contracts/crosschain/bridges/BridgeERC20.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.24;

import {InteroperableAddress} from "../../utils/draft-InteroperableAddress.sol";
import {ERC7786Recipient} from "../ERC7786Recipient.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, 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;

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.
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) 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),
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));
Comment on lines +57 to +58
Copy link

@coderabbitai coderabbitai bot Sep 30, 2025

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Unsafe address extraction from bytes.

The conversion address(bytes20(toBinary)) assumes toBinary is exactly 20 bytes in the expected position. However, addr from the sender could be in various interoperable address formats (not just raw EVM addresses). This creates a mismatch:

  • Sending side (line 40): encodes addr directly from parseV1() output
  • Receiving side (line 58): blindly casts to bytes20

If toBinary is not a raw 20-byte EVM address or has encoding wrapper bytes, this will either revert or extract the wrong address.

Consider using a proper parser or validating that toBinary is an EVM address format before casting:

  (bytes memory from, bytes memory toBinary, uint256 amount) = abi.decode(payload, (bytes, bytes, uint256));
- address to = address(bytes20(toBinary));
+ // Parse the destination address properly
+ (, , bytes memory addrBytes) = toBinary.parseV1();
+ require(addrBytes.length == 20, "Invalid EVM address");
+ address to = address(bytes20(addrBytes));

Or ensure that the sending side always encodes a raw 20-byte address rather than a full interoperable address structure.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In contracts/crosschain/bridges/BridgeERC20.sol around lines 57-58, the code
unsafely casts toBinary to an address via address(bytes20(toBinary)) which
assumes toBinary is exactly a raw 20-byte EVM address; instead, validate the
payload format before casting (e.g., require(toBinary.length == 20) and revert
with a clear error if not) or implement a parser that accepts interoperable
address encodings (strip known wrappers or handle 32-byte padded addresses by
selecting the correct 20-byte slice) so you only convert when you have a
confirmed raw 20-byte address; alternatively, ensure the sender always encodes a
raw 20-byte address and document/validate that invariant at decode time.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The origin of the message has been verified, so we have a guarantee that bytes memory toBinary was encoded on line 40 and corresponds to addr that was parsed on line 35.

So we have a guarantee that this is the "address" part of the to ERC-7930 parameter of _crosschainTransfer.

What if this is not properly formated? Reverting here means the the tokens were locked/burn on the source chain, and never minted on this side, creating an inconsistency. Not doing the check means we mint to whatever match the to parameter as best as possible. If the token is administered, there may be a way to recover these funds using a force transfer.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Note: toBinary is NOT a full ERC-7930 address. Its only the address section, so we shouldn't parse it.

Copy link

Choose a reason for hiding this comment

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

@Amxx, you're absolutely right. I apologize for the misleading suggestion. In a cross-chain bridge context, reverting on malformed addresses would create an irrecoverable inconsistency where tokens are locked/burned on the source chain but never minted on the destination chain.

The current best-effort approach is the correct design:

  • The message origin has been verified, so toBinary is guaranteed to come from a trusted source (line 40 encoding)
  • If the user provided a malformed to parameter, that's user error at the point of initiating the transfer
  • Minting to the best interpretation of the address preserves cross-chain consistency
  • For administered tokens, there's a recovery path via force transfer
  • Reverting would permanently lock funds with no recovery mechanism

The current implementation correctly prioritizes cross-chain consistency over local validation. Thank you for the clarification!


✏️ Learnings added
Learnt from: Amxx
PR: OpenZeppelin/openzeppelin-contracts#5914
File: contracts/crosschain/bridges/BridgeERC20.sol:57-58
Timestamp: 2025-10-03T13:14:57.638Z
Learning: In cross-chain bridge contracts like BridgeERC20, when processing incoming messages in _processMessage, avoid validation checks that revert on malformed addresses. Reverting would create cross-chain inconsistency where tokens are locked/burned on the source chain but never minted on the destination. Instead, use best-effort address extraction (e.g., address(bytes20(toBinary))) to maintain atomicity across chains. If tokens are minted to an incorrect address due to user error, recovery may be possible through admin controls rather than leaving funds permanently locked.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Copy link

Choose a reason for hiding this comment

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

@Amxx, thank you for the clarification! I understand now—toBinary is just the raw address bytes extracted from the ERC-7930 address structure (the addr component from parseV1()), not a complete ERC-7930 formatted address. That's why the direct cast to address(bytes20(toBinary)) is appropriate here without needing to parse it again.

I appreciate you taking the time to ensure I understood the data structure correctly.


🧠 Learnings used
Learnt from: Amxx
PR: OpenZeppelin/openzeppelin-contracts#5914
File: contracts/crosschain/bridges/BridgeERC20.sol:57-58
Timestamp: 2025-10-03T13:14:57.638Z
Learning: In cross-chain bridge contracts like BridgeERC20, when processing incoming messages in _processMessage, avoid validation checks that revert on malformed addresses. Reverting would create cross-chain inconsistency where tokens are locked/burned on the source chain but never minted on the destination. Instead, use best-effort address extraction (e.g., address(bytes20(toBinary))) to maintain atomicity across chains. If tokens are minted to an incorrect address due to user error, recovery may be possible through admin controls rather than leaving funds permanently locked.


_unlock(to, amount);

emit CrossChainTransferReceived(receiveId, from, to, amount);
}
Comment on lines +49 to +63
Copy link

@coderabbitai coderabbitai bot Sep 30, 2025

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

Verify address extraction safety.

The payload decoding and address extraction at line 58 using address(bytes20(toBinary)) assumes toBinary contains at least 20 bytes. If the remote bridge sends a shorter or differently formatted address, this could silently produce an incorrect address (zero-padded).

Consider validating that toBinary is exactly 20 bytes for EVM addresses, or document the expected format and ensure remote bridges comply.


🏁 Script executed:

#!/bin/bash
# Description: Verify how addresses are encoded in the sending logic and decoded here

# Find formatEvmV1 implementation to understand the address encoding
ast-grep --pattern $'function formatEvmV1($$$) {
  $$$
}'

# Check if there are any address length validations in the codebase
rg -n "bytes20|address.*length" --type solidity -C 2

Length of output: 1274


🏁 Script executed:

#!/bin/bash
sed -n '1,200p' contracts/crosschain/bridges/BridgeERC20.sol

Length of output: 3066


🏁 Script executed:

#!/bin/bash
# Show parseV1 implementation in InteroperableAddress
grep -R "function parseV1" -n contracts/utils/draft-InteroperableAddress.sol -C5

Length of output: 989


🏁 Script executed:

#!/bin/bash
grep -R "function tryParseV1" -n contracts/utils/draft-InteroperableAddress.sol -C5

Length of output: 1070


🏁 Script executed:

#!/bin/bash
# Display the body of tryParseV1 to inspect how `addr` is extracted
sed -n '90,150p' contracts/utils/draft-InteroperableAddress.sol

Length of output: 3077


Enforce 20-byte EVM address in _processMessage before casting.
Insert at the top of the function, before the address(bytes20(toBinary)) line:

     (bytes memory from, bytes memory toBinary, uint256 amount) = abi.decode(payload, (bytes, bytes, uint256));
+    require(toBinary.length == 20, "BridgeERC20: invalid address length");
     address to = address(bytes20(toBinary));

This guarantees the bytes20 conversion can’t under- or over-read the payload.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// @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));
_unlock(to, amount);
emit CrossChainTransferReceived(receiveId, from, to, amount);
}
/// @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));
require(toBinary.length == 20, "BridgeERC20: invalid address length");
address to = address(bytes20(toBinary));
_unlock(to, amount);
emit CrossChainTransferReceived(receiveId, from, to, amount);
}
🤖 Prompt for AI Agents
In contracts/crosschain/bridges/BridgeERC20.sol around lines 49 to 63, the
function _processMessage currently casts toBinary to address without validating
its length; add a sanity check at the top of the function to require that
toBinary.length == 20 (reverting with a clear message like "BridgeERC20: invalid
to address length") before converting bytes20(toBinary) to address, then proceed
with the existing cast, unlock, and emit logic.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Copy link

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Fetch the discussion from the referenced comment URL
# The comment ID is r2401871066
gh api graphql -f query='
query {
  node(id: "r2401871066") {
    ... on PullRequestReviewComment {
      body
      path
      position
      author {
        login
      }
      replies(first: 10) {
        nodes {
          body
          author {
            login
          }
        }
      }
    }
  }
}'

Length of output: 363


🏁 Script executed:

#!/bin/bash
# Try fetching the comment using REST API
# Extract the numeric ID from the review comment ID
# The URL format is: /pull/5914/files#r2401871066
# We need to fetch review comments for PR 5914 and find the one with this ID

gh api repos/OpenZeppelin/openzeppelin-contracts/pulls/5914/comments | jq '.[] | select(.id == 2401871066) | {body: .body, path: .path, user: .user.login}'

Length of output: 752


@Amxx, thank you for the reference. I've reviewed the discussion, and you're absolutely right.

As you explained in that thread, adding a require check here would create a cross-chain inconsistency risk: if the validation fails, tokens would remain locked/burned on the source chain but never minted on the destination chain, leaving funds in limbo.

The best-effort approach with address(bytes20(toBinary)) is the correct design choice. Since the message origin has been verified through the gateway, and toBinary originates from the encoded addr parameter on line 40, the extraction will work correctly for well-formed addresses. If a malformed address is provided due to user error, the tokens will be minted to the derived address, and recovery may be possible through admin controls rather than having funds permanently locked across chains.

My suggestion to add the length validation was inappropriate for this cross-chain bridging context. Would you like me to resolve this comment thread?


🧠 Learnings used
Learnt from: Amxx
PR: OpenZeppelin/openzeppelin-contracts#5914
File: contracts/crosschain/bridges/BridgeERC20.sol:57-58
Timestamp: 2025-10-03T13:14:57.638Z
Learning: In cross-chain bridge contracts like BridgeERC20, when processing incoming messages in _processMessage, avoid validation checks that revert on malformed addresses. Reverting would create cross-chain inconsistency where tokens are locked/burned on the source chain but never minted on the destination. Instead, use best-effort address extraction (e.g., address(bytes20(toBinary))) to maintain atomicity across chains. If tokens are minted to an incorrect address due to user error, recovery may be possible through admin controls rather than leaving funds permanently locked.


/// @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;
}
32 changes: 32 additions & 0 deletions contracts/crosschain/bridges/BridgeERC20Bridgeable.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.24;

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

/**
* @dev This is a variant of {BridgeERC20} that implements the bridge logic for ERC-7802 compliant tokens.
*/
abstract contract BridgeERC20Bridgeable is BridgeERC20 {
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 _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);
}
}
35 changes: 35 additions & 0 deletions contracts/crosschain/bridges/BridgeERC20Custodial.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.24;

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 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;

IERC20 private immutable _token;

constructor(IERC20 token_) {
_token = token_;
}
Comment on lines +17 to +19
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Validate token address in constructor.

The constructor does not validate that token_ is non-zero. If deployed with address(0), all custody operations (_lock and _unlock) will fail, rendering the bridge unusable.

Apply this diff to add validation:

+    error InvalidToken();
+
     constructor(IERC20 token_) {
+        if (address(token_) == address(0)) revert InvalidToken();
         _token = token_;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
constructor(IERC20 token_) {
_token = token_;
}
error InvalidToken();
constructor(IERC20 token_) {
if (address(token_) == address(0)) revert InvalidToken();
_token = token_;
}
🤖 Prompt for AI Agents
In contracts/crosschain/bridges/BridgeERC20Custodial.sol around lines 17 to 19,
the constructor currently assigns token_ without validation which allows
deploying with address(0); add a require that address(token_) != address(0) (or
token_ != IERC20(address(0))) with a clear revert message (e.g., "token cannot
be zero") before assigning _token to ensure a non-zero ERC20 address is
provided.


/// @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);
}
}
4 changes: 2 additions & 2 deletions contracts/interfaces/draft-IERC7786.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -49,7 +49,7 @@ interface IERC7786GatewaySource {
*
* See ERC-7786 for more details
*/
interface IERC7786Receiver {
interface IERC7786Recipient {
/**
* @dev Endpoint for receiving cross-chain message.
*
Expand Down
56 changes: 56 additions & 0 deletions contracts/mocks/crosschain/ERC7786GatewayMock.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.27;

import {IERC7786GatewaySource, IERC7786Recipient} 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 = IERC7786Recipient(target).receiveMessage{value: msg.value}(
bytes32(++_lastReceiveId),
InteroperableAddress.formatEvmV1(block.chainid, msg.sender),
payload
);
require(magic == IERC7786Recipient.receiveMessage.selector, ReceiverError());

// emit standard event
emit MessageSent(
bytes32(0),
InteroperableAddress.formatEvmV1(block.chainid, msg.sender),
recipient,
payload,
msg.value,
attributes
);

return 0;
}
}
Loading
Loading