Skip to content

ERC-7786 based crosschain bridge for ERC-721 tokens#6259

Merged
Amxx merged 27 commits intoOpenZeppelin:masterfrom
Amxx:crosschain/erc721bridge
Feb 26, 2026
Merged

ERC-7786 based crosschain bridge for ERC-721 tokens#6259
Amxx merged 27 commits intoOpenZeppelin:masterfrom
Amxx:crosschain/erc721bridge

Conversation

@Amxx
Copy link
Collaborator

@Amxx Amxx commented Jan 5, 2026

Followup to #5914
Fixes #5901

PR Checklist

  • Tests
  • Documentation
  • Changeset entry (run npx changeset add)

@Amxx Amxx requested a review from a team as a code owner January 5, 2026 12:58
@changeset-bot
Copy link

changeset-bot bot commented Jan 5, 2026

🦋 Changeset detected

Latest commit: d2cef0a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
openzeppelin-solidity Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 5, 2026

Walkthrough

This PR introduces cross-chain bridging support for ERC-721 tokens across multiple chains. It adds three new Solidity contracts: BridgeERC721Core (abstract base providing core cross-chain transfer and message processing logic), BridgeERC721 (custody-based bridge implementation with token locking/unlocking), and ERC721Crosschain (ERC721 extension enabling native cross-chain transfers). The implementation leverages ERC-7786 gateway infrastructure for message passing and includes internal hooks for handling token state transitions on send and receive. Test files provide comprehensive coverage including behavior specification, permission validation, and end-to-end transfer scenarios in both directions.

Possibly related PRs

Suggested labels

crosschain, ignore-changeset

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'ERC-7786 based crosschain bridge for ERC-721 tokens' directly and clearly describes the main change: adding ERC-7786 based cross-chain bridge functionality for ERC-721 tokens, which is the core focus of all code changes in this PR.
Linked Issues check ✅ Passed The PR implements ERC-7786 based bridging for ERC-721 tokens across the new BridgeERC721Core, BridgeERC721, and ERC721Crosschain contracts with comprehensive test coverage, directly addressing issue #5901's objective to add an ERC-7786 bridgeable ERC721 extension.
Out of Scope Changes check ✅ Passed All changes are directly related to implementing ERC-7786 cross-chain bridging for ERC-721 tokens; minor reorganization of existing ERC-20 tests (BridgeERC20.behavior.js) appears to be structural cleanup without functional changes and does not constitute scope creep.
Description check ✅ Passed The PR description references related issues (#5914, #5901) and includes a checklist, confirming its relation to ERC-721 cross-chain bridging changes in the changeset.
✨ Finishing touches
  • 📝 Generate docstrings

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

Fix all issues with AI Agents 🤖
In @contracts/crosschain/bridges/BridgeERC721.sol:
- Line 35: Fix the typo in the comment that currently reads "Permission is
handeled using the ERC721's allowance system. This check replicates
`ERC721._isAuthorized`." — change "handeled" to "handled" in the comment near
the BridgeERC721 contract (the line containing that sentence) so the comment
correctly reads "Permission is handled...".

In @contracts/crosschain/bridges/BridgeERC721Core.sol:
- Line 18: Fix the NatSpec typo in the contract documentation where the phrase
"the {ERC721Crosschain}extension" appears: insert a space so it reads "the
{ERC721Crosschain} extension" (update the comment containing {ERC721Crosschain}
to include the space before "extension").

In @test/crosschain/BridgeERC721.behavior.js:
- Line 198: The test title string passed to the it(...) call is misspelled:
replace 'cannot override configuration is "allowOverride" is false' with 'cannot
override configuration if "allowOverride" is false' by updating the it(...)
description (the string literal) in the test case so the wording uses "if"
instead of "is".

In @test/crosschain/BridgeERC721.test.js:
- Line 71: Fix the typo in the test comment that currently reads "bridge on
custodial chain releases mints the token" by updating it to a grammatically
correct phrase such as "bridge on custodial chain releases the token" (or
"bridge on custodial chain mints the token" if minting is intended); edit the
comment in BridgeERC721.test.js where that string appears so it clearly reflects
the test behavior.
- Line 39: The test suite is misnamed: change the describe title from
"CrosschainBridgeERC20" to "CrosschainBridgeERC721" so it accurately reflects
the tests in BridgeERC721.test.js; locate the describe(...) call with the string
"CrosschainBridgeERC20" and update that literal to "CrosschainBridgeERC721"
(ensure any other occurrences in this file that reference the ERC20 name are
also updated for consistency).

