Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
9535015
Add crosschain bridging logic for ERC721
Amxx Dec 28, 2025
769398e
update
Amxx Jan 5, 2026
adddbc4
Apply suggestions from code review
Amxx Jan 5, 2026
e52226c
Apply suggestions from code review
Amxx Jan 5, 2026
4c6ee7e
changeset entries
Amxx Jan 5, 2026
42ffea0
Merge branch 'crosschain/erc721bridge' of https://github.com/Amxx/ope…
Amxx Jan 5, 2026
7a0db4e
slither
Amxx Jan 5, 2026
07f5812
coverage
Amxx Jan 5, 2026
d756c5c
slither
Amxx Jan 5, 2026
551a3c7
documentation
Amxx Jan 5, 2026
9f3a5e4
Apply suggestion from @Amxx
Amxx Jan 12, 2026
4355ab0
refactor flow
Amxx Jan 12, 2026
6c50eaf
Remove the IERC721Receiver flow to avoid risks associated to hiden da…
Amxx Jan 12, 2026
f490550
Apply suggestions from code review
Amxx Jan 12, 2026
88faa8e
use transferFrom in
Amxx Jan 12, 2026
86ef787
update docs
Amxx Jan 13, 2026
ade7c92
Apply suggestions from code review
Amxx Jan 13, 2026
bbbfa0b
Update contracts/crosschain/bridges/BridgeERC721Core.sol
Amxx Feb 4, 2026
09aebc7
PR comments
Amxx Feb 4, 2026
f8ff779
Merge branch 'crosschain/erc721bridge' of https://github.com/Amxx/ope…
Amxx Feb 4, 2026
6cf8faf
Merge branch 'master' into crosschain/erc721bridge
Amxx Feb 7, 2026
7f6d544
rename / reorganise following the ERC20 changes
Amxx Feb 7, 2026
86c41a5
Merge branch 'master' into crosschain/erc721bridge
Amxx Feb 12, 2026
be7ab96
Apply suggestions from code review
Amxx Feb 12, 2026
9a53ca2
Merge branch 'master' into crosschain/erc721bridge
Amxx Feb 19, 2026
981a262
update test following ERC7786Recipient update
Amxx Feb 19, 2026
d2cef0a
reorder README
Amxx Feb 19, 2026
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/fruity-coats-smash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'openzeppelin-solidity': minor
---

`BridgeNonFungibleCore` and `BridgeERC721`: Added bridge contracts to handle crosschain movements of ERC-721 tokens.
5 changes: 5 additions & 0 deletions .changeset/tidy-turkeys-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'openzeppelin-solidity': minor
---

`ERC721Crosschain`: Added an ERC-721 extension to embed an ERC-7786 based crosschain bridge directly in the token contract.
16 changes: 11 additions & 5 deletions contracts/crosschain/README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ This directory contains contracts for sending and receiving cross chain messages
Additionally there are multiple bridge constructions:

* {BridgeFungible}: 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 counterparts on remote chains,
* {BridgeERC7802}: Standalone bridge contract to connect an ERC-7802 token contract with counterparts on remote chains.
* {BridgeNonFungible}: Core bridging logic for crosschain ERC-721 transfer. Used by {BridgeERC721} and {ERC721Crosschain},
* {BridgeMultiToken}: Core bridging logic for crosschain ERC-1155 transfer. Used by {BridgeERC1155} and {ERC1155Crosschain},
* {BridgeERC20}: Standalone bridge contract to connect an ERC-20 token contract with counterparts on remote chains,
* {BridgeERC721}: Standalone bridge contract to connect an ERC-721 token contract with counterparts on remote chains,
* {BridgeERC1155}: Standalone bridge contract to connect an ERC-1155 token contract with counterparts on remote chains,
* {BridgeERC7802}: Standalone bridge contract to connect an ERC-7802 token contract with counterparts on remote chains.

== Helpers

Expand All @@ -26,10 +28,14 @@ Additionally there are multiple bridge constructions:

{{BridgeFungible}}

{{BridgeERC20}}

{{BridgeERC7802}}
{{BridgeNonFungible}}

{{BridgeMultiToken}}

{{BridgeERC20}}

{{BridgeERC721}}

{{BridgeERC1155}}

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

pragma solidity ^0.8.26;

import {IERC721} from "../../interfaces/IERC721.sol";
import {IERC721Errors} from "../../interfaces/draft-IERC6093.sol";
import {BridgeNonFungible} from "./abstract/BridgeNonFungible.sol";

