Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
81 changes: 81 additions & 0 deletions contracts/crosschain/bridges/BridgeERC721.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.26;

import {IERC721} from "../../interfaces/IERC721.sol";
import {IERC721Receiver} from "../../interfaces/IERC721Receiver.sol";
import {IERC721Errors} from "../../interfaces/draft-IERC6093.sol";
import {BridgeERC721Core} from "./BridgeERC721Core.sol";

/**
* @dev This is a variant of {BridgeERC721Core} 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 IERC721Receiver, BridgeERC721Core {
IERC721 private immutable _token;

error BridgeERC721Unauthorized(address caller);

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 `amount` tokens to a crosschain receiver.
*
* Note: The `to` parameter is the full InteroperableAddress (chain ref + address).
*/
function crosschainTransferFrom(address from, bytes memory to, uint256 tokenId) public virtual returns (bytes32) {
// Permission is handeled using the ERC721's allowance system. This check replicates `ERC721._isAuthorized`.

Check failure on line 35 in contracts/crosschain/bridges/BridgeERC721.sol

View workflow job for this annotation

GitHub Actions / codespell

handeled ==> handled, handheld
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`
// Note: do not use safeTransferFrom here! Using it would trigger the `onERC721Received` which we don't want.
token().transferFrom(from, address(this), tokenId);

// Perform the crosschain transfer and return the handler
return _crosschainTransfer(from, to, tokenId);
}

/**
* @dev Transfer a token received using an ERC-721 safeTransferFrom
*
* Note: The `data` must contain the `to` as a full InteroperableAddress (chain ref + address).
*/
function onERC721Received(
address /*operator*/,
address from,
uint256 tokenId,
bytes calldata data // this is the to
) public virtual override returns (bytes4) {
// TODO: should this consider _msgSender() ?
require(msg.sender == address(_token), BridgeERC721Unauthorized(msg.sender));
_crosschainTransfer(from, data, tokenId);
return IERC721Receiver.onERC721Received.selector;
}

/// @dev "Locking" tokens is done by taking custody
function _onSend(address from, uint256 tokenId) internal virtual override {
// Do nothing, token movement is handled by `crosschainTransfer` and `onERC721Received`
}

/**
* @dev "Unlocking" tokens is done by releasing custody
*
* NOTE: `safeTransferFrom` will revert if the receiver is a contract that doesn't implement {IERC721Receiver}
* This can be retried by at the ERC-7786 gateway level.
*/
function _onReceive(address to, uint256 tokenId) internal virtual override {
token().safeTransferFrom(address(this), to, tokenId);
}
}
69 changes: 69 additions & 0 deletions contracts/crosschain/bridges/BridgeERC721Core.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// 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 BridgeERC721Core is Context, CrosschainLinked {
using InteroperableAddress for bytes;

event CrosschainERC721TransferSent(bytes32 indexed sendId, address indexed from, bytes to, uint256 tokenId);
event CrosschainERC721TransferReceived(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) = to.parseV1();
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 CrosschainERC721TransferSent(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 toBinary, uint256 tokenId) = abi.decode(payload, (bytes, bytes, uint256));
Copy link
Member

@ernestognw ernestognw Feb 4, 2026

Choose a reason for hiding this comment

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

It would be easy to decode this in calldata afaik

require(payload.length > 9f); // fromOffset + toBinaryOffset + tokenId + fromLength + toBinaryLength
uint256 fromOffset = payload[:0x20];
uint256 toBinaryOffset = payload[0x20:0x40];
require(fromOffset > 0x5f && fromOffset <= payload.length && toBinaryOffset > 0x7f && toBinaryOffset <= payload.length);
uint256 tokenId = payload[0x40:0x60];
uint256 fromLength = payload[fromOffset:fromOffset + 0x20];
uint256 toBinaryLength = payload[toBinaryOffset:toBinaryOffset + 0x20];
require(fromOffset + fromLength <= payload.length && toBinaryOffset + toBinaryLength <= payload.length)

So we don't allocate extra memory

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Readability is not great. I think in the context of a "we are moving tokens" operation, the extra gas cost here is not that bad.

If we really want to micro optimize this, I would first merge the "simple solidity" version, and then do small PR that incrementally optimise. That way we can leverage our tools for gas comparison.

address to = address(bytes20(toBinary));

_onReceive(to, tokenId);

emit CrosschainERC721TransferReceived(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;
}
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 {BridgeERC721Core} from "../../../crosschain/bridges/BridgeERC721Core.sol";

/**
* @dev Extension of {ERC721} that makes it natively cross-chain using the ERC-7786 based {BridgeERC721Core}.
*
* 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, BridgeERC721Core {
/// @dev TransferFrom variant of {crosschainTransferFrom}, using ERC721 allowance from the sender to the caller.
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).

}
}
Loading
Loading