diff --git a/.changeset/fruity-coats-smash.md b/.changeset/fruity-coats-smash.md new file mode 100644 index 00000000000..ebc27594ee2 --- /dev/null +++ b/.changeset/fruity-coats-smash.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`BridgeNonFungibleCore` 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. diff --git a/contracts/crosschain/README.adoc b/contracts/crosschain/README.adoc index 5ed313ca0e8..4e00a7e5b08 100644 --- a/contracts/crosschain/README.adoc +++ b/contracts/crosschain/README.adoc @@ -11,10 +11,12 @@ This directory contains contracts for sending and receiving cross chain messages Additionally there are multiple bridge constructions: * {BridgeFungible}: Core bridging logic for crosschain ERC-20 transfer. Used by {BridgeERC20}, {BridgeERC7802} and {ERC20Crosschain}, -* {BridgeERC20}: Standalone bridge contract to connect an ERC-20 token contract with counterparts on remote chains, -* {BridgeERC7802}: Standalone bridge contract to connect an ERC-7802 token contract with counterparts on remote chains. +* {BridgeNonFungible}: Core bridging logic for crosschain ERC-721 transfer. Used by {BridgeERC721} and {ERC721Crosschain}, * {BridgeMultiToken}: Core bridging logic for crosschain ERC-1155 transfer. Used by {BridgeERC1155} and {ERC1155Crosschain}, +* {BridgeERC20}: Standalone bridge contract to connect an ERC-20 token contract with counterparts on remote chains, +* {BridgeERC721}: Standalone bridge contract to connect an ERC-721 token contract with counterparts on remote chains, * {BridgeERC1155}: Standalone bridge contract to connect an ERC-1155 token contract with counterparts on remote chains, +* {BridgeERC7802}: Standalone bridge contract to connect an ERC-7802 token contract with counterparts on remote chains. == Helpers @@ -26,10 +28,14 @@ Additionally there are multiple bridge constructions: {{BridgeFungible}} -{{BridgeERC20}} - -{{BridgeERC7802}} +{{BridgeNonFungible}} {{BridgeMultiToken}} +{{BridgeERC20}} + +{{BridgeERC721}} + {{BridgeERC1155}} + +{{BridgeERC7802}} diff --git a/contracts/crosschain/bridges/BridgeERC721.sol b/contracts/crosschain/bridges/BridgeERC721.sol new file mode 100644 index 00000000000..75b020c9949 --- /dev/null +++ b/contracts/crosschain/bridges/BridgeERC721.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.26; + +import {IERC721} from "../../interfaces/IERC721.sol"; +import {IERC721Errors} from "../../interfaces/draft-IERC6093.sol"; +import {BridgeNonFungible} from "./abstract/BridgeNonFungible.sol"; + +/** + * @dev This is a variant of {BridgeNonFungible} that implements the bridge logic for ERC-721 tokens that do not expose + * a crosschain mint and burn mechanism. Instead, it takes custody of bridged assets. + */ +// slither-disable-next-line locked-ether +abstract contract BridgeERC721 is BridgeNonFungible { + IERC721 private immutable _token; + + constructor(IERC721 token_) { + _token = token_; + } + + /// @dev Return the address of the ERC721 token this bridge operates on. + function token() public view virtual returns (IERC721) { + return _token; + } + + /** + * @dev Transfer `tokenId` from `from` (on this chain) to `to` (on a different chain). + * + * The `to` parameter is the full InteroperableAddress that references both the destination chain and the account + * on that chain. Similarly to the underlying token's {ERC721-transferFrom} function, this function can be called + * either by the token holder or by anyone that is approved by the token holder. It reuses the token's allowance + * system, meaning that an account that is "approved for all" or "approved for tokenId" can perform the crosschain + * transfer directly without having to take temporary custody of the token. + */ + function crosschainTransferFrom(address from, bytes memory to, uint256 tokenId) public virtual returns (bytes32) { + // Permission is handled using the ERC721's allowance system. This check replicates `ERC721._isAuthorized`. + address spender = _msgSender(); + require( + from == spender || token().isApprovedForAll(from, spender) || token().getApproved(tokenId) == spender, + IERC721Errors.ERC721InsufficientApproval(spender, tokenId) + ); + + // This call verifies that `from` is the owner of `tokenId` (in `_onSend`), and the previous checks ensure + // that `spender` is allowed to move tokenId on behalf of `from`. + // + // Perform the crosschain transfer and return the send id + return _crosschainTransfer(from, to, tokenId); + } + + /// @dev "Locking" tokens is done by taking custody + function _onSend(address from, uint256 tokenId) internal virtual override { + // slither-disable-next-line arbitrary-send-erc20 + token().transferFrom(from, address(this), tokenId); + } + + /// @dev "Unlocking" tokens is done by releasing custody + function _onReceive(address to, uint256 tokenId) internal virtual override { + // slither-disable-next-line arbitrary-send-erc20 + token().transferFrom(address(this), to, tokenId); + } +} diff --git a/contracts/crosschain/bridges/abstract/BridgeFungible.sol b/contracts/crosschain/bridges/abstract/BridgeFungible.sol index f31b4a87626..e4061bb2270 100644 --- a/contracts/crosschain/bridges/abstract/BridgeFungible.sol +++ b/contracts/crosschain/bridges/abstract/BridgeFungible.sol @@ -19,9 +19,10 @@ import {CrosschainLinked} from "../../CrosschainLinked.sol"; * extension, which embeds the bridge logic directly in the token contract. */ abstract contract BridgeFungible is Context, CrosschainLinked { - using InteroperableAddress for bytes; - + /// @dev Emitted when a crosschain ERC-20 transfer is sent. event CrosschainFungibleTransferSent(bytes32 indexed sendId, address indexed from, bytes to, uint256 amount); + + /// @dev Emitted when a crosschain ERC-20 transfer is received. event CrosschainFungibleTransferReceived(bytes32 indexed receiveId, bytes from, address indexed to, uint256 amount); /** @@ -41,7 +42,7 @@ abstract contract BridgeFungible is Context, CrosschainLinked { function _crosschainTransfer(address from, bytes memory to, uint256 amount) internal virtual returns (bytes32) { _onSend(from, amount); - (bytes2 chainType, bytes memory chainReference, bytes memory addr) = to.parseV1(); + (bytes2 chainType, bytes memory chainReference, bytes memory addr) = InteroperableAddress.parseV1(to); bytes memory chain = InteroperableAddress.formatV1(chainType, chainReference, hex""); bytes32 sendId = _sendMessageToCounterpart( @@ -65,8 +66,8 @@ abstract contract BridgeFungible is Context, CrosschainLinked { // NOTE: Gateway is validated by {_isAuthorizedGateway} (implemented in {CrosschainLinked}). No need to check here. // split payload - (bytes memory from, bytes memory toBinary, uint256 amount) = abi.decode(payload, (bytes, bytes, uint256)); - address to = address(bytes20(toBinary)); + (bytes memory from, bytes memory toEvm, uint256 amount) = abi.decode(payload, (bytes, bytes, uint256)); + address to = address(bytes20(toEvm)); _onReceive(to, amount); diff --git a/contracts/crosschain/bridges/abstract/BridgeNonFungible.sol b/contracts/crosschain/bridges/abstract/BridgeNonFungible.sol new file mode 100644 index 00000000000..498ceebd71f --- /dev/null +++ b/contracts/crosschain/bridges/abstract/BridgeNonFungible.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.26; + +import {InteroperableAddress} from "../../../utils/draft-InteroperableAddress.sol"; +import {Context} from "../../../utils/Context.sol"; +import {ERC7786Recipient} from "../../ERC7786Recipient.sol"; +import {CrosschainLinked} from "../../CrosschainLinked.sol"; + +/** + * @dev Base contract for bridging ERC-721 between chains using an ERC-7786 gateway. + * + * In order to use this contract, two functions must be implemented to link it to the token: + * * {_onSend}: called when a crosschain transfer is going out. Must take the sender tokens or revert. + * * {_onReceive}: called when a crosschain transfer is coming in. Must give tokens to the receiver. + * + * This base contract is used by the {BridgeERC721}, which interfaces with legacy ERC-721 tokens. It is also used by + * the {ERC721Crosschain} extension, which embeds the bridge logic directly in the token contract. + */ +abstract contract BridgeNonFungible is Context, CrosschainLinked { + /// @dev Emitted when a crosschain ERC-721 transfer is sent. + event CrosschainNonFungibleTransferSent(bytes32 indexed sendId, address indexed from, bytes to, uint256 tokenId); + + /// @dev Emitted when a crosschain ERC-721 transfer is received. + event CrosschainNonFungibleTransferReceived( + bytes32 indexed receiveId, + bytes from, + address indexed to, + uint256 tokenId + ); + + /** + * @dev Internal crosschain transfer function. + * + * NOTE: The `to` parameter is the full InteroperableAddress (chain ref + address). + */ + function _crosschainTransfer(address from, bytes memory to, uint256 tokenId) internal virtual returns (bytes32) { + _onSend(from, tokenId); + + (bytes2 chainType, bytes memory chainReference, bytes memory addr) = InteroperableAddress.parseV1(to); + bytes memory chain = InteroperableAddress.formatV1(chainType, chainReference, hex""); + + bytes32 sendId = _sendMessageToCounterpart( + chain, + abi.encode(InteroperableAddress.formatEvmV1(block.chainid, from), addr, tokenId), + new bytes[](0) + ); + + emit CrosschainNonFungibleTransferSent(sendId, from, to, tokenId); + + return sendId; + } + + /// @inheritdoc ERC7786Recipient + function _processMessage( + address /*gateway*/, + bytes32 receiveId, + bytes calldata /*sender*/, + bytes calldata payload + ) internal virtual override { + // split payload + (bytes memory from, bytes memory toEvm, uint256 tokenId) = abi.decode(payload, (bytes, bytes, uint256)); + address to = address(bytes20(toEvm)); + + _onReceive(to, tokenId); + + emit CrosschainNonFungibleTransferReceived(receiveId, from, to, tokenId); + } + + /// @dev Virtual function: implementation is required to handle token being burnt or locked on the source chain. + function _onSend(address from, uint256 tokenId) internal virtual; + + /// @dev Virtual function: implementation is required to handle token being minted or unlocked on the destination chain. + function _onReceive(address to, uint256 tokenId) internal virtual; +} diff --git a/contracts/token/ERC721/README.adoc b/contracts/token/ERC721/README.adoc index 22a306235c1..178ac6ac929 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 {BridgeNonFungible} bridge, making the token crosschain through the use of ERC-7786 gateways. +* {ERC721Pausable}: A primitive to pause contract operation. +* {ERC721Royalty}: A way to signal royalty information following ERC-2981. * {ERC721URIStorage}: A more flexible but more expensive way of storing metadata. * {ERC721Votes}: Support for voting and vote delegation. -* {ERC721Royalty}: A way to signal royalty information following ERC-2981. -* {ERC721Pausable}: A primitive to pause contract operation. -* {ERC721Burnable}: A way for token holders to burn their own tokens. * {ERC721Wrapper}: Wrapper to create an ERC-721 backed by another ERC-721, with deposit and withdraw methods. Useful in conjunction with {ERC721Votes}. NOTE: This core set of contracts is designed to be unopinionated, allowing developers to access the internal functions in ERC-721 (such as <>) 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 diff --git a/contracts/token/ERC721/extensions/ERC721Crosschain.sol b/contracts/token/ERC721/extensions/ERC721Crosschain.sol new file mode 100644 index 00000000000..c77a2b9af14 --- /dev/null +++ b/contracts/token/ERC721/extensions/ERC721Crosschain.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.26; + +import {ERC721} from "../ERC721.sol"; +import {BridgeNonFungible} from "../../../crosschain/bridges/abstract/BridgeNonFungible.sol"; + +/** + * @dev Extension of {ERC721} that makes it natively cross-chain using the ERC-7786 based {BridgeNonFungible}. + * + * This extension makes the token compatible with: + * * {ERC721Crosschain} instances on other chains, + * * {ERC721} instances on other chains that are bridged using {BridgeERC721}, + */ +// slither-disable-next-line locked-ether +abstract contract ERC721Crosschain is ERC721, BridgeNonFungible { + /// @dev Crosschain variant of {transferFrom}, using the allowance system from the underlying ERC-721 token. + function crosschainTransferFrom(address from, bytes memory to, uint256 tokenId) public virtual returns (bytes32) { + // operator (_msgSender) permission over `from` is checked in `_onSend` + return _crosschainTransfer(from, to, tokenId); + } + + /// @dev "Locking" tokens is achieved through burning + function _onSend(address from, uint256 tokenId) internal virtual override { + address previousOwner = _update(address(0), tokenId, _msgSender()); + if (previousOwner == address(0)) { + revert ERC721NonexistentToken(tokenId); + } else if (previousOwner != from) { + revert ERC721IncorrectOwner(from, tokenId, previousOwner); + } + } + + /// @dev "Unlocking" tokens is achieved through minting + function _onReceive(address to, uint256 tokenId) internal virtual override { + _mint(to, tokenId); + } +} 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 976f7e4011a..3e54114bee2 100644 --- a/test/crosschain/BridgeERC20.behavior.js +++ b/test/crosschain/BridgeERC20.behavior.js @@ -5,136 +5,138 @@ 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, 'CrosschainFungibleTransferSent') - .withArgs(anyValue, alice, this.chain.toErc7930(bruce), amount) - // ERC-7786 event - .to.emit(this.gateway, 'MessageSent') - // crosschain transfer received - .to.emit(this.bridgeB, 'CrosschainFungibleTransferReceived') - .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, 'CrosschainFungibleTransferSent') - .withArgs(anyValue, bruce, this.chain.toErc7930(chris), amount) - // ERC-7786 event - .to.emit(this.gateway, 'MessageSent') - // crosschain transfer received - .to.emit(this.bridgeA, 'CrosschainFungibleTransferReceived') - .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; + 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, 'CrosschainFungibleTransferSent') + .withArgs(anyValue, alice, this.chain.toErc7930(bruce), amount) + // ERC-7786 event + .to.emit(this.gateway, 'MessageSent') + // crosschain transfer received + .to.emit(this.bridgeB, 'CrosschainFungibleTransferReceived') + .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, 'CrosschainFungibleTransferSent') + .withArgs(anyValue, bruce, this.chain.toErc7930(chris), amount) + // ERC-7786 event + .to.emit(this.gateway, 'MessageSent') + // crosschain transfer received + .to.emit(this.bridgeA, 'CrosschainFungibleTransferReceived') + .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); + }); - 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)); + 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)); + }); }); - }); - 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..41cbdab7f9c --- /dev/null +++ b/test/crosschain/BridgeERC721.behavior.js @@ -0,0 +1,218 @@ +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).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) + // crosschain transfer sent + .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, 'CrosschainNonFungibleTransferReceived') + .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).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) + // crosschain transfer sent + .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, 'CrosschainNonFungibleTransferReceived') + .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('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, 'CrosschainNonFungibleTransferSent') + .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, 'CrosschainNonFungibleTransferSent') + .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, 'CrosschainNonFungibleTransferSent') + .withArgs(anyValue, alice, this.chain.toErc7930(bruce), tokenId); + }); + }); + + 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; + + 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)); + }); + }); + + 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 if "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..00b8188dc79 --- /dev/null +++ b/test/crosschain/BridgeERC721.test.js @@ -0,0 +1,48 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +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('CrosschainBridgeERC721', 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 }); +}); diff --git a/test/token/ERC721/extensions/ERC721Crosschain.test.js b/test/token/ERC721/extensions/ERC721Crosschain.test.js new file mode 100644 index 00000000000..06f92676563 --- /dev/null +++ b/test/token/ERC721/extensions/ERC721Crosschain.test.js @@ -0,0 +1,44 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +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: ERC721 with native bridge integration + 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('ERC721Crosschain', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + shouldBehaveLikeBridgeERC721(); +});