In @test/token/ERC721/extensions/ERC721Crosschain.test.js:
- Line 38: The test suite name is wrong: rename the describe block currently
labeled 'CrosschainBridgeERC20' to 'CrosschainBridgeERC721' to match the file
and the used behavior test shouldBehaveLikeBridgeERC721; update the describe
string in the test file so the suite accurately reflects ERC721 tests and any
related test output logs.
🧹 Nitpick comments (1)
contracts/crosschain/bridges/BridgeERC721.sol (1)

61-62: Resolve TODO: msg.sender is correct here.

Using msg.sender directly is the correct choice for this check because it validates the actual calling contract address. Using _msgSender() could allow meta-transaction forwarders to bypass this security check. Consider removing the TODO and optionally adding a brief comment explaining why msg.sender is used intentionally.

🔎 Proposed fix
-        // TODO: should this consider _msgSender() ?
-        require(msg.sender == address(_token), BridgeERC721Unauthorized(msg.sender));
+        // Note: Using msg.sender intentionally (not _msgSender) to validate the actual caller contract
+        require(msg.sender == address(_token), BridgeERC721Unauthorized(msg.sender));
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a83d9aa and 769398e.

📒 Files selected for processing (7)
  • contracts/crosschain/bridges/BridgeERC721.sol
  • contracts/crosschain/bridges/BridgeERC721Core.sol
  • contracts/token/ERC721/extensions/ERC721Crosschain.sol
  • test/crosschain/BridgeERC20.behavior.js
  • test/crosschain/BridgeERC721.behavior.js
  • test/crosschain/BridgeERC721.test.js
  • test/token/ERC721/extensions/ERC721Crosschain.test.js
🧰 Additional context used
🧠 Learnings (4)
📓 Common learnings
Learnt from: Amxx
Repo: OpenZeppelin/openzeppelin-contracts PR: 5914
File: contracts/crosschain/bridges/BridgeERC20.sol:57-58
Timestamp: 2025-10-03T13:14:57.679Z
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.
📚 Learning: 2025-10-03T13:14:57.679Z
Learnt from: Amxx
Repo: OpenZeppelin/openzeppelin-contracts PR: 5914
File: contracts/crosschain/bridges/BridgeERC20.sol:57-58
Timestamp: 2025-10-03T13:14:57.679Z
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.

Applied to files:

  • test/crosschain/BridgeERC721.test.js
  • test/token/ERC721/extensions/ERC721Crosschain.test.js
  • test/crosschain/BridgeERC20.behavior.js
  • test/crosschain/BridgeERC721.behavior.js
  • contracts/crosschain/bridges/BridgeERC721.sol
  • contracts/token/ERC721/extensions/ERC721Crosschain.sol
  • contracts/crosschain/bridges/BridgeERC721Core.sol
📚 Learning: 2025-08-29T13:16:08.640Z
Learnt from: Amxx
Repo: OpenZeppelin/openzeppelin-contracts PR: 5904
File: contracts/mocks/crosschain/ERC7786RecipientMock.sol:12-14
Timestamp: 2025-08-29T13:16:08.640Z
Learning: In OpenZeppelin contracts, mock contracts (like ERC7786RecipientMock) don't require input validation such as zero-address checks in constructors, as they are only used for testing purposes in controlled environments.

Applied to files:

  • test/crosschain/BridgeERC721.test.js
  • test/crosschain/BridgeERC20.behavior.js
  • test/crosschain/BridgeERC721.behavior.js
  • contracts/crosschain/bridges/BridgeERC721.sol
  • contracts/crosschain/bridges/BridgeERC721Core.sol
📚 Learning: 2025-10-15T02:52:05.027Z
Learnt from: ernestognw
Repo: OpenZeppelin/openzeppelin-contracts PR: 5891
File: test/account/modules/ERC7579Module.behavior.js:56-61
Timestamp: 2025-10-15T02:52:05.027Z
Learning: In ERC7579 validator tests for `isValidSignatureWithSender`, using `this.mock` (not bound to a specific account) is valid when testing signature validation with any arbitrary sender, while `this.mockFromAccount` is used when testing account-specific validation scenarios.