/**
* @dev This is a variant of {BridgeNonFungible} that implements the bridge logic for ERC-721 tokens that do not expose
* a crosschain mint and burn mechanism. Instead, it takes custody of bridged assets.
*/
// slither-disable-next-line locked-ether
Copy link
Member

Choose a reason for hiding this comment

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

Why is this line required? I see no relation with its documentation

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

If we don't have it, slither will be unhappy. This is because the IERC7786Recipient.receiveMessage is payable (to accomodate gateways that want to send native token.

In our case, we could teoretically get ether in, and there would be no way of getting it out. Slither is unhappy about that, and this is how we silent it.

Copy link
Member

Choose a reason for hiding this comment

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

Interesting. I'm curious why slither is not unhappy about the ERC7786Recipient contract since it also implements the receiveMessage payable. Maybe it's because BridgeERC721 implements processMessage without handling value, so there are no other functions left to handle the native value received in receiveMessage.

I agree BridgeERC721 shouldn't handle value at all, but, maybe we should restrict value sent in receiveMessage? E.g.:

function _processMessage(
    address gateway,
    bytes32 receiveId,
    bytes calldata sender,
    bytes calldata payload
) internal virtual override {
    require(msg.value < 1);
    super._processMessage(gateway, receiveId, sender, payload);
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I think its because ERC7786Recipient (and BridgeERC721) have unimplemented functions. Slither doesn't know what they do, and if they are going to move ether out, so I guess its not flagging them.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

maybe we should restrict value sent in receiveMessage

If we do that, and if the gateway ever decides to send some value, then we would not be processing the message, and the ERC-721 NFT would not be minted/released. Just becaused we receive either we don't know how to handle, we would be posibly bricking a token. Sounds like a disaster waiting to happen.

Also, what if someone wants to override the bridge to add more logic that deals with ethers. Some admin may add a drain, and that is fine. Other may want to add more code that deals with the token as a payment, and automatically move it somewhere or something.

I wouldn't add a check here.

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, got it. So the receiving side may require value to process the message (e.g. charging the gas for passing the message).

Let's not add the payable check, but I still think Slither has a good point, and we should document that if the underlying bridge sends value to receiveMessage, such value will indeed be stuck.

abstract contract BridgeERC721 is BridgeNonFungible {
IERC721 private immutable _token;

constructor(IERC721 token_) {
_token = token_;
}

/// @dev Return the address of the ERC721 token this bridge operates on.
function token() public view virtual returns (IERC721) {
return _token;
}

/**
* @dev Transfer `tokenId` from `from` (on this chain) to `to` (on a different chain).
*
* The `to` parameter is the full InteroperableAddress that references both the destination chain and the account
* on that chain. Similarly to the underlying token's {ERC721-transferFrom} function, this function can be called
* either by the token holder or by anyone that is approved by the token holder. It reuses the token's allowance
* system, meaning that an account that is "approved for all" or "approved for tokenId" can perform the crosschain
* transfer directly without having to take temporary custody of the token.
*/
function crosschainTransferFrom(address from, bytes memory to, uint256 tokenId) public virtual returns (bytes32) {
// Permission is handled using the ERC721's allowance system. This check replicates `ERC721._isAuthorized`.
address spender = _msgSender();
require(
from == spender || token().isApprovedForAll(from, spender) || token().getApproved(tokenId) == spender,
IERC721Errors.ERC721InsufficientApproval(spender, tokenId)
);

// This call verifies that `from` is the owner of `tokenId` (in `_onSend`), and the previous checks ensure
// that `spender` is allowed to move tokenId on behalf of `from`.
//
// Perform the crosschain transfer and return the send id
return _crosschainTransfer(from, to, tokenId);
}

/// @dev "Locking" tokens is done by taking custody
function _onSend(address from, uint256 tokenId) internal virtual override {
// slither-disable-next-line arbitrary-send-erc20
token().transferFrom(from, address(this), tokenId);
Copy link
Member

Choose a reason for hiding this comment

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

Shouldn't this contract implement onERC721Received? How's the bridge able to receive the tokens?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is an unsafe transfer, to it will work without the onERC721Received hook present in the bridge.

We used to have a onERC721Received hook that allowed anyone to do a safeTransferFrom to the the bridge, and pass in data that contained the to (in ERC-7830 format). That way you were able to do a crosschain send in just one operation, without the approval+transfer pattern.

We decided to remove that because of security issues. Not on the contract side, but on the wallet side. Basically, if an app/UI asks your wallet to do a safeTransferFrom to the bridge, you'll see you are sending the token to the bridge (which is good), but its likelly you won't see the data part of the transfer. If the app/UI puts the wrong data (either maliciously or by mistake) you are going to send you token to some unexpected recipient, and the wallet won't show that information to you.

@frangio

Copy link
Member

Choose a reason for hiding this comment

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

Basically, if an app/UI asks your wallet to do a safeTransferFrom to the bridge, you'll see you are sending the token to the bridge (which is good), but its likelly you won't see the data part of the transfer.

I see. So one thing is actually using the data argument to allow one-step crosschain transfers, but another is guaranteeing that the recipient can indeed receive the token. One thing that may happen is that the message is processed and the token is effectively sent to an address that can't transfer it out. Wouldn't be better that the bridge keeps custody in those cases?

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 not really about "guaranteeing that the recipient can receive the token". Its about guaranteeing that the sender sees (and can verify) who he is sending the token to. I believe the sender should not be "blindly" signing the crosschain recipient. If the wallet doesn't display the "data" section that his happening.

In particular, there is no way for a bridge to distinguish between:

  • the EOA of the (valid) receiver
  • the EOA of an attacker that injected malicious "data", that the user was not able to review because the wallet hides it
  • an invalid address that has no code, and that doesn't have a (known) private key.

Copy link
Contributor

@frangio frangio Feb 10, 2026

Choose a reason for hiding this comment

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

One thing that may happen is that the message is processed and the token is effectively sent to an address that can't transfer it out. Wouldn't be better that the bridge keeps custody in those cases?

We briefly discussed this before and landed on transferFrom because it seems to require fewer assumptions for recovery. The bridge keeping custody is only better if the message can be retried after the receiver has been upgraded with the receive handler. If you do transferFrom, you just need the receiver to be upgradeable.

An alternative path for recovery would be if the failure to deliver the message could be reported back in the source chain. But these are strong assumptions that 7786 doesn't guarantee at all.

Edit: Just saw this was discussed in another thread #6259 (comment)

}

/// @dev "Unlocking" tokens is done by releasing custody
function _onReceive(address to, uint256 tokenId) internal virtual override {
// slither-disable-next-line arbitrary-send-erc20
token().transferFrom(address(this), to, tokenId);
}
}
11 changes: 6 additions & 5 deletions contracts/crosschain/bridges/abstract/BridgeFungible.sol
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ import {CrosschainLinked} from "../../CrosschainLinked.sol";
* extension, which embeds the bridge logic directly in the token contract.
*/
abstract contract BridgeFungible is Context, CrosschainLinked {
using InteroperableAddress for bytes;

/// @dev Emitted when a crosschain ERC-20 transfer is sent.
event CrosschainFungibleTransferSent(bytes32 indexed sendId, address indexed from, bytes to, uint256 amount);

/// @dev Emitted when a crosschain ERC-20 transfer is received.
event CrosschainFungibleTransferReceived(bytes32 indexed receiveId, bytes from, address indexed to, uint256 amount);

/**
Expand All @@ -41,7 +42,7 @@ abstract contract BridgeFungible is Context, CrosschainLinked {
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();
(bytes2 chainType, bytes memory chainReference, bytes memory addr) = InteroperableAddress.parseV1(to);
bytes memory chain = InteroperableAddress.formatV1(chainType, chainReference, hex"");

bytes32 sendId = _sendMessageToCounterpart(
Expand All @@ -65,8 +66,8 @@ abstract contract BridgeFungible is Context, CrosschainLinked {
// NOTE: Gateway is validated by {_isAuthorizedGateway} (implemented in {CrosschainLinked}). No need to check here.

// split payload
(bytes memory from, bytes memory toBinary, uint256 amount) = abi.decode(payload, (bytes, bytes, uint256));
address to = address(bytes20(toBinary));
(bytes memory from, bytes memory toEvm, uint256 amount) = abi.decode(payload, (bytes, bytes, uint256));
address to = address(bytes20(toEvm));

_onReceive(to, amount);

Expand Down
75 changes: 75 additions & 0 deletions contracts/crosschain/bridges/abstract/BridgeNonFungible.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.26;

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

/**
* @dev Base contract for bridging ERC-721 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 in. Must give tokens to the receiver.
*
* This base contract is used by the {BridgeERC721}, which interfaces with legacy ERC-721 tokens. It is also used by
* the {ERC721Crosschain} extension, which embeds the bridge logic directly in the token contract.
*/
abstract contract BridgeNonFungible is Context, CrosschainLinked {
/// @dev Emitted when a crosschain ERC-721 transfer is sent.
event CrosschainNonFungibleTransferSent(bytes32 indexed sendId, address indexed from, bytes to, uint256 tokenId);

/// @dev Emitted when a crosschain ERC-721 transfer is received.
event CrosschainNonFungibleTransferReceived(
bytes32 indexed receiveId,
bytes from,
address indexed to,
uint256 tokenId
);

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

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

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

emit CrosschainNonFungibleTransferSent(sendId, from, to, tokenId);

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 toEvm, uint256 tokenId) = abi.decode(payload, (bytes, bytes, uint256));
address to = address(bytes20(toEvm));

_onReceive(to, tokenId);

emit CrosschainNonFungibleTransferReceived(receiveId, from, to, tokenId);
}

/// @dev Virtual function: implementation is required to handle token being burnt or locked on the source chain.
function _onSend(address from, uint256 tokenId) internal virtual;

/// @dev Virtual function: implementation is required to handle token being minted or unlocked on the destination chain.
function _onReceive(address to, uint256 tokenId) internal virtual;
}
17 changes: 10 additions & 7 deletions contracts/token/ERC721/README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,13 @@ OpenZeppelin Contracts provides implementations of all four interfaces:

Additionally there are a few of other extensions:

* {ERC721Burnable}: A way for token holders to burn their own tokens.
* {ERC721Consecutive}: An implementation of https://eips.ethereum.org/EIPS/eip-2309[ERC-2309] for minting batches of tokens during construction, in accordance with ERC-721.
* {ERC721Crosschain}: Embedded {BridgeNonFungible} bridge, making the token crosschain through the use of ERC-7786 gateways.
* {ERC721Pausable}: A primitive to pause contract operation.
* {ERC721Royalty}: A way to signal royalty information following ERC-2981.
* {ERC721URIStorage}: A more flexible but more expensive way of storing metadata.
* {ERC721Votes}: Support for voting and vote delegation.
* {ERC721Royalty}: A way to signal royalty information following ERC-2981.
* {ERC721Pausable}: A primitive to pause contract operation.
* {ERC721Burnable}: A way for token holders to burn their own tokens.
* {ERC721Wrapper}: Wrapper to create an ERC-721 backed by another ERC-721, with deposit and withdraw methods. Useful in conjunction with {ERC721Votes}.

NOTE: This core set of contracts is designed to be unopinionated, allowing developers to access the internal functions in ERC-721 (such as <<ERC721-_mint-address-uint256-,`_mint`>>) and expose them as external functions in the way they prefer.
Expand All @@ -48,18 +49,20 @@ NOTE: This core set of contracts is designed to be unopinionated, allowing devel

