From 9535015e2c400cf00d10063bd1cd1a8e4f5e8cd6 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Sun, 28 Dec 2025 09:54:52 +0100 Subject: [PATCH 01/22] Add crosschain bridging logic for ERC721 --- contracts/crosschain/bridges/BridgeERC721.sol | 67 +++++ .../crosschain/bridges/BridgeERC721Core.sol | 68 +++++ .../ERC721/extensions/ERC721Crosschain.sol | 42 +++ test/crosschain/BridgeERC20.behavior.js | 272 +++++++++--------- test/crosschain/BridgeERC721.behavior.js | 163 +++++++++++ test/crosschain/BridgeERC721.test.js | 75 +++++ .../extensions/ERC721Crosschain.test.js | 82 ++++++ 7 files changed, 634 insertions(+), 135 deletions(-) create mode 100644 contracts/crosschain/bridges/BridgeERC721.sol create mode 100644 contracts/crosschain/bridges/BridgeERC721Core.sol create mode 100644 contracts/token/ERC721/extensions/ERC721Crosschain.sol create mode 100644 test/crosschain/BridgeERC721.behavior.js create mode 100644 test/crosschain/BridgeERC721.test.js create mode 100644 test/token/ERC721/extensions/ERC721Crosschain.test.js diff --git a/contracts/crosschain/bridges/BridgeERC721.sol b/contracts/crosschain/bridges/BridgeERC721.sol new file mode 100644 index 00000000000..d2a8eab8fca --- /dev/null +++ b/contracts/crosschain/bridges/BridgeERC721.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.26; + +import {IERC721} from "../../interfaces/IERC721.sol"; +import {IERC721Receiver} from "../../interfaces/IERC721Receiver.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 +abstract contract BridgeERC721 is IERC721Receiver, BridgeERC721Core { + 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 `amount` tokens to a crosschain receiver. + * + * Note: The `to` parameter is the full InteroperableAddress (chain ref + address). + */ + function crosschainTransfer(bytes memory to, uint256 tokenId) public virtual returns (bytes32) { + // Note: do not use safeTransferFrom here! Using it would trigger the `onERC721Received` which we don't want. + token().transferFrom(msg.sender, address(this), tokenId); + return _crosschainTransfer(msg.sender, 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) { + require(msg.sender == address(_token)); // TODO + _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); + } +} diff --git a/contracts/crosschain/bridges/BridgeERC721Core.sol b/contracts/crosschain/bridges/BridgeERC721Core.sol new file mode 100644 index 00000000000..adff75bb866 --- /dev/null +++ b/contracts/crosschain/bridges/BridgeERC721Core.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.26; + +import {InteroperableAddress} from "../../utils/draft-InteroperableAddress.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 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)); + 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; +} diff --git a/contracts/token/ERC721/extensions/ERC721Crosschain.sol b/contracts/token/ERC721/extensions/ERC721Crosschain.sol new file mode 100644 index 00000000000..32f31797dff --- /dev/null +++ b/contracts/token/ERC721/extensions/ERC721Crosschain.sol @@ -0,0 +1,42 @@ +// 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 Variant of {crosschainTransferFrom} that uses `_msgSender()` for the `from` for compatibility with {BridgeERc721} + function crosschainTransfer(bytes memory to, uint256 tokenId) public virtual returns (bytes32) { + return crosschainTransferFrom(_msgSender(), to, tokenId); + } + + /// @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 (msg.sender) 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); + } +} diff --git a/test/crosschain/BridgeERC20.behavior.js b/test/crosschain/BridgeERC20.behavior.js index 22874867464..5c1e8a97019 100644 --- a/test/crosschain/BridgeERC20.behavior.js +++ b/test/crosschain/BridgeERC20.behavior.js @@ -5,155 +5,157 @@ const { anyValue } = require('@nomicfoundation/hardhat-chai-matchers/withArgs'); const amount = 100n; function shouldBehaveLikeBridgeERC20({ chainAIsCustodial = false, chainBIsCustodial = false } = {}) { - beforeEach(function () { - // helper - this.encodePayload = (from, to, amount) => - ethers.AbiCoder.defaultAbiCoder().encode( - ['bytes', 'bytes', 'uint256'], - [this.chain.toErc7930(from), to.target ?? to.address ?? to, amount], - ); - }); - - it('bridge setup', async function () { - await expect(this.bridgeA.getLink(this.chain.erc7930)).to.eventually.deep.equal([ - this.gateway.target, - this.chain.toErc7930(this.bridgeB), - ]); - await expect(this.bridgeB.getLink(this.chain.erc7930)).to.eventually.deep.equal([ - this.gateway.target, - this.chain.toErc7930(this.bridgeA), - ]); - }); - - it('crosschain send (both direction)', async function () { - const [alice, bruce, chris] = this.accounts; - - await this.tokenA.$_mint(alice, amount); - await this.tokenA.connect(alice).approve(this.bridgeA, ethers.MaxUint256); - - // Alice sends tokens from chain A to Bruce on chain B. - await expect(this.bridgeA.connect(alice).crosschainTransfer(this.chain.toErc7930(bruce), amount)) - // bridge on chain A takes custody of the funds - .to.emit(this.tokenA, 'Transfer') - .withArgs(alice, chainAIsCustodial ? this.bridgeA : ethers.ZeroAddress, amount) - // crosschain transfer sent - .to.emit(this.bridgeA, 'CrosschainERC20TransferSent') - .withArgs(anyValue, alice, this.chain.toErc7930(bruce), amount) - // ERC-7786 event - .to.emit(this.gateway, 'MessageSent') - // crosschain transfer received - .to.emit(this.bridgeB, 'CrosschainERC20TransferReceived') - .withArgs(anyValue, this.chain.toErc7930(alice), bruce, amount) - // crosschain mint event - .to.emit(this.tokenB, 'CrosschainMint') - .withArgs(bruce, amount, this.bridgeB) - // tokens are minted on chain B - .to.emit(this.tokenB, 'Transfer') - .withArgs(chainBIsCustodial ? this.bridgeB : ethers.ZeroAddress, bruce, amount); - - // Bruce sends tokens from chain B to Chris on chain A. - await expect(this.bridgeB.connect(bruce).crosschainTransfer(this.chain.toErc7930(chris), amount)) - // tokens are burned on chain B - .to.emit(this.tokenB, 'Transfer') - .withArgs(bruce, chainBIsCustodial ? this.bridgeB : ethers.ZeroAddress, amount) - // crosschain burn event - .to.emit(this.tokenB, 'CrosschainBurn') - .withArgs(bruce, amount, this.bridgeB) - // crosschain transfer sent - .to.emit(this.bridgeB, 'CrosschainERC20TransferSent') - .withArgs(anyValue, bruce, this.chain.toErc7930(chris), amount) - // ERC-7786 event - .to.emit(this.gateway, 'MessageSent') - // crosschain transfer received - .to.emit(this.bridgeA, 'CrosschainERC20TransferReceived') - .withArgs(anyValue, this.chain.toErc7930(bruce), chris, amount) - // bridge on chain A releases custody of the funds - .to.emit(this.tokenA, 'Transfer') - .withArgs(chainAIsCustodial ? this.bridgeA : ethers.ZeroAddress, chris, amount); - }); - - describe('restrictions', function () { - beforeEach(async function () { - await this.tokenA.$_mint(this.bridgeA, 1_000_000_000n); + describe('bridge ERC20 like', function () { + beforeEach(function () { + // helper + this.encodePayload = (from, to, amount) => + ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes', 'bytes', 'uint256'], + [this.chain.toErc7930(from), to.target ?? to.address ?? to, amount], + ); }); - it('only gateway can relay messages', async function () { - const [notGateway] = this.accounts; - - await expect( - this.bridgeA - .connect(notGateway) - .receiveMessage( - ethers.ZeroHash, - this.chain.toErc7930(this.tokenB), - this.encodePayload(notGateway, notGateway, amount), - ), - ) - .to.be.revertedWithCustomError(this.bridgeA, 'ERC7786RecipientUnauthorizedGateway') - .withArgs(notGateway, this.chain.toErc7930(this.tokenB)); + it('bridge setup', async function () { + await expect(this.bridgeA.getLink(this.chain.erc7930)).to.eventually.deep.equal([ + this.gateway.target, + this.chain.toErc7930(this.bridgeB), + ]); + await expect(this.bridgeB.getLink(this.chain.erc7930)).to.eventually.deep.equal([ + this.gateway.target, + this.chain.toErc7930(this.bridgeA), + ]); }); - it('only counterpart can send a crosschain message', async function () { - const [invalid] = this.accounts; - - await expect( - this.gateway - .connect(invalid) - .sendMessage(this.chain.toErc7930(this.bridgeA), this.encodePayload(invalid, invalid, amount), []), - ) - .to.be.revertedWithCustomError(this.bridgeA, 'ERC7786RecipientUnauthorizedGateway') - .withArgs(this.gateway, this.chain.toErc7930(invalid)); + it('crosschain send (both direction)', async function () { + const [alice, bruce, chris] = this.accounts; + + await this.tokenA.$_mint(alice, amount); + await this.tokenA.connect(alice).approve(this.bridgeA, ethers.MaxUint256); + + // Alice sends tokens from chain A to Bruce on chain B. + await expect(this.bridgeA.connect(alice).crosschainTransfer(this.chain.toErc7930(bruce), amount)) + // bridge on chain A takes custody of the funds + .to.emit(this.tokenA, 'Transfer') + .withArgs(alice, chainAIsCustodial ? this.bridgeA : ethers.ZeroAddress, amount) + // crosschain transfer sent + .to.emit(this.bridgeA, 'CrosschainERC20TransferSent') + .withArgs(anyValue, alice, this.chain.toErc7930(bruce), amount) + // ERC-7786 event + .to.emit(this.gateway, 'MessageSent') + // crosschain transfer received + .to.emit(this.bridgeB, 'CrosschainERC20TransferReceived') + .withArgs(anyValue, this.chain.toErc7930(alice), bruce, amount) + // crosschain mint event + .to.emit(this.tokenB, 'CrosschainMint') + .withArgs(bruce, amount, this.bridgeB) + // tokens are minted on chain B + .to.emit(this.tokenB, 'Transfer') + .withArgs(chainBIsCustodial ? this.bridgeB : ethers.ZeroAddress, bruce, amount); + + // Bruce sends tokens from chain B to Chris on chain A. + await expect(this.bridgeB.connect(bruce).crosschainTransfer(this.chain.toErc7930(chris), amount)) + // tokens are burned on chain B + .to.emit(this.tokenB, 'Transfer') + .withArgs(bruce, chainBIsCustodial ? this.bridgeB : ethers.ZeroAddress, amount) + // crosschain burn event + .to.emit(this.tokenB, 'CrosschainBurn') + .withArgs(bruce, amount, this.bridgeB) + // crosschain transfer sent + .to.emit(this.bridgeB, 'CrosschainERC20TransferSent') + .withArgs(anyValue, bruce, this.chain.toErc7930(chris), amount) + // ERC-7786 event + .to.emit(this.gateway, 'MessageSent') + // crosschain transfer received + .to.emit(this.bridgeA, 'CrosschainERC20TransferReceived') + .withArgs(anyValue, this.chain.toErc7930(bruce), chris, amount) + // bridge on chain A releases custody of the funds + .to.emit(this.tokenA, 'Transfer') + .withArgs(chainAIsCustodial ? this.bridgeA : ethers.ZeroAddress, chris, amount); }); - it('cannot replay message', async function () { - const [from, to] = this.accounts; - - const id = ethers.ZeroHash; - const payload = this.encodePayload(from, to, amount); - - // first time works - await expect( - this.bridgeA.connect(this.gatewayAsEOA).receiveMessage(id, this.chain.toErc7930(this.bridgeB), payload), - ).to.emit(this.bridgeA, 'CrosschainERC20TransferReceived'); - - // second time fails - await expect( - this.bridgeA.connect(this.gatewayAsEOA).receiveMessage(id, this.chain.toErc7930(this.bridgeB), payload), - ) - .to.be.revertedWithCustomError(this.bridgeA, 'ERC7786RecipientMessageAlreadyProcessed') - .withArgs(this.gateway, id); + describe('restrictions', function () { + beforeEach(async function () { + await this.tokenA.$_mint(this.bridgeA, 1_000_000_000n); + }); + + it('only gateway can relay messages', async function () { + const [notGateway] = this.accounts; + + await expect( + this.bridgeA + .connect(notGateway) + .receiveMessage( + ethers.ZeroHash, + this.chain.toErc7930(this.tokenB), + this.encodePayload(notGateway, notGateway, amount), + ), + ) + .to.be.revertedWithCustomError(this.bridgeA, 'ERC7786RecipientUnauthorizedGateway') + .withArgs(notGateway, this.chain.toErc7930(this.tokenB)); + }); + + it('only counterpart can send a crosschain message', async function () { + const [invalid] = this.accounts; + + await expect( + this.gateway + .connect(invalid) + .sendMessage(this.chain.toErc7930(this.bridgeA), this.encodePayload(invalid, invalid, amount), []), + ) + .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 id = ethers.ZeroHash; + const payload = this.encodePayload(from, to, amount); + + // first time works + await expect( + this.bridgeA.connect(this.gatewayAsEOA).receiveMessage(id, this.chain.toErc7930(this.bridgeB), payload), + ).to.emit(this.bridgeA, 'CrosschainERC20TransferReceived'); + + // second time fails + await expect( + this.bridgeA.connect(this.gatewayAsEOA).receiveMessage(id, this.chain.toErc7930(this.bridgeB), payload), + ) + .to.be.revertedWithCustomError(this.bridgeA, 'ERC7786RecipientMessageAlreadyProcessed') + .withArgs(this.gateway, id); + }); }); - }); - describe('reconfiguration', function () { - it('updating a link emits an event', async function () { - const newGateway = await ethers.deployContract('$ERC7786GatewayMock'); - const newCounterpart = this.chain.toErc7930(this.accounts[0]); + describe('reconfiguration', function () { + it('updating a link emits an event', async function () { + const newGateway = await ethers.deployContract('$ERC7786GatewayMock'); + const newCounterpart = this.chain.toErc7930(this.accounts[0]); - await expect(this.bridgeA.$_setLink(newGateway, newCounterpart, true)) - .to.emit(this.bridgeA, 'LinkRegistered') - .withArgs(newGateway, newCounterpart); + await expect(this.bridgeA.$_setLink(newGateway, newCounterpart, true)) + .to.emit(this.bridgeA, 'LinkRegistered') + .withArgs(newGateway, newCounterpart); - await expect(this.bridgeA.getLink(this.chain.erc7930)).to.eventually.deep.equal([ - newGateway.target, - newCounterpart, - ]); - }); + await expect(this.bridgeA.getLink(this.chain.erc7930)).to.eventually.deep.equal([ + newGateway.target, + newCounterpart, + ]); + }); - it('cannot override configuration is "allowOverride" is false', async function () { - const newGateway = await ethers.deployContract('$ERC7786GatewayMock'); - const newCounterpart = this.chain.toErc7930(this.accounts[0]); + it('cannot override configuration is "allowOverride" is false', async function () { + const newGateway = await ethers.deployContract('$ERC7786GatewayMock'); + const newCounterpart = this.chain.toErc7930(this.accounts[0]); - await expect(this.bridgeA.$_setLink(newGateway, newCounterpart, false)) - .to.be.revertedWithCustomError(this.bridgeA, 'LinkAlreadyRegistered') - .withArgs(this.chain.erc7930); - }); + await expect(this.bridgeA.$_setLink(newGateway, newCounterpart, false)) + .to.be.revertedWithCustomError(this.bridgeA, 'LinkAlreadyRegistered') + .withArgs(this.chain.erc7930); + }); - it('reject invalid gateway', async function () { - const notAGateway = this.accounts[0]; - const newCounterpart = this.chain.toErc7930(this.accounts[0]); + it('reject invalid gateway', async function () { + const notAGateway = this.accounts[0]; + const newCounterpart = this.chain.toErc7930(this.accounts[0]); - await expect(this.bridgeA.$_setLink(notAGateway, newCounterpart, false)).to.be.revertedWithoutReason(); + await expect(this.bridgeA.$_setLink(notAGateway, newCounterpart, false)).to.be.revertedWithoutReason(); + }); }); }); } diff --git a/test/crosschain/BridgeERC721.behavior.js b/test/crosschain/BridgeERC721.behavior.js new file mode 100644 index 00000000000..0a9e536c314 --- /dev/null +++ b/test/crosschain/BridgeERC721.behavior.js @@ -0,0 +1,163 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { anyValue } = require('@nomicfoundation/hardhat-chai-matchers/withArgs'); + +const tokenId = 42n; + +function shouldBehaveLikeBridgeERC721({ chainAIsCustodial = false, chainBIsCustodial = false } = {}) { + describe('bridge ERC721 like', function () { + beforeEach(function () { + // helper + this.encodePayload = (from, to, tokenId) => + ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes', 'bytes', 'uint256'], + [this.chain.toErc7930(from), to.target ?? to.address ?? to, tokenId], + ); + }); + + it('bridge setup', async function () { + await expect(this.bridgeA.getLink(this.chain.erc7930)).to.eventually.deep.equal([ + this.gateway.target, + this.chain.toErc7930(this.bridgeB), + ]); + await expect(this.bridgeB.getLink(this.chain.erc7930)).to.eventually.deep.equal([ + this.gateway.target, + this.chain.toErc7930(this.bridgeA), + ]); + }); + + it('crosschain send (both direction)', async function () { + const [alice, bruce, chris] = this.accounts; + + await this.tokenA.$_mint(alice, tokenId); + await this.tokenA.connect(alice).setApprovalForAll(this.bridgeA, true); + + // Alice sends tokens from chain A to Bruce on chain B. + await expect(this.bridgeA.connect(alice).crosschainTransfer(this.chain.toErc7930(bruce), tokenId)) + // bridge on chain A takes custody of the token + .to.emit(this.tokenA, 'Transfer') + .withArgs(alice, chainAIsCustodial ? this.bridgeA : ethers.ZeroAddress, tokenId) + // crosschain transfer sent + .to.emit(this.bridgeA, 'CrosschainERC721TransferSent') + .withArgs(anyValue, alice, this.chain.toErc7930(bruce), tokenId) + // ERC-7786 event + .to.emit(this.gateway, 'MessageSent') + // crosschain transfer received + .to.emit(this.bridgeB, 'CrosschainERC721TransferReceived') + .withArgs(anyValue, this.chain.toErc7930(alice), bruce, tokenId) + // tokens are minted on chain B + .to.emit(this.tokenB, 'Transfer') + .withArgs(chainBIsCustodial ? this.bridgeB : ethers.ZeroAddress, bruce, tokenId); + + // Bruce sends tokens from chain B to Chris on chain A. + await expect(this.bridgeB.connect(bruce).crosschainTransfer(this.chain.toErc7930(chris), tokenId)) + // tokens are burned on chain B + .to.emit(this.tokenB, 'Transfer') + .withArgs(bruce, chainBIsCustodial ? this.bridgeB : ethers.ZeroAddress, tokenId) + // crosschain transfer sent + .to.emit(this.bridgeB, 'CrosschainERC721TransferSent') + .withArgs(anyValue, bruce, this.chain.toErc7930(chris), tokenId) + // ERC-7786 event + .to.emit(this.gateway, 'MessageSent') + // crosschain transfer received + .to.emit(this.bridgeA, 'CrosschainERC721TransferReceived') + .withArgs(anyValue, this.chain.toErc7930(bruce), chris, tokenId) + // bridge on chain A releases custody of the token + .to.emit(this.tokenA, 'Transfer') + .withArgs(chainAIsCustodial ? this.bridgeA : ethers.ZeroAddress, chris, tokenId); + }); + + describe('restrictions', function () { + beforeEach(async function () { + await this.tokenA.$_mint(this.bridgeA, 1_000_000_000n); + }); + + it('only gateway can relay messages', async function () { + const [notGateway] = this.accounts; + + await expect( + this.bridgeA + .connect(notGateway) + .receiveMessage( + ethers.ZeroHash, + this.chain.toErc7930(this.tokenB), + this.encodePayload(notGateway, notGateway, tokenId), + ), + ) + .to.be.revertedWithCustomError(this.bridgeA, 'ERC7786RecipientUnauthorizedGateway') + .withArgs(notGateway, this.chain.toErc7930(this.tokenB)); + }); + + it('only counterpart can send a crosschain message', async function () { + const [invalid] = this.accounts; + + await expect( + this.gateway + .connect(invalid) + .sendMessage(this.chain.toErc7930(this.bridgeA), this.encodePayload(invalid, invalid, tokenId), []), + ) + .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 id = ethers.ZeroHash; + const payload = this.encodePayload(from, to, tokenId); + + if (chainAIsCustodial) { + await this.tokenA.$_mint(this.bridgeA, tokenId); + } + + // first time works + await expect( + this.bridgeA.connect(this.gatewayAsEOA).receiveMessage(id, this.chain.toErc7930(this.bridgeB), payload), + ).to.emit(this.bridgeA, 'CrosschainERC721TransferReceived'); + + // second time fails + await expect( + this.bridgeA.connect(this.gatewayAsEOA).receiveMessage(id, this.chain.toErc7930(this.bridgeB), payload), + ) + .to.be.revertedWithCustomError(this.bridgeA, 'ERC7786RecipientMessageAlreadyProcessed') + .withArgs(this.gateway, id); + }); + }); + + describe('reconfiguration', function () { + it('updating a link emits an event', async function () { + const newGateway = await ethers.deployContract('$ERC7786GatewayMock'); + const newCounterpart = this.chain.toErc7930(this.accounts[0]); + + await expect(this.bridgeA.$_setLink(newGateway, newCounterpart, true)) + .to.emit(this.bridgeA, 'LinkRegistered') + .withArgs(newGateway, newCounterpart); + + await expect(this.bridgeA.getLink(this.chain.erc7930)).to.eventually.deep.equal([ + newGateway.target, + newCounterpart, + ]); + }); + + it('cannot override configuration is "allowOverride" is false', async function () { + const newGateway = await ethers.deployContract('$ERC7786GatewayMock'); + const newCounterpart = this.chain.toErc7930(this.accounts[0]); + + await expect(this.bridgeA.$_setLink(newGateway, newCounterpart, false)) + .to.be.revertedWithCustomError(this.bridgeA, 'LinkAlreadyRegistered') + .withArgs(this.chain.erc7930); + }); + + it('reject invalid gateway', async function () { + const notAGateway = this.accounts[0]; + const newCounterpart = this.chain.toErc7930(this.accounts[0]); + + await expect(this.bridgeA.$_setLink(notAGateway, newCounterpart, false)).to.be.revertedWithoutReason(); + }); + }); + }); +} + +module.exports = { + shouldBehaveLikeBridgeERC721, +}; diff --git a/test/crosschain/BridgeERC721.test.js b/test/crosschain/BridgeERC721.test.js new file mode 100644 index 00000000000..86763940e4b --- /dev/null +++ b/test/crosschain/BridgeERC721.test.js @@ -0,0 +1,75 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const { anyValue } = require('@nomicfoundation/hardhat-chai-matchers/withArgs'); + +const { impersonate } = require('../helpers/account'); +const { getLocalChain } = require('../helpers/chains'); + +const { shouldBehaveLikeBridgeERC721 } = require('./BridgeERC721.behavior'); + +async function fixture() { + const chain = await getLocalChain(); + const accounts = await ethers.getSigners(); + + // Mock gateway + const gateway = await ethers.deployContract('$ERC7786GatewayMock'); + const gatewayAsEOA = await impersonate(gateway); + + // Chain A: legacy ERC721 with bridge + const tokenA = await ethers.deployContract('$ERC721', ['Token1', 'T1']); + const bridgeA = await ethers.deployContract('$BridgeERC721', [[], tokenA]); + + // Chain B: ERC721 with native bridge integration + const tokenB = await ethers.deployContract('$ERC721Crosschain', [ + 'Token2', + 'T2', + [[gateway, chain.toErc7930(bridgeA)]], + ]); + const bridgeB = tokenB; // self bridge + + // deployment check + counterpart setup + await expect(bridgeA.$_setLink(gateway, chain.toErc7930(bridgeB), false)) + .to.emit(bridgeA, 'LinkRegistered') + .withArgs(gateway, chain.toErc7930(bridgeB)); + + return { chain, accounts, gateway, gatewayAsEOA, tokenA, tokenB, bridgeA, bridgeB }; +} + +describe('CrosschainBridgeERC20', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + it('token getters', async function () { + await expect(this.bridgeA.token()).to.eventually.equal(this.tokenA); + }); + + shouldBehaveLikeBridgeERC721({ chainAIsCustodial: true }); + + it('direct crosschain transfer using a safeTransferFrom to the bridge', async function () { + const tokenId = 17n; + const [alice, bruce] = this.accounts; + await this.tokenA.$_mint(alice, tokenId); + + await expect( + this.tokenA + .connect(alice) + .safeTransferFrom(alice, this.bridgeA, tokenId, ethers.Typed.bytes(this.chain.toErc7930(bruce))), + ) + // bridge takes custody of the token + .to.emit(this.tokenA, 'Transfer') + .withArgs(alice, this.bridgeA, tokenId) + // crosschain transfer sent + .to.emit(this.bridgeA, 'CrosschainERC721TransferSent') + .withArgs(anyValue, alice, this.chain.toErc7930(bruce), tokenId) + // ERC-7786 event + .to.emit(this.gateway, 'MessageSent') + // crosschain transfer received + .to.emit(this.tokenB, 'CrosschainERC721TransferReceived') + .withArgs(anyValue, this.chain.toErc7930(alice), bruce, tokenId) + // bridge on custodial chain releases mints the token + .to.emit(this.tokenB, 'Transfer') + .withArgs(ethers.ZeroAddress, bruce, tokenId); + }); +}); diff --git a/test/token/ERC721/extensions/ERC721Crosschain.test.js b/test/token/ERC721/extensions/ERC721Crosschain.test.js new file mode 100644 index 00000000000..f611ed53e43 --- /dev/null +++ b/test/token/ERC721/extensions/ERC721Crosschain.test.js @@ -0,0 +1,82 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const { anyValue } = require('@nomicfoundation/hardhat-chai-matchers/withArgs'); + +const { impersonate } = require('../../../helpers/account'); +const { getLocalChain } = require('../../../helpers/chains'); + +const { shouldBehaveLikeBridgeERC721 } = require('../../../crosschain/BridgeERC721.behavior'); + +async function fixture() { + const chain = await getLocalChain(); + const accounts = await ethers.getSigners(); + + // Mock gateway + const gateway = await ethers.deployContract('$ERC7786GatewayMock'); + const gatewayAsEOA = await impersonate(gateway); + + // Chain A: legacy ERC721 with bridge + const tokenA = await ethers.deployContract('$ERC721Crosschain', ['Token1', 'T1', []]); + const bridgeA = tokenA; // self bridge + + // Chain B: ERC721 with native bridge integration + const tokenB = await ethers.deployContract('$ERC721Crosschain', [ + 'Token2', + 'T2', + [[gateway, chain.toErc7930(bridgeA)]], + ]); + const bridgeB = tokenB; // self bridge + + // deployment check + counterpart setup + await expect(bridgeA.$_setLink(gateway, chain.toErc7930(bridgeB), false)) + .to.emit(bridgeA, 'LinkRegistered') + .withArgs(gateway, chain.toErc7930(bridgeB)); + + return { chain, accounts, gateway, gatewayAsEOA, tokenA, tokenB, bridgeA, bridgeB }; +} + +describe('CrosschainBridgeERC20', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + shouldBehaveLikeBridgeERC721(); + + describe('crosschainTransferFrom', function () { + it('with allowance: success', async function () { + const [alice, bruce, chris] = this.accounts; + const tokenId = 17n; + + await this.tokenA.$_mint(alice, tokenId); + await this.tokenA.connect(alice).setApprovalForAll(chris, true); + + await expect(this.tokenA.connect(chris).crosschainTransferFrom(alice, this.chain.toErc7930(bruce), tokenId)) + // tokens are burned on non custodial chains + .to.emit(this.tokenA, 'Transfer') + .withArgs(alice, ethers.ZeroAddress, tokenId) + // crosschain transfer sent + .to.emit(this.tokenA, 'CrosschainERC721TransferSent') + .withArgs(anyValue, alice, this.chain.toErc7930(bruce), tokenId) + // ERC-7786 event + .to.emit(this.gateway, 'MessageSent') + // crosschain transfer received + .to.emit(this.tokenB, 'CrosschainERC721TransferReceived') + .withArgs(anyValue, this.chain.toErc7930(alice), bruce, tokenId) + // bridge on custodial chain releases mints the token + .to.emit(this.tokenB, 'Transfer') + .withArgs(ethers.ZeroAddress, bruce, tokenId); + }); + + it('without allowance: revert', async function () { + const [alice, bruce, chris] = this.accounts; + const tokenId = 17n; + + await this.tokenA.$_mint(alice, tokenId); + + await expect(this.tokenA.connect(chris).crosschainTransferFrom(alice, this.chain.toErc7930(bruce), tokenId)) + .to.be.revertedWithCustomError(this.tokenA, 'ERC721InsufficientApproval') + .withArgs(chris, tokenId); + }); + }); +}); From 769398eb002ddf60a282b989132e0d98bf30167f Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Mon, 5 Jan 2026 13:40:43 +0100 Subject: [PATCH 02/22] update --- contracts/crosschain/bridges/BridgeERC721.sol | 22 +++++-- .../crosschain/bridges/BridgeERC721Core.sol | 3 +- .../ERC721/extensions/ERC721Crosschain.sol | 7 +-- test/crosschain/BridgeERC721.behavior.js | 60 ++++++++++++++++++- .../extensions/ERC721Crosschain.test.js | 40 +------------ 5 files changed, 80 insertions(+), 52 deletions(-) diff --git a/contracts/crosschain/bridges/BridgeERC721.sol b/contracts/crosschain/bridges/BridgeERC721.sol index d2a8eab8fca..df63dcec1e6 100644 --- a/contracts/crosschain/bridges/BridgeERC721.sol +++ b/contracts/crosschain/bridges/BridgeERC721.sol @@ -4,6 +4,7 @@ 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"; /** @@ -14,6 +15,8 @@ import {BridgeERC721Core} from "./BridgeERC721Core.sol"; abstract contract BridgeERC721 is IERC721Receiver, BridgeERC721Core { IERC721 private immutable _token; + error BridgeERC721Unauthorized(address caller); + constructor(IERC721 token_) { _token = token_; } @@ -28,10 +31,20 @@ abstract contract BridgeERC721 is IERC721Receiver, BridgeERC721Core { * * Note: The `to` parameter is the full InteroperableAddress (chain ref + address). */ - function crosschainTransfer(bytes memory to, uint256 tokenId) public virtual returns (bytes32) { + 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`. + 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(msg.sender, address(this), tokenId); - return _crosschainTransfer(msg.sender, to, tokenId); + token().transferFrom(from, address(this), tokenId); + + // Perform the crosschain transfer and return the handler + return _crosschainTransfer(from, to, tokenId); } /** @@ -45,7 +58,8 @@ abstract contract BridgeERC721 is IERC721Receiver, BridgeERC721Core { uint256 tokenId, bytes calldata data // this is the to ) public virtual override returns (bytes4) { - require(msg.sender == address(_token)); // TODO + // TODO: should this consider _msgSender() ? + require(msg.sender == address(_token), BridgeERC721Unauthorized(msg.sender)); _crosschainTransfer(from, data, tokenId); return IERC721Receiver.onERC721Received.selector; } diff --git a/contracts/crosschain/bridges/BridgeERC721Core.sol b/contracts/crosschain/bridges/BridgeERC721Core.sol index adff75bb866..f10c89061ea 100644 --- a/contracts/crosschain/bridges/BridgeERC721Core.sol +++ b/contracts/crosschain/bridges/BridgeERC721Core.sol @@ -3,6 +3,7 @@ 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"; @@ -16,7 +17,7 @@ import {CrosschainLinked} from "../CrosschainLinked.sol"; * 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 CrosschainLinked { +abstract contract BridgeERC721Core is Context, CrosschainLinked { using InteroperableAddress for bytes; event CrosschainERC721TransferSent(bytes32 indexed sendId, address indexed from, bytes to, uint256 tokenId); diff --git a/contracts/token/ERC721/extensions/ERC721Crosschain.sol b/contracts/token/ERC721/extensions/ERC721Crosschain.sol index 32f31797dff..5b9ba94be75 100644 --- a/contracts/token/ERC721/extensions/ERC721Crosschain.sol +++ b/contracts/token/ERC721/extensions/ERC721Crosschain.sol @@ -14,14 +14,9 @@ import {BridgeERC721Core} from "../../../crosschain/bridges/BridgeERC721Core.sol */ // slither-disable-next-line locked-ether abstract contract ERC721Crosschain is ERC721, BridgeERC721Core { - /// @dev Variant of {crosschainTransferFrom} that uses `_msgSender()` for the `from` for compatibility with {BridgeERc721} - function crosschainTransfer(bytes memory to, uint256 tokenId) public virtual returns (bytes32) { - return crosschainTransferFrom(_msgSender(), to, tokenId); - } - /// @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 (msg.sender) permission over `from` is checked in `_onSend` + // operator (_msgSender) permission over `from` is checked in `_onSend` return _crosschainTransfer(from, to, tokenId); } diff --git a/test/crosschain/BridgeERC721.behavior.js b/test/crosschain/BridgeERC721.behavior.js index 0a9e536c314..c4344be6872 100644 --- a/test/crosschain/BridgeERC721.behavior.js +++ b/test/crosschain/BridgeERC721.behavior.js @@ -33,7 +33,7 @@ function shouldBehaveLikeBridgeERC721({ chainAIsCustodial = false, chainBIsCusto await this.tokenA.connect(alice).setApprovalForAll(this.bridgeA, true); // Alice sends tokens from chain A to Bruce on chain B. - await expect(this.bridgeA.connect(alice).crosschainTransfer(this.chain.toErc7930(bruce), tokenId)) + await expect(this.bridgeA.connect(alice).crosschainTransferFrom(alice, this.chain.toErc7930(bruce), tokenId)) // bridge on chain A takes custody of the token .to.emit(this.tokenA, 'Transfer') .withArgs(alice, chainAIsCustodial ? this.bridgeA : ethers.ZeroAddress, tokenId) @@ -50,7 +50,7 @@ function shouldBehaveLikeBridgeERC721({ chainAIsCustodial = false, chainBIsCusto .withArgs(chainBIsCustodial ? this.bridgeB : ethers.ZeroAddress, bruce, tokenId); // Bruce sends tokens from chain B to Chris on chain A. - await expect(this.bridgeB.connect(bruce).crosschainTransfer(this.chain.toErc7930(chris), tokenId)) + await expect(this.bridgeB.connect(bruce).crosschainTransferFrom(bruce, this.chain.toErc7930(chris), tokenId)) // tokens are burned on chain B .to.emit(this.tokenB, 'Transfer') .withArgs(bruce, chainBIsCustodial ? this.bridgeB : ethers.ZeroAddress, tokenId) @@ -67,6 +67,62 @@ function shouldBehaveLikeBridgeERC721({ chainAIsCustodial = false, chainBIsCusto .withArgs(chainAIsCustodial ? this.bridgeA : ethers.ZeroAddress, chris, tokenId); }); + describe('transfer with allowance', function () { + it('spender is owner', async function () { + const [alice, bruce] = this.accounts; + const tokenId = 17n; + + await this.tokenA.$_mint(alice, tokenId); + await this.tokenA.connect(alice).setApprovalForAll(this.bridgeA, true); + + await expect(this.bridgeA.connect(alice).crosschainTransferFrom(alice, this.chain.toErc7930(bruce), tokenId)) + .to.emit(this.bridgeA, 'CrosschainERC721TransferSent') + .withArgs(anyValue, alice, this.chain.toErc7930(bruce), tokenId); + }); + + it('spender is allowed for all', async function () { + const [alice, bruce, chris, david] = this.accounts; + const tokenId = 17n; + + await this.tokenA.$_mint(alice, tokenId); + await this.tokenA.connect(alice).setApprovalForAll(this.bridgeA, true); + await this.tokenA.connect(alice).setApprovalForAll(chris, true); + + // david is not allowed + await expect(this.bridgeA.connect(david).crosschainTransferFrom(alice, this.chain.toErc7930(bruce), tokenId)) + .to.be.revertedWithCustomError(this.tokenA, 'ERC721InsufficientApproval') + .withArgs(david, tokenId); + + // chris is allowed + await expect(this.bridgeA.connect(chris).crosschainTransferFrom(alice, this.chain.toErc7930(bruce), tokenId)) + .to.emit(this.bridgeA, 'CrosschainERC721TransferSent') + .withArgs(anyValue, alice, this.chain.toErc7930(bruce), tokenId); + }); + + it('spender is allowed for specific token', async function () { + const [alice, bruce, chris] = this.accounts; + const tokenId = 17n; + const otherTokenId = 42n; + + await this.tokenA.$_mint(alice, tokenId); + await this.tokenA.$_mint(alice, otherTokenId); + await this.tokenA.connect(alice).setApprovalForAll(this.bridgeA, true); + await this.tokenA.connect(alice).approve(chris, tokenId); + + // chris is not allowed to transfer otherTokenId + await expect( + this.bridgeA.connect(chris).crosschainTransferFrom(alice, this.chain.toErc7930(bruce), otherTokenId), + ) + .to.be.revertedWithCustomError(this.tokenA, 'ERC721InsufficientApproval') + .withArgs(chris, otherTokenId); + + // chris is allowed to transfer tokenId + await expect(this.bridgeA.connect(chris).crosschainTransferFrom(alice, this.chain.toErc7930(bruce), tokenId)) + .to.emit(this.bridgeA, 'CrosschainERC721TransferSent') + .withArgs(anyValue, alice, this.chain.toErc7930(bruce), tokenId); + }); + }); + describe('restrictions', function () { beforeEach(async function () { await this.tokenA.$_mint(this.bridgeA, 1_000_000_000n); diff --git a/test/token/ERC721/extensions/ERC721Crosschain.test.js b/test/token/ERC721/extensions/ERC721Crosschain.test.js index f611ed53e43..f33f6744bf4 100644 --- a/test/token/ERC721/extensions/ERC721Crosschain.test.js +++ b/test/token/ERC721/extensions/ERC721Crosschain.test.js @@ -1,7 +1,6 @@ const { ethers } = require('hardhat'); const { expect } = require('chai'); const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); -const { anyValue } = require('@nomicfoundation/hardhat-chai-matchers/withArgs'); const { impersonate } = require('../../../helpers/account'); const { getLocalChain } = require('../../../helpers/chains'); @@ -16,7 +15,7 @@ async function fixture() { const gateway = await ethers.deployContract('$ERC7786GatewayMock'); const gatewayAsEOA = await impersonate(gateway); - // Chain A: legacy ERC721 with bridge + // Chain A: ERC721 with native bridge integration const tokenA = await ethers.deployContract('$ERC721Crosschain', ['Token1', 'T1', []]); const bridgeA = tokenA; // self bridge @@ -42,41 +41,4 @@ describe('CrosschainBridgeERC20', function () { }); shouldBehaveLikeBridgeERC721(); - - describe('crosschainTransferFrom', function () { - it('with allowance: success', async function () { - const [alice, bruce, chris] = this.accounts; - const tokenId = 17n; - - await this.tokenA.$_mint(alice, tokenId); - await this.tokenA.connect(alice).setApprovalForAll(chris, true); - - await expect(this.tokenA.connect(chris).crosschainTransferFrom(alice, this.chain.toErc7930(bruce), tokenId)) - // tokens are burned on non custodial chains - .to.emit(this.tokenA, 'Transfer') - .withArgs(alice, ethers.ZeroAddress, tokenId) - // crosschain transfer sent - .to.emit(this.tokenA, 'CrosschainERC721TransferSent') - .withArgs(anyValue, alice, this.chain.toErc7930(bruce), tokenId) - // ERC-7786 event - .to.emit(this.gateway, 'MessageSent') - // crosschain transfer received - .to.emit(this.tokenB, 'CrosschainERC721TransferReceived') - .withArgs(anyValue, this.chain.toErc7930(alice), bruce, tokenId) - // bridge on custodial chain releases mints the token - .to.emit(this.tokenB, 'Transfer') - .withArgs(ethers.ZeroAddress, bruce, tokenId); - }); - - it('without allowance: revert', async function () { - const [alice, bruce, chris] = this.accounts; - const tokenId = 17n; - - await this.tokenA.$_mint(alice, tokenId); - - await expect(this.tokenA.connect(chris).crosschainTransferFrom(alice, this.chain.toErc7930(bruce), tokenId)) - .to.be.revertedWithCustomError(this.tokenA, 'ERC721InsufficientApproval') - .withArgs(chris, tokenId); - }); - }); }); From adddbc4964f25fefda0eec73ab66caef4b6cff69 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Mon, 5 Jan 2026 14:03:41 +0100 Subject: [PATCH 03/22] Apply suggestions from code review --- contracts/crosschain/bridges/BridgeERC721.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/crosschain/bridges/BridgeERC721.sol b/contracts/crosschain/bridges/BridgeERC721.sol index df63dcec1e6..f2e5ca3244f 100644 --- a/contracts/crosschain/bridges/BridgeERC721.sol +++ b/contracts/crosschain/bridges/BridgeERC721.sol @@ -32,7 +32,7 @@ abstract contract BridgeERC721 is IERC721Receiver, BridgeERC721Core { * 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`. + // 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, From e52226c26fd9ee7f3d9c8f5da73e71f055c31cc3 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Mon, 5 Jan 2026 14:05:39 +0100 Subject: [PATCH 04/22] Apply suggestions from code review Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- contracts/crosschain/bridges/BridgeERC721Core.sol | 2 +- test/crosschain/BridgeERC721.behavior.js | 2 +- test/crosschain/BridgeERC721.test.js | 4 ++-- test/token/ERC721/extensions/ERC721Crosschain.test.js | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/contracts/crosschain/bridges/BridgeERC721Core.sol b/contracts/crosschain/bridges/BridgeERC721Core.sol index f10c89061ea..0d710c95e53 100644 --- a/contracts/crosschain/bridges/BridgeERC721Core.sol +++ b/contracts/crosschain/bridges/BridgeERC721Core.sol @@ -15,7 +15,7 @@ import {CrosschainLinked} from "../CrosschainLinked.sol"; * * {_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. + * the {ERC721Crosschain} extension, which embeds the bridge logic directly in the token contract. */ abstract contract BridgeERC721Core is Context, CrosschainLinked { using InteroperableAddress for bytes; diff --git a/test/crosschain/BridgeERC721.behavior.js b/test/crosschain/BridgeERC721.behavior.js index c4344be6872..bcfeae23331 100644 --- a/test/crosschain/BridgeERC721.behavior.js +++ b/test/crosschain/BridgeERC721.behavior.js @@ -195,7 +195,7 @@ function shouldBehaveLikeBridgeERC721({ chainAIsCustodial = false, chainBIsCusto ]); }); - it('cannot override configuration is "allowOverride" is false', async function () { + it('cannot override configuration if "allowOverride" is false', async function () { const newGateway = await ethers.deployContract('$ERC7786GatewayMock'); const newCounterpart = this.chain.toErc7930(this.accounts[0]); diff --git a/test/crosschain/BridgeERC721.test.js b/test/crosschain/BridgeERC721.test.js index 86763940e4b..d006e2a2db5 100644 --- a/test/crosschain/BridgeERC721.test.js +++ b/test/crosschain/BridgeERC721.test.js @@ -36,7 +36,7 @@ async function fixture() { return { chain, accounts, gateway, gatewayAsEOA, tokenA, tokenB, bridgeA, bridgeB }; } -describe('CrosschainBridgeERC20', function () { +describe('CrosschainBridgeERC721', function () { beforeEach(async function () { Object.assign(this, await loadFixture(fixture)); }); @@ -68,7 +68,7 @@ describe('CrosschainBridgeERC20', function () { // crosschain transfer received .to.emit(this.tokenB, 'CrosschainERC721TransferReceived') .withArgs(anyValue, this.chain.toErc7930(alice), bruce, tokenId) - // bridge on custodial chain releases mints the token + // bridge on destination chain mints the token .to.emit(this.tokenB, 'Transfer') .withArgs(ethers.ZeroAddress, bruce, tokenId); }); diff --git a/test/token/ERC721/extensions/ERC721Crosschain.test.js b/test/token/ERC721/extensions/ERC721Crosschain.test.js index f33f6744bf4..06f92676563 100644 --- a/test/token/ERC721/extensions/ERC721Crosschain.test.js +++ b/test/token/ERC721/extensions/ERC721Crosschain.test.js @@ -35,7 +35,7 @@ async function fixture() { return { chain, accounts, gateway, gatewayAsEOA, tokenA, tokenB, bridgeA, bridgeB }; } -describe('CrosschainBridgeERC20', function () { +describe('ERC721Crosschain', function () { beforeEach(async function () { Object.assign(this, await loadFixture(fixture)); }); From 4c6ee7e9b653fc4ade005d32d27e08b568429ff8 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Mon, 5 Jan 2026 14:08:04 +0100 Subject: [PATCH 05/22] changeset entries --- .changeset/fruity-coats-smash.md | 5 +++++ .changeset/tidy-turkeys-build.md | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 .changeset/fruity-coats-smash.md create mode 100644 .changeset/tidy-turkeys-build.md diff --git a/.changeset/fruity-coats-smash.md b/.changeset/fruity-coats-smash.md new file mode 100644 index 00000000000..70b35dcf98f --- /dev/null +++ b/.changeset/fruity-coats-smash.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`BridgeERC721Core` and `BridgeERC721`: Added bridge contracts to handle crosschain movements of ERC-721 tokens. diff --git a/.changeset/tidy-turkeys-build.md b/.changeset/tidy-turkeys-build.md new file mode 100644 index 00000000000..4f4ac4d51f6 --- /dev/null +++ b/.changeset/tidy-turkeys-build.md @@ -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. From 7a0db4ef42c621897c8509759dc10dafb8b359e1 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Mon, 5 Jan 2026 16:29:22 +0100 Subject: [PATCH 06/22] slither --- contracts/crosschain/bridges/BridgeERC721.sol | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/contracts/crosschain/bridges/BridgeERC721.sol b/contracts/crosschain/bridges/BridgeERC721.sol index f2e5ca3244f..1e7dd62e04b 100644 --- a/contracts/crosschain/bridges/BridgeERC721.sol +++ b/contracts/crosschain/bridges/BridgeERC721.sol @@ -39,8 +39,11 @@ abstract contract BridgeERC721 is IERC721Receiver, BridgeERC721Core { 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. + // 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`. Note: do not use safeTransferFrom here! Using it would trigger + // the `onERC721Received` which we don't want. + // + // slither-disable-next-line arbitrary-from-in-transferfrom token().transferFrom(from, address(this), tokenId); // Perform the crosschain transfer and return the handler From 07f58124a473af7c6bfba0ccbf8ecba2974e2017 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Mon, 5 Jan 2026 17:21:44 +0100 Subject: [PATCH 07/22] coverage --- test/crosschain/BridgeERC721.behavior.js | 28 +++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/test/crosschain/BridgeERC721.behavior.js b/test/crosschain/BridgeERC721.behavior.js index bcfeae23331..9d5ea8032ab 100644 --- a/test/crosschain/BridgeERC721.behavior.js +++ b/test/crosschain/BridgeERC721.behavior.js @@ -123,11 +123,33 @@ function shouldBehaveLikeBridgeERC721({ chainAIsCustodial = false, chainBIsCusto }); }); - describe('restrictions', function () { - beforeEach(async function () { - await this.tokenA.$_mint(this.bridgeA, 1_000_000_000n); + describe('invalid transfer', function () { + it('token not minted', async function () { + const [alice, bruce] = this.accounts; + const tokenId = 17n; + + await expect( + this.bridgeA.connect(alice).crosschainTransferFrom(ethers.ZeroAddress, this.chain.toErc7930(bruce), tokenId), + ) + .to.be.revertedWithCustomError(this.tokenA, 'ERC721NonexistentToken') + .withArgs(tokenId); + }); + + it('incorrect from argument', async function () { + const [alice, bruce] = this.accounts; + const tokenId = 17n; + + await this.tokenA.$_mint(alice, tokenId); + await this.tokenA.connect(alice).setApprovalForAll(this.bridgeA, true); + await this.tokenA.connect(alice).setApprovalForAll(bruce, true); + + await expect(this.bridgeA.connect(bruce).crosschainTransferFrom(bruce, this.chain.toErc7930(bruce), tokenId)) + .to.be.revertedWithCustomError(this.tokenA, 'ERC721IncorrectOwner') + .withArgs(bruce, tokenId, alice); }); + }); + describe('restrictions', function () { it('only gateway can relay messages', async function () { const [notGateway] = this.accounts; From d756c5c0624bd546f78b9edd41cbeca963fc34ee Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Mon, 5 Jan 2026 17:30:40 +0100 Subject: [PATCH 08/22] slither --- contracts/crosschain/bridges/BridgeERC721.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/crosschain/bridges/BridgeERC721.sol b/contracts/crosschain/bridges/BridgeERC721.sol index 1e7dd62e04b..48eae647d96 100644 --- a/contracts/crosschain/bridges/BridgeERC721.sol +++ b/contracts/crosschain/bridges/BridgeERC721.sol @@ -43,7 +43,7 @@ abstract contract BridgeERC721 is IERC721Receiver, BridgeERC721Core { // allowed to move tokenId on behalf of `from`. Note: do not use safeTransferFrom here! Using it would trigger // the `onERC721Received` which we don't want. // - // slither-disable-next-line arbitrary-from-in-transferfrom + // slither-disable-next-line arbitrary-send-erc20 token().transferFrom(from, address(this), tokenId); // Perform the crosschain transfer and return the handler From 551a3c70e2b2043bec8fba9a809cfd7715d86dc4 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Mon, 5 Jan 2026 18:37:20 +0100 Subject: [PATCH 09/22] documentation --- contracts/crosschain/README.adoc | 8 +++++++- contracts/token/ERC721/README.adoc | 17 ++++++++++------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/contracts/crosschain/README.adoc b/contracts/crosschain/README.adoc index 4f79f53eaff..45c2e9976d9 100644 --- a/contracts/crosschain/README.adoc +++ b/contracts/crosschain/README.adoc @@ -13,6 +13,8 @@ Additionally there are multiple bridge constructions: * {BridgeERC20Core}: Core bridging logic for crosschain ERC-20 transfer. Used by {BridgeERC20}, {BridgeERC7802} and {ERC20Crosschain}, * {BridgeERC20}: Standalone bridge contract to connect an ERC-20 token contract with counterparts on remote chains, * {BridgeERC7802}: Standalone bridge contract to connect an ERC-7802 token contract with counterparts on remote chains. +* {BridgeERC721Core}: Core bridging logic for crosschain ERC-721 transfer. Used by {BridgeERC721} and {ERC721Crosschain}, +* {BridgeERC721}: Standalone bridge contract to connect an ERC-721 token contract with counterparts on remote chains, == Helpers @@ -26,4 +28,8 @@ Additionally there are multiple bridge constructions: {{BridgeERC20}} -{{BridgeERC7802}} \ No newline at end of file +{{BridgeERC7802}} + +{{BridgeERC721Core}} + +{{BridgeERC721}} \ No newline at end of file diff --git a/contracts/token/ERC721/README.adoc b/contracts/token/ERC721/README.adoc index 22a306235c1..d06799e487e 100644 --- a/contracts/token/ERC721/README.adoc +++ b/contracts/token/ERC721/README.adoc @@ -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 {BridgeERC721Core} 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 <>) and expose them as external functions in the way they prefer. @@ -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 From 9f3a5e4946a24d9cb381f07603649eb1174a6413 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Mon, 12 Jan 2026 12:51:32 +0100 Subject: [PATCH 10/22] Apply suggestion from @Amxx --- contracts/token/ERC721/extensions/ERC721Crosschain.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/token/ERC721/extensions/ERC721Crosschain.sol b/contracts/token/ERC721/extensions/ERC721Crosschain.sol index 5b9ba94be75..ea654c5ec8b 100644 --- a/contracts/token/ERC721/extensions/ERC721Crosschain.sol +++ b/contracts/token/ERC721/extensions/ERC721Crosschain.sol @@ -14,7 +14,7 @@ import {BridgeERC721Core} from "../../../crosschain/bridges/BridgeERC721Core.sol */ // 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. + /// @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); From 4355ab0f13f813c98797ce3ef4a26c01982218ab Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Mon, 12 Jan 2026 12:58:46 +0100 Subject: [PATCH 11/22] refactor flow --- contracts/crosschain/bridges/BridgeERC721.sol | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/contracts/crosschain/bridges/BridgeERC721.sol b/contracts/crosschain/bridges/BridgeERC721.sol index 48eae647d96..d99be9f69f7 100644 --- a/contracts/crosschain/bridges/BridgeERC721.sol +++ b/contracts/crosschain/bridges/BridgeERC721.sol @@ -40,11 +40,8 @@ abstract contract BridgeERC721 is IERC721Receiver, BridgeERC721Core { ); // 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`. Note: do not use safeTransferFrom here! Using it would trigger - // the `onERC721Received` which we don't want. - // - // slither-disable-next-line arbitrary-send-erc20 - token().transferFrom(from, address(this), tokenId); + // allowed to move tokenId on behalf of `from`. + token().safeTransferFrom(from, address(this), tokenId, to); // Perform the crosschain transfer and return the handler return _crosschainTransfer(from, to, tokenId); @@ -56,14 +53,20 @@ abstract contract BridgeERC721 is IERC721Receiver, BridgeERC721Core { * Note: The `data` must contain the `to` as a full InteroperableAddress (chain ref + address). */ function onERC721Received( - address /*operator*/, + 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); + + // If the operator is not this contract, it means the transfer was not initiated by + // `crosschainTransferFrom`, so we need to perform the crosschain send here. + if (operator != address(this)) { + _crosschainTransfer(from, data, tokenId); + } + return IERC721Receiver.onERC721Received.selector; } From 6c50eafcc0a25adce89916b0f9b2e39ae9c43ebd Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Mon, 12 Jan 2026 15:58:37 +0100 Subject: [PATCH 12/22] Remove the IERC721Receiver flow to avoid risks associated to hiden data (see #4043) --- contracts/crosschain/bridges/BridgeERC721.sol | 36 ++++--------------- test/crosschain/BridgeERC721.test.js | 27 -------------- 2 files changed, 6 insertions(+), 57 deletions(-) diff --git a/contracts/crosschain/bridges/BridgeERC721.sol b/contracts/crosschain/bridges/BridgeERC721.sol index d99be9f69f7..16130858f47 100644 --- a/contracts/crosschain/bridges/BridgeERC721.sol +++ b/contracts/crosschain/bridges/BridgeERC721.sol @@ -3,7 +3,6 @@ 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"; @@ -12,7 +11,7 @@ import {BridgeERC721Core} from "./BridgeERC721Core.sol"; * a crosschain mint and burn mechanism. Instead, it takes custody of bridged assets. */ // slither-disable-next-line locked-ether -abstract contract BridgeERC721 is IERC721Receiver, BridgeERC721Core { +abstract contract BridgeERC721 is BridgeERC721Core { IERC721 private immutable _token; error BridgeERC721Unauthorized(address caller); @@ -39,40 +38,17 @@ abstract contract BridgeERC721 is IERC721Receiver, BridgeERC721Core { IERC721Errors.ERC721InsufficientApproval(spender, tokenId) ); - // 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`. - token().safeTransferFrom(from, address(this), tokenId, 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 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)); - - // If the operator is not this contract, it means the transfer was not initiated by - // `crosschainTransferFrom`, so we need to perform the crosschain send here. - if (operator != address(this)) { - _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` + // slither-disable-next-line arbitrary-send-erc20 + token().transferFrom(from, address(this), tokenId); } /** diff --git a/test/crosschain/BridgeERC721.test.js b/test/crosschain/BridgeERC721.test.js index d006e2a2db5..00b8188dc79 100644 --- a/test/crosschain/BridgeERC721.test.js +++ b/test/crosschain/BridgeERC721.test.js @@ -1,7 +1,6 @@ const { ethers } = require('hardhat'); const { expect } = require('chai'); const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); -const { anyValue } = require('@nomicfoundation/hardhat-chai-matchers/withArgs'); const { impersonate } = require('../helpers/account'); const { getLocalChain } = require('../helpers/chains'); @@ -46,30 +45,4 @@ describe('CrosschainBridgeERC721', function () { }); shouldBehaveLikeBridgeERC721({ chainAIsCustodial: true }); - - it('direct crosschain transfer using a safeTransferFrom to the bridge', async function () { - const tokenId = 17n; - const [alice, bruce] = this.accounts; - await this.tokenA.$_mint(alice, tokenId); - - await expect( - this.tokenA - .connect(alice) - .safeTransferFrom(alice, this.bridgeA, tokenId, ethers.Typed.bytes(this.chain.toErc7930(bruce))), - ) - // bridge takes custody of the token - .to.emit(this.tokenA, 'Transfer') - .withArgs(alice, this.bridgeA, tokenId) - // crosschain transfer sent - .to.emit(this.bridgeA, 'CrosschainERC721TransferSent') - .withArgs(anyValue, alice, this.chain.toErc7930(bruce), tokenId) - // ERC-7786 event - .to.emit(this.gateway, 'MessageSent') - // crosschain transfer received - .to.emit(this.tokenB, 'CrosschainERC721TransferReceived') - .withArgs(anyValue, this.chain.toErc7930(alice), bruce, tokenId) - // bridge on destination chain mints the token - .to.emit(this.tokenB, 'Transfer') - .withArgs(ethers.ZeroAddress, bruce, tokenId); - }); }); From f49055061bcd3bf61b23ed2507dc006372c736e5 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Mon, 12 Jan 2026 16:09:29 +0100 Subject: [PATCH 13/22] Apply suggestions from code review Co-authored-by: Francisco Giordano --- contracts/crosschain/bridges/BridgeERC721.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/crosschain/bridges/BridgeERC721.sol b/contracts/crosschain/bridges/BridgeERC721.sol index 16130858f47..981b2f224f6 100644 --- a/contracts/crosschain/bridges/BridgeERC721.sol +++ b/contracts/crosschain/bridges/BridgeERC721.sol @@ -26,7 +26,7 @@ abstract contract BridgeERC721 is BridgeERC721Core { } /** - * @dev Transfer `amount` tokens to a crosschain receiver. + * @dev Transfer `tokenId` token to a crosschain receiver. * * Note: The `to` parameter is the full InteroperableAddress (chain ref + address). */ @@ -41,7 +41,7 @@ abstract contract BridgeERC721 is BridgeERC721Core { // 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 + // Perform the crosschain transfer and return the send id return _crosschainTransfer(from, to, tokenId); } From 88faa8e51e698eabf79bfe9810d7791f14d98dd0 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Mon, 12 Jan 2026 16:16:42 +0100 Subject: [PATCH 14/22] use transferFrom in --- contracts/crosschain/bridges/BridgeERC721.sol | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/contracts/crosschain/bridges/BridgeERC721.sol b/contracts/crosschain/bridges/BridgeERC721.sol index 981b2f224f6..3458e0a0393 100644 --- a/contracts/crosschain/bridges/BridgeERC721.sol +++ b/contracts/crosschain/bridges/BridgeERC721.sol @@ -51,13 +51,9 @@ abstract contract BridgeERC721 is BridgeERC721Core { token().transferFrom(from, address(this), tokenId); } - /** - * @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. - */ + /// @dev "Unlocking" tokens is done by releasing custody function _onReceive(address to, uint256 tokenId) internal virtual override { - token().safeTransferFrom(address(this), to, tokenId); + // slither-disable-next-line arbitrary-send-erc20 + token().transferFrom(address(this), to, tokenId); } } From 86ef7870dcaf1eccaa2b0a0c6ca3925bb44401a0 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Tue, 13 Jan 2026 16:48:35 +0100 Subject: [PATCH 15/22] update docs --- contracts/crosschain/bridges/BridgeERC721.sol | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/contracts/crosschain/bridges/BridgeERC721.sol b/contracts/crosschain/bridges/BridgeERC721.sol index 3458e0a0393..ba3f058c0b3 100644 --- a/contracts/crosschain/bridges/BridgeERC721.sol +++ b/contracts/crosschain/bridges/BridgeERC721.sol @@ -26,9 +26,13 @@ abstract contract BridgeERC721 is BridgeERC721Core { } /** - * @dev Transfer `tokenId` token to a crosschain receiver. + * @dev Transfer `tokenId` from `from` (on this chain) to `to` (on a different chain). * - * Note: The `to` parameter is the full InteroperableAddress (chain ref + address). + * 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`. From ade7c920c8c923a66a4b30157071bc4ed12896a7 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Tue, 13 Jan 2026 16:55:59 +0100 Subject: [PATCH 16/22] Apply suggestions from code review --- contracts/crosschain/bridges/BridgeERC721.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/crosschain/bridges/BridgeERC721.sol b/contracts/crosschain/bridges/BridgeERC721.sol index ba3f058c0b3..c12d286a41a 100644 --- a/contracts/crosschain/bridges/BridgeERC721.sol +++ b/contracts/crosschain/bridges/BridgeERC721.sol @@ -29,7 +29,7 @@ abstract contract BridgeERC721 is BridgeERC721Core { * @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 + * 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. From bbbfa0ba906488003636b058f0e9dc51412125eb Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Wed, 4 Feb 2026 18:38:46 +0100 Subject: [PATCH 17/22] Update contracts/crosschain/bridges/BridgeERC721Core.sol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ernesto García --- contracts/crosschain/bridges/BridgeERC721Core.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/crosschain/bridges/BridgeERC721Core.sol b/contracts/crosschain/bridges/BridgeERC721Core.sol index 0d710c95e53..7597c45d9d8 100644 --- a/contracts/crosschain/bridges/BridgeERC721Core.sol +++ b/contracts/crosschain/bridges/BridgeERC721Core.sol @@ -26,7 +26,7 @@ abstract contract BridgeERC721Core is Context, CrosschainLinked { /** * @dev Internal crosschain transfer function. * - * Note: The `to` parameter is the full InteroperableAddress (chain ref + address). + * 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); From 09aebc70dc166a0bb66259fe46f79a2f15382336 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Wed, 4 Feb 2026 20:53:12 +0100 Subject: [PATCH 18/22] PR comments --- contracts/crosschain/bridges/BridgeERC20Core.sol | 11 ++++++----- contracts/crosschain/bridges/BridgeERC721.sol | 2 -- contracts/crosschain/bridges/BridgeERC721Core.sol | 11 ++++++----- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/contracts/crosschain/bridges/BridgeERC20Core.sol b/contracts/crosschain/bridges/BridgeERC20Core.sol index b1667a4495c..8f7fa396d35 100644 --- a/contracts/crosschain/bridges/BridgeERC20Core.sol +++ b/contracts/crosschain/bridges/BridgeERC20Core.sol @@ -19,9 +19,10 @@ import {CrosschainLinked} from "../CrosschainLinked.sol"; * extension, which embeds the bridge logic directly in the token contract. */ abstract contract BridgeERC20Core is Context, CrosschainLinked { - using InteroperableAddress for bytes; - + /// @dev Emitted when a crosschain ERC-20 transfer is sent. event CrosschainERC20TransferSent(bytes32 indexed sendId, address indexed from, bytes to, uint256 amount); + + /// @dev Emitted when a crosschain ERC-20 transfer is received. event CrosschainERC20TransferReceived(bytes32 indexed receiveId, bytes from, address indexed to, uint256 amount); /** @@ -41,7 +42,7 @@ abstract contract BridgeERC20Core 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( @@ -63,8 +64,8 @@ abstract contract BridgeERC20Core is Context, CrosschainLinked { 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)); + (bytes memory from, bytes memory addr, uint256 amount) = abi.decode(payload, (bytes, bytes, uint256)); + address to = address(bytes20(addr)); _onReceive(to, amount); diff --git a/contracts/crosschain/bridges/BridgeERC721.sol b/contracts/crosschain/bridges/BridgeERC721.sol index c12d286a41a..62f913b9a13 100644 --- a/contracts/crosschain/bridges/BridgeERC721.sol +++ b/contracts/crosschain/bridges/BridgeERC721.sol @@ -14,8 +14,6 @@ import {BridgeERC721Core} from "./BridgeERC721Core.sol"; abstract contract BridgeERC721 is BridgeERC721Core { IERC721 private immutable _token; - error BridgeERC721Unauthorized(address caller); - constructor(IERC721 token_) { _token = token_; } diff --git a/contracts/crosschain/bridges/BridgeERC721Core.sol b/contracts/crosschain/bridges/BridgeERC721Core.sol index 0d710c95e53..3b305ba92d6 100644 --- a/contracts/crosschain/bridges/BridgeERC721Core.sol +++ b/contracts/crosschain/bridges/BridgeERC721Core.sol @@ -18,9 +18,10 @@ import {CrosschainLinked} from "../CrosschainLinked.sol"; * the {ERC721Crosschain} extension, which embeds the bridge logic directly in the token contract. */ abstract contract BridgeERC721Core is Context, CrosschainLinked { - using InteroperableAddress for bytes; - + /// @dev Emitted when a crosschain ERC-721 transfer is sent. event CrosschainERC721TransferSent(bytes32 indexed sendId, address indexed from, bytes to, uint256 tokenId); + + /// @dev Emitted when a crosschain ERC-721 transfer is received. event CrosschainERC721TransferReceived(bytes32 indexed receiveId, bytes from, address indexed to, uint256 tokenId); /** @@ -31,7 +32,7 @@ abstract contract BridgeERC721Core is Context, CrosschainLinked { 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(); + (bytes2 chainType, bytes memory chainReference, bytes memory addr) = InteroperableAddress.parseV1(to); bytes memory chain = InteroperableAddress.formatV1(chainType, chainReference, hex""); bytes32 sendId = _sendMessageToCounterpart( @@ -53,8 +54,8 @@ abstract contract BridgeERC721Core is Context, CrosschainLinked { bytes calldata payload ) internal virtual override { // split payload - (bytes memory from, bytes memory toBinary, uint256 tokenId) = abi.decode(payload, (bytes, bytes, uint256)); - address to = address(bytes20(toBinary)); + (bytes memory from, bytes memory addr, uint256 tokenId) = abi.decode(payload, (bytes, bytes, uint256)); + address to = address(bytes20(addr)); _onReceive(to, tokenId); From 7f6d544667a45c766ab9fff29dcd51261ecb42b0 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Sat, 7 Feb 2026 15:55:10 +0100 Subject: [PATCH 19/22] rename / reorganise following the ERC20 changes --- .changeset/fruity-coats-smash.md | 2 +- contracts/crosschain/README.adoc | 4 ++-- contracts/crosschain/bridges/BridgeERC721.sol | 6 ++--- .../BridgeNonFungible.sol} | 23 +++++++++++-------- contracts/token/ERC721/README.adoc | 2 +- .../ERC721/extensions/ERC721Crosschain.sol | 6 ++--- test/crosschain/BridgeERC721.behavior.js | 16 ++++++------- 7 files changed, 32 insertions(+), 27 deletions(-) rename contracts/crosschain/bridges/{BridgeERC721Core.sol => abstract/BridgeNonFungible.sol} (75%) diff --git a/.changeset/fruity-coats-smash.md b/.changeset/fruity-coats-smash.md index 70b35dcf98f..ebc27594ee2 100644 --- a/.changeset/fruity-coats-smash.md +++ b/.changeset/fruity-coats-smash.md @@ -2,4 +2,4 @@ 'openzeppelin-solidity': minor --- -`BridgeERC721Core` and `BridgeERC721`: Added bridge contracts to handle crosschain movements of ERC-721 tokens. +`BridgeNonFungibleCore` and `BridgeERC721`: Added bridge contracts to handle crosschain movements of ERC-721 tokens. diff --git a/contracts/crosschain/README.adoc b/contracts/crosschain/README.adoc index e48d212aa82..274228b46c1 100644 --- a/contracts/crosschain/README.adoc +++ b/contracts/crosschain/README.adoc @@ -13,7 +13,7 @@ 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. -* {BridgeERC721Core}: Core bridging logic for crosschain ERC-721 transfer. Used by {BridgeERC721} and {ERC721Crosschain}, +* {BridgeNonFungible}: Core bridging logic for crosschain ERC-721 transfer. Used by {BridgeERC721} and {ERC721Crosschain}, * {BridgeERC721}: Standalone bridge contract to connect an ERC-721 token contract with counterparts on remote chains, == Helpers @@ -30,6 +30,6 @@ Additionally there are multiple bridge constructions: {{BridgeERC7802}} -{{BridgeERC721Core}} +{{BridgeNonFungible}} {{BridgeERC721}} \ No newline at end of file diff --git a/contracts/crosschain/bridges/BridgeERC721.sol b/contracts/crosschain/bridges/BridgeERC721.sol index 62f913b9a13..75b020c9949 100644 --- a/contracts/crosschain/bridges/BridgeERC721.sol +++ b/contracts/crosschain/bridges/BridgeERC721.sol @@ -4,14 +4,14 @@ pragma solidity ^0.8.26; import {IERC721} from "../../interfaces/IERC721.sol"; import {IERC721Errors} from "../../interfaces/draft-IERC6093.sol"; -import {BridgeERC721Core} from "./BridgeERC721Core.sol"; +import {BridgeNonFungible} from "./abstract/BridgeNonFungible.sol"; /** - * @dev This is a variant of {BridgeERC721Core} that implements the bridge logic for ERC-721 tokens that do not expose + * @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 -abstract contract BridgeERC721 is BridgeERC721Core { +abstract contract BridgeERC721 is BridgeNonFungible { IERC721 private immutable _token; constructor(IERC721 token_) { diff --git a/contracts/crosschain/bridges/BridgeERC721Core.sol b/contracts/crosschain/bridges/abstract/BridgeNonFungible.sol similarity index 75% rename from contracts/crosschain/bridges/BridgeERC721Core.sol rename to contracts/crosschain/bridges/abstract/BridgeNonFungible.sol index dcfb2b1cc52..706d428e8ef 100644 --- a/contracts/crosschain/bridges/BridgeERC721Core.sol +++ b/contracts/crosschain/bridges/abstract/BridgeNonFungible.sol @@ -2,10 +2,10 @@ 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"; +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. @@ -17,12 +17,17 @@ import {CrosschainLinked} from "../CrosschainLinked.sol"; * 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 { +abstract contract BridgeNonFungible is Context, CrosschainLinked { /// @dev Emitted when a crosschain ERC-721 transfer is sent. - event CrosschainERC721TransferSent(bytes32 indexed sendId, address indexed from, bytes to, uint256 tokenId); + event CrosschainNonFungibleTransferSent(bytes32 indexed sendId, address indexed from, bytes to, uint256 tokenId); /// @dev Emitted when a crosschain ERC-721 transfer is received. - event CrosschainERC721TransferReceived(bytes32 indexed receiveId, bytes from, address indexed to, uint256 tokenId); + event CrosschainNonFungibleTransferReceived( + bytes32 indexed receiveId, + bytes from, + address indexed to, + uint256 tokenId + ); /** * @dev Internal crosschain transfer function. @@ -41,7 +46,7 @@ abstract contract BridgeERC721Core is Context, CrosschainLinked { new bytes[](0) ); - emit CrosschainERC721TransferSent(sendId, from, to, tokenId); + emit CrosschainNonFungibleTransferSent(sendId, from, to, tokenId); return sendId; } @@ -59,7 +64,7 @@ abstract contract BridgeERC721Core is Context, CrosschainLinked { _onReceive(to, tokenId); - emit CrosschainERC721TransferReceived(receiveId, from, 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. diff --git a/contracts/token/ERC721/README.adoc b/contracts/token/ERC721/README.adoc index d06799e487e..178ac6ac929 100644 --- a/contracts/token/ERC721/README.adoc +++ b/contracts/token/ERC721/README.adoc @@ -24,7 +24,7 @@ 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 {BridgeERC721Core} bridge, making the token crosschain through the use of ERC-7786 gateways. +* {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. diff --git a/contracts/token/ERC721/extensions/ERC721Crosschain.sol b/contracts/token/ERC721/extensions/ERC721Crosschain.sol index ea654c5ec8b..c77a2b9af14 100644 --- a/contracts/token/ERC721/extensions/ERC721Crosschain.sol +++ b/contracts/token/ERC721/extensions/ERC721Crosschain.sol @@ -3,17 +3,17 @@ pragma solidity ^0.8.26; import {ERC721} from "../ERC721.sol"; -import {BridgeERC721Core} from "../../../crosschain/bridges/BridgeERC721Core.sol"; +import {BridgeNonFungible} from "../../../crosschain/bridges/abstract/BridgeNonFungible.sol"; /** - * @dev Extension of {ERC721} that makes it natively cross-chain using the ERC-7786 based {BridgeERC721Core}. + * @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, BridgeERC721Core { +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` diff --git a/test/crosschain/BridgeERC721.behavior.js b/test/crosschain/BridgeERC721.behavior.js index 9d5ea8032ab..65756a2c38e 100644 --- a/test/crosschain/BridgeERC721.behavior.js +++ b/test/crosschain/BridgeERC721.behavior.js @@ -38,12 +38,12 @@ function shouldBehaveLikeBridgeERC721({ chainAIsCustodial = false, chainBIsCusto .to.emit(this.tokenA, 'Transfer') .withArgs(alice, chainAIsCustodial ? this.bridgeA : ethers.ZeroAddress, tokenId) // crosschain transfer sent - .to.emit(this.bridgeA, 'CrosschainERC721TransferSent') + .to.emit(this.bridgeA, 'CrosschainNonFungibleTransferSent') .withArgs(anyValue, alice, this.chain.toErc7930(bruce), tokenId) // ERC-7786 event .to.emit(this.gateway, 'MessageSent') // crosschain transfer received - .to.emit(this.bridgeB, 'CrosschainERC721TransferReceived') + .to.emit(this.bridgeB, 'CrosschainNonFungibleTransferReceived') .withArgs(anyValue, this.chain.toErc7930(alice), bruce, tokenId) // tokens are minted on chain B .to.emit(this.tokenB, 'Transfer') @@ -55,12 +55,12 @@ function shouldBehaveLikeBridgeERC721({ chainAIsCustodial = false, chainBIsCusto .to.emit(this.tokenB, 'Transfer') .withArgs(bruce, chainBIsCustodial ? this.bridgeB : ethers.ZeroAddress, tokenId) // crosschain transfer sent - .to.emit(this.bridgeB, 'CrosschainERC721TransferSent') + .to.emit(this.bridgeB, 'CrosschainNonFungibleTransferSent') .withArgs(anyValue, bruce, this.chain.toErc7930(chris), tokenId) // ERC-7786 event .to.emit(this.gateway, 'MessageSent') // crosschain transfer received - .to.emit(this.bridgeA, 'CrosschainERC721TransferReceived') + .to.emit(this.bridgeA, 'CrosschainNonFungibleTransferReceived') .withArgs(anyValue, this.chain.toErc7930(bruce), chris, tokenId) // bridge on chain A releases custody of the token .to.emit(this.tokenA, 'Transfer') @@ -76,7 +76,7 @@ function shouldBehaveLikeBridgeERC721({ chainAIsCustodial = false, chainBIsCusto await this.tokenA.connect(alice).setApprovalForAll(this.bridgeA, true); await expect(this.bridgeA.connect(alice).crosschainTransferFrom(alice, this.chain.toErc7930(bruce), tokenId)) - .to.emit(this.bridgeA, 'CrosschainERC721TransferSent') + .to.emit(this.bridgeA, 'CrosschainNonFungibleTransferSent') .withArgs(anyValue, alice, this.chain.toErc7930(bruce), tokenId); }); @@ -95,7 +95,7 @@ function shouldBehaveLikeBridgeERC721({ chainAIsCustodial = false, chainBIsCusto // chris is allowed await expect(this.bridgeA.connect(chris).crosschainTransferFrom(alice, this.chain.toErc7930(bruce), tokenId)) - .to.emit(this.bridgeA, 'CrosschainERC721TransferSent') + .to.emit(this.bridgeA, 'CrosschainNonFungibleTransferSent') .withArgs(anyValue, alice, this.chain.toErc7930(bruce), tokenId); }); @@ -118,7 +118,7 @@ function shouldBehaveLikeBridgeERC721({ chainAIsCustodial = false, chainBIsCusto // chris is allowed to transfer tokenId await expect(this.bridgeA.connect(chris).crosschainTransferFrom(alice, this.chain.toErc7930(bruce), tokenId)) - .to.emit(this.bridgeA, 'CrosschainERC721TransferSent') + .to.emit(this.bridgeA, 'CrosschainNonFungibleTransferSent') .withArgs(anyValue, alice, this.chain.toErc7930(bruce), tokenId); }); }); @@ -191,7 +191,7 @@ function shouldBehaveLikeBridgeERC721({ chainAIsCustodial = false, chainBIsCusto // first time works await expect( this.bridgeA.connect(this.gatewayAsEOA).receiveMessage(id, this.chain.toErc7930(this.bridgeB), payload), - ).to.emit(this.bridgeA, 'CrosschainERC721TransferReceived'); + ).to.emit(this.bridgeA, 'CrosschainNonFungibleTransferReceived'); // second time fails await expect( From be7ab960a7285eb6547ae9114e039b2a811a80b0 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 12 Feb 2026 17:09:48 +0100 Subject: [PATCH 20/22] Apply suggestions from code review Co-authored-by: Francisco Giordano --- contracts/crosschain/bridges/abstract/BridgeFungible.sol | 4 ++-- contracts/crosschain/bridges/abstract/BridgeNonFungible.sol | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/contracts/crosschain/bridges/abstract/BridgeFungible.sol b/contracts/crosschain/bridges/abstract/BridgeFungible.sol index ba86c8f5464..28e590a6696 100644 --- a/contracts/crosschain/bridges/abstract/BridgeFungible.sol +++ b/contracts/crosschain/bridges/abstract/BridgeFungible.sol @@ -64,8 +64,8 @@ abstract contract BridgeFungible is Context, CrosschainLinked { bytes calldata payload ) internal virtual override { // split payload - (bytes memory from, bytes memory addr, uint256 amount) = abi.decode(payload, (bytes, bytes, uint256)); - address to = address(bytes20(addr)); + (bytes memory from, bytes memory toEvm, uint256 amount) = abi.decode(payload, (bytes, bytes, uint256)); + address to = address(bytes20(toEvm)); _onReceive(to, amount); diff --git a/contracts/crosschain/bridges/abstract/BridgeNonFungible.sol b/contracts/crosschain/bridges/abstract/BridgeNonFungible.sol index 706d428e8ef..498ceebd71f 100644 --- a/contracts/crosschain/bridges/abstract/BridgeNonFungible.sol +++ b/contracts/crosschain/bridges/abstract/BridgeNonFungible.sol @@ -59,8 +59,8 @@ abstract contract BridgeNonFungible is Context, CrosschainLinked { bytes calldata payload ) internal virtual override { // split payload - (bytes memory from, bytes memory addr, uint256 tokenId) = abi.decode(payload, (bytes, bytes, uint256)); - address to = address(bytes20(addr)); + (bytes memory from, bytes memory toEvm, uint256 tokenId) = abi.decode(payload, (bytes, bytes, uint256)); + address to = address(bytes20(toEvm)); _onReceive(to, tokenId); From 981a262c56820af86c5485a51f72a0fe64e9d55a Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 19 Feb 2026 17:11:36 +0100 Subject: [PATCH 21/22] update test following ERC7786Recipient update --- test/crosschain/BridgeERC1155.behavior.js | 28 ----------------------- test/crosschain/BridgeERC20.behavior.js | 19 --------------- test/crosschain/BridgeERC721.behavior.js | 23 ------------------- 3 files changed, 70 deletions(-) diff --git a/test/crosschain/BridgeERC1155.behavior.js b/test/crosschain/BridgeERC1155.behavior.js index 909eac7bce9..a36d6a38ab2 100644 --- a/test/crosschain/BridgeERC1155.behavior.js +++ b/test/crosschain/BridgeERC1155.behavior.js @@ -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 () { diff --git a/test/crosschain/BridgeERC20.behavior.js b/test/crosschain/BridgeERC20.behavior.js index 2c7ed357294..3e54114bee2 100644 --- a/test/crosschain/BridgeERC20.behavior.js +++ b/test/crosschain/BridgeERC20.behavior.js @@ -105,25 +105,6 @@ function shouldBehaveLikeBridgeERC20({ chainAIsCustodial = false, chainBIsCustod .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 id = ethers.ZeroHash; - const payload = this.encodePayload(from, to, amount); - - // first time works - await expect( - this.bridgeA.connect(this.gatewayAsEOA).receiveMessage(id, this.chain.toErc7930(this.bridgeB), payload), - ).to.emit(this.bridgeA, 'CrosschainFungibleTransferReceived'); - - // second time fails - await expect( - this.bridgeA.connect(this.gatewayAsEOA).receiveMessage(id, this.chain.toErc7930(this.bridgeB), payload), - ) - .to.be.revertedWithCustomError(this.bridgeA, 'ERC7786RecipientMessageAlreadyProcessed') - .withArgs(this.gateway, id); - }); }); describe('reconfiguration', function () { diff --git a/test/crosschain/BridgeERC721.behavior.js b/test/crosschain/BridgeERC721.behavior.js index 65756a2c38e..41cbdab7f9c 100644 --- a/test/crosschain/BridgeERC721.behavior.js +++ b/test/crosschain/BridgeERC721.behavior.js @@ -177,29 +177,6 @@ function shouldBehaveLikeBridgeERC721({ chainAIsCustodial = false, chainBIsCusto .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 id = ethers.ZeroHash; - const payload = this.encodePayload(from, to, tokenId); - - if (chainAIsCustodial) { - await this.tokenA.$_mint(this.bridgeA, tokenId); - } - - // first time works - await expect( - this.bridgeA.connect(this.gatewayAsEOA).receiveMessage(id, this.chain.toErc7930(this.bridgeB), payload), - ).to.emit(this.bridgeA, 'CrosschainNonFungibleTransferReceived'); - - // second time fails - await expect( - this.bridgeA.connect(this.gatewayAsEOA).receiveMessage(id, this.chain.toErc7930(this.bridgeB), payload), - ) - .to.be.revertedWithCustomError(this.bridgeA, 'ERC7786RecipientMessageAlreadyProcessed') - .withArgs(this.gateway, id); - }); }); describe('reconfiguration', function () { From d2cef0a64f8d2ea10b9976eb80f07b102f55fee0 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 19 Feb 2026 17:12:45 +0100 Subject: [PATCH 22/22] reorder README --- contracts/crosschain/README.adoc | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/contracts/crosschain/README.adoc b/contracts/crosschain/README.adoc index 63388786352..4e00a7e5b08 100644 --- a/contracts/crosschain/README.adoc +++ b/contracts/crosschain/README.adoc @@ -11,12 +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 @@ -28,14 +28,14 @@ Additionally there are multiple bridge constructions: {{BridgeFungible}} -{{BridgeERC20}} - -{{BridgeERC7802}} - {{BridgeNonFungible}} {{BridgeMultiToken}} +{{BridgeERC20}} + {{BridgeERC721}} {{BridgeERC1155}} + +{{BridgeERC7802}}