Applied to files:

  • test/crosschain/BridgeERC721.test.js
🧬 Code graph analysis (3)
test/crosschain/BridgeERC721.test.js (4)
test/crosschain/BridgeERC721.behavior.js (7)
  • require (1-1)
  • require (2-2)
  • require (3-3)
  • tokenId (5-5)
  • tokenId (73-73)
  • tokenId (85-85)
  • tokenId (104-104)
test/token/ERC721/extensions/ERC721Crosschain.test.js (7)
  • require (1-1)
  • require (2-2)
  • require (3-3)
  • require (5-5)
  • require (6-6)
  • require (8-8)
  • chain (11-11)
test/crosschain/ERC7786Recipient.test.js (1)
  • getLocalChain (15-15)
test/helpers/account.js (1)
  • impersonate (7-12)
test/crosschain/BridgeERC20.behavior.js (2)
test/crosschain/BridgeERC721.behavior.js (6)
  • from (160-160)
  • alice (30-30)
  • alice (72-72)
  • alice (84-84)
  • alice (103-103)
  • id (162-162)
test/token/ERC20/extensions/ERC20Crosschain.test.js (4)
  • amount (46-46)
  • amount (74-74)
  • alice (45-45)
  • alice (73-73)
test/crosschain/BridgeERC721.behavior.js (2)
test/token/ERC721/extensions/ERC721Crosschain.test.js (6)
  • require (1-1)
  • require (2-2)
  • require (3-3)
  • require (5-5)
  • require (6-6)
  • require (8-8)
test/crosschain/BridgeERC721.test.js (7)
  • require (1-1)
  • require (2-2)
  • require (3-3)
  • require (4-4)
  • require (6-6)
  • require (7-7)
  • require (9-9)
🪛 GitHub Check: codespell
contracts/crosschain/bridges/BridgeERC721.sol

[failure] 35-35:
handeled ==> handled, handheld

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (9)
  • GitHub Check: Redirect rules - solidity-contracts
  • GitHub Check: Header rules - solidity-contracts
  • GitHub Check: Pages changed - solidity-contracts
  • GitHub Check: tests-upgradeable
  • GitHub Check: coverage
  • GitHub Check: tests
  • GitHub Check: tests-foundry
  • GitHub Check: slither
  • GitHub Check: halmos
🔇 Additional comments (20)
test/crosschain/BridgeERC20.behavior.js (5)

9-16: LGTM!

The encodePayload helper is well-designed with consistent address encoding via toErc7930() and defensive handling for the to parameter. The structure aligns with ERC-7786 message format requirements.


18-27: LGTM!

The bridge setup test correctly validates bidirectional link configuration between chains. The assertion properly checks both gateway and counterpart addresses in the expected format.


29-74: LGTM!

The crosschain send test is comprehensive and well-structured. It correctly validates bidirectional transfers with proper event sequencing and custody-model-aware assertions. The use of anyValue for message IDs is appropriate, and the test flows logically through a realistic token transfer scenario.


76-127: LGTM!

The restrictions tests comprehensively validate access control and replay protection. The error assertions correctly target specific custom errors, and the test setup with pre-minted tokens enables proper message processing. The replay protection test appropriately uses ZeroHash for deterministic testing.


129-159: LGTM!

The reconfiguration tests properly validate link management with event validation, immutability protection, and gateway interface validation. The revert-without-reason in the invalid gateway test suggests an interface check, which is a reasonable approach for validating contract types.

test/token/ERC721/extensions/ERC721Crosschain.test.js (1)

10-36: LGTM!

The fixture correctly sets up a cross-chain test scenario with two ERC721Crosschain tokens, a mock gateway, and proper link registration with event verification.

test/crosschain/BridgeERC721.test.js (1)

11-48: LGTM!

The fixture correctly sets up a heterogeneous cross-chain scenario with a legacy ERC721 using a custody-based bridge on chain A and an ERC721Crosschain on chain B. The chainAIsCustodial: true parameter appropriately configures the shared behavior suite.

test/crosschain/BridgeERC721.behavior.js (5)

1-16: LGTM!

The shared behavior suite setup is well-structured with a flexible configuration pattern (chainAIsCustodial, chainBIsCustodial) and a clear payload encoding helper.