== Extensions

{{ERC721Pausable}}

{{ERC721Burnable}}

{{ERC721Consecutive}}

{{ERC721URIStorage}}
{{ERC721Crosschain}}

{{ERC721Votes}}
{{ERC721Pausable}}

{{ERC721Royalty}}

{{ERC721URIStorage}}

{{ERC721Votes}}

{{ERC721Wrapper}}

== Utilities
Expand Down
37 changes: 37 additions & 0 deletions contracts/token/ERC721/extensions/ERC721Crosschain.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.26;

import {ERC721} from "../ERC721.sol";
import {BridgeNonFungible} from "../../../crosschain/bridges/abstract/BridgeNonFungible.sol";

/**
* @dev Extension of {ERC721} that makes it natively cross-chain using the ERC-7786 based {BridgeNonFungible}.
*
* This extension makes the token compatible with:
* * {ERC721Crosschain} instances on other chains,
* * {ERC721} instances on other chains that are bridged using {BridgeERC721},
*/
// slither-disable-next-line locked-ether
abstract contract ERC721Crosschain is ERC721, BridgeNonFungible {
/// @dev Crosschain variant of {transferFrom}, using the allowance system from the underlying ERC-721 token.
function crosschainTransferFrom(address from, bytes memory to, uint256 tokenId) public virtual returns (bytes32) {
// operator (_msgSender) permission over `from` is checked in `_onSend`
return _crosschainTransfer(from, to, tokenId);
}

/// @dev "Locking" tokens is achieved through burning
function _onSend(address from, uint256 tokenId) internal virtual override {
address previousOwner = _update(address(0), tokenId, _msgSender());
if (previousOwner == address(0)) {
revert ERC721NonexistentToken(tokenId);
} else if (previousOwner != from) {
revert ERC721IncorrectOwner(from, tokenId, previousOwner);
}
}

/// @dev "Unlocking" tokens is achieved through minting
function _onReceive(address to, uint256 tokenId) internal virtual override {
_mint(to, tokenId);
Copy link
Member

Choose a reason for hiding this comment

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

Shouldn't we use _safeMint?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I think we used to, and we had a long discussion about that with @frangio.

Basically, if the recipient is not "equiped" to receive tokens, what do we want to happen ?

Option 1

We do a _safeMint (on ERC721Crosschain) / a safeTransferFrom (on BridgeERC721). This causes the ERC7786.receiveMessage to fail. This likelly goes back to the gateway. This token is not moved. To fix the situation, you need to upgrade the receiver to add the required logic, and the re-do the call at the gateway level.

  • if you cannot upgrade the receiver, the token is lost forever
  • If for some reason the upgrade took to long, and some expiry thing on the gateway prevents you from replaying, the token is lost forever.

Options 2

we do a _mint (on ERC721Crosschain) / a transferFrom (on BridgeERC721). This forces the token transfer. From a gateway point of view, everything is fine. If the receiver doesn't know how to handle the token it just received, you need to do an upgrade to had that functionnality

  • if you cannot upgrade the receiver, the token is lost forever

In both cases, an upgrade is needed. But option 1 also adds more logic to replay the message after the upgrade. @frangio and myself thought option 2 is probably better.

Copy link
Member

Choose a reason for hiding this comment

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

I was thinking a 3rd option is to include a "reverse" function (or similar) that cancels out the message delivery if it's not executed in X time. While I agree it's an opinionated decision and may not be the best default, I feel the BridgeERC721 is incomplete if we either suggest a workaround or implement one by default.

To be clear, I overall agree that option 2 is simpler give the case of a stuck token, but may be worth considering a default functionality that lets developers handle it properly. For example, see that ERC721Wrapper has a _recover internal function.

Copy link
Collaborator Author

@Amxx Amxx Feb 4, 2026

Choose a reason for hiding this comment

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

Option 3 comes with its own set of problem.

It could be implemented as an extension, but I don't think this complexity should be in the default.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Lets discuss that on our weekly call.

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm open to an internal _recover function that maybe gets unlocked for a token id once a transfer fails.

My preference would be for the simpler approach that just does transferFrom. But this is not an option in ERC-1155 (#6281).

}
}
28 changes: 0 additions & 28 deletions test/crosschain/BridgeERC1155.behavior.js
Original file line number Diff line number Diff line change
Expand Up @@ -292,34 +292,6 @@ function shouldBehaveLikeBridgeERC1155({ chainAIsCustodial = false, chainBIsCust
.to.be.revertedWithCustomError(this.bridgeA, 'ERC7786RecipientUnauthorizedGateway')
.withArgs(this.gateway, this.chain.toErc7930(invalid));
});

it('cannot replay message', async function () {
const [from, to] = this.accounts;

const receiveId = ethers.ZeroHash;
const payload = this.encodePayload(from, to, ids, values);

if (chainAIsCustodial) {
// cannot use _mintBatch here because the bridge's receive hook prevent it.
await this.tokenA.$_update(ethers.ZeroAddress, this.bridgeA, ids, values);
}

// first time works
await expect(
this.bridgeA
.connect(this.gatewayAsEOA)
.receiveMessage(receiveId, this.chain.toErc7930(this.bridgeB), payload),
).to.emit(this.bridgeA, 'CrosschainMultiTokenTransferReceived');

// second time fails
await expect(
this.bridgeA
.connect(this.gatewayAsEOA)
.receiveMessage(receiveId, this.chain.toErc7930(this.bridgeB), payload),
)
.to.be.revertedWithCustomError(this.bridgeA, 'ERC7786RecipientMessageAlreadyProcessed')
.withArgs(this.gateway, receiveId);
});
});

describe('reconfiguration', function () {
Expand Down
Loading