29-68: LGTM!

The bidirectional cross-chain transfer test correctly validates custody semantics based on the chainAIsCustodial/chainBIsCustodial configuration, with appropriate event assertions for each direction.


70-124: LGTM!

The allowance tests comprehensively cover owner transfers, universal operator approval, and token-specific approval, including proper negative test cases for unauthorized transfers.


126-181: LGTM!

The restriction tests properly validate gateway authorization, counterpart verification, and replay protection. The conditional minting in the replay test correctly handles the custodial configuration.


183-213: LGTM!

The reconfiguration tests properly validate link updates, override prevention, and gateway validation.

contracts/token/ERC721/extensions/ERC721Crosschain.sol (3)

16-21: LGTM!

The crosschainTransferFrom correctly delegates to _crosschainTransfer, and the comment appropriately notes that permission checks occur in _onSend.


24-31: LGTM!

The _onSend implementation correctly burns the token using _update(address(0), tokenId, _msgSender()), which internally validates operator approval. The subsequent checks properly handle non-existent tokens and ownership verification.


34-36: LGTM!

The _onReceive implementation correctly uses a best-effort approach by minting without additional validation, which aligns with cross-chain bridge design principles to avoid inconsistency when tokens are already locked/burned on the source chain. Based on learnings from similar cross-chain bridge patterns.

contracts/crosschain/bridges/BridgeERC721Core.sol (2)

31-46: LGTM!

The _crosschainTransfer implementation correctly sequences the operations: first calls _onSend to lock/burn tokens, then encodes and sends the cross-chain message, and finally emits the event with the message ID.


49-62: LGTM!

The _processMessage correctly uses best-effort address extraction (address(bytes20(toBinary))) without validation that could revert, which maintains cross-chain atomicity. This aligns with the established pattern for cross-chain bridges where reverting would leave tokens locked on the source chain with no recourse.

contracts/crosschain/bridges/BridgeERC721.sol (3)

34-48: LGTM!

The crosschainTransferFrom correctly replicates ERC721 authorization checks, uses transferFrom (not safeTransferFrom) to avoid re-entrancy into onERC721Received, and properly sequences custody acquisition before initiating the cross-chain transfer.


55-65: LGTM!

The onERC721Received hook correctly enables safeTransferFrom-based cross-chain transfers with proper validation that only the bridged token contract can trigger it.


67-80: LGTM!

The hook implementations are correctly designed for the custody-based model. The _onSend no-op is appropriate since custody is handled earlier, and _onReceive using safeTransferFrom provides recipient safety with the documented retry mechanism at the gateway level for failed transfers.

Amxx and others added 3 commits January 5, 2026 14:03
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
@Amxx Amxx added this to the 5.7 milestone Jan 5, 2026
@Amxx Amxx added the crosschain label Jan 5, 2026
@Amxx Amxx requested a review from frangio January 8, 2026 16:01
);

// This call verifies that `from` is the owner of `tokenId`, and the previous checks ensure that `spender` is
// allowed to move tokenId on behalf of `from`.
Copy link
Contributor

Choose a reason for hiding this comment

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

Isn't this something that's checked by the token?

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 was updated to

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

The first part is indeed checked by the token. That is what I'm saying (maybe it could be more explicit).
The second part is not checked by the token. The token check that the spender he sees is allowed. That is the bridge itself. So the token will check that the owner authorised the bridge, and the bridge will do an additional check that whoever did the crosschainTransferFrom is also authorised by the owner of token. That last check is not (and cannot) be done by the token itself.

Copy link
Contributor

Choose a reason for hiding this comment

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

I see so this allows authorized ERC-721 spenders to also do cross-chain transfers.

This needs to be documented in the interface of crosschainTransferFrom.

frangio
frangio previously approved these changes Jan 13, 2026
@Amxx Amxx requested a review from gonzaotc January 13, 2026 17:29
* @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.

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

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.


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

Co-authored-by: Ernesto García <ernestognw@gmail.com>
@Amxx Amxx requested review from ernestognw and frangio February 19, 2026 21:23
@Amxx Amxx merged commit 77b1d80 into OpenZeppelin:master Feb 26, 2026
21 checks passed
@Amxx Amxx deleted the crosschain/erc721bridge branch February 26, 2026 14:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add ERC7786 bridgeable ERC721 extension

3 participants