diff --git a/.changeset/blue-jars-lay.md b/.changeset/blue-jars-lay.md new file mode 100644 index 00000000000..5934a64cb28 --- /dev/null +++ b/.changeset/blue-jars-lay.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`ERC1155Crosschain`: Added an ERC-1155 extension to embed an ERC-7786 based crosschain bridge directly in the token contract. diff --git a/.changeset/sweet-houses-cheer.md b/.changeset/sweet-houses-cheer.md new file mode 100644 index 00000000000..968c2c026b2 --- /dev/null +++ b/.changeset/sweet-houses-cheer.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`BridgeMultiToken` and `BridgeERC1155`: Added bridge contracts to handle crosschain movements of ERC-1155 tokens. diff --git a/contracts/crosschain/README.adoc b/contracts/crosschain/README.adoc index 3e181252009..5ed313ca0e8 100644 --- a/contracts/crosschain/README.adoc +++ b/contracts/crosschain/README.adoc @@ -13,6 +13,8 @@ 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. +* {BridgeMultiToken}: Core bridging logic for crosschain ERC-1155 transfer. Used by {BridgeERC1155} and {ERC1155Crosschain}, +* {BridgeERC1155}: Standalone bridge contract to connect an ERC-1155 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}} + +{{BridgeMultiToken}} + +{{BridgeERC1155}} diff --git a/contracts/crosschain/bridges/BridgeERC1155.sol b/contracts/crosschain/bridges/BridgeERC1155.sol new file mode 100644 index 00000000000..10060579777 --- /dev/null +++ b/contracts/crosschain/bridges/BridgeERC1155.sol @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.26; + +import {IERC1155} from "../../interfaces/IERC1155.sol"; +import {IERC1155Receiver} from "../../interfaces/IERC1155Receiver.sol"; +import {IERC1155Errors} from "../../interfaces/draft-IERC6093.sol"; +import {ERC1155Holder} from "../../token/ERC1155/utils/ERC1155Holder.sol"; +import {BridgeMultiToken} from "./abstract/BridgeMultiToken.sol"; + +/** + * @dev This is a variant of {BridgeMultiToken} that implements the bridge logic for ERC-1155 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 BridgeERC1155 is BridgeMultiToken, ERC1155Holder { + IERC1155 private immutable _token; + + constructor(IERC1155 token_) { + _token = token_; + } + + /// @dev Return the address of the ERC1155 token this bridge operates on. + function token() public view virtual returns (IERC1155) { + return _token; + } + + /** + * @dev Transfer `amount` tokens to a crosschain receiver. + * + * Note: The `to` parameter is the full InteroperableAddress (chain ref + address). + */ + function crosschainTransferFrom(address from, bytes memory to, uint256 id, uint256 value) public returns (bytes32) { + uint256[] memory ids = new uint256[](1); + uint256[] memory values = new uint256[](1); + ids[0] = id; + values[0] = value; + + return crosschainTransferFrom(from, to, ids, values); + } + + /** + * @dev Transfer `amount` tokens to a crosschain receiver. + * + * Note: The `to` parameter is the full InteroperableAddress (chain ref + address). + */ + function crosschainTransferFrom( + address from, + bytes memory to, + uint256[] memory ids, + uint256[] memory values + ) public virtual returns (bytes32) { + // Permission is handled using the ERC1155's allowance system. This check replicates `ERC1155._checkAuthorized`. + address spender = _msgSender(); + require( + from == spender || token().isApprovedForAll(from, spender), + IERC1155Errors.ERC1155MissingApprovalForAll(spender, from) + ); + + // Perform the crosschain transfer and return the handler + return _crosschainTransfer(from, to, ids, values); + } + + /// @dev "Locking" tokens is done by taking custody + function _onSend(address from, uint256[] memory ids, uint256[] memory values) internal virtual override { + token().safeBatchTransferFrom(from, address(this), ids, values, ""); + } + + /// @dev "Unlocking" tokens is done by releasing custody + function _onReceive(address to, uint256[] memory ids, uint256[] memory values) internal virtual override { + token().safeBatchTransferFrom(address(this), to, ids, values, ""); + } + + /// @dev Support receiving tokens only if the transfer was initiated by the bridge itself. + function onERC1155Received( + address operator, + address /* from */, + uint256 /* id */, + uint256 /* value */, + bytes memory /* data */ + ) public virtual override returns (bytes4) { + return + msg.sender == address(_token) && operator == address(this) + ? IERC1155Receiver.onERC1155Received.selector + : bytes4(0); + } + + /// @dev Support receiving tokens only if the transfer was initiated by the bridge itself. + function onERC1155BatchReceived( + address operator, + address /* from */, + uint256[] memory /* ids */, + uint256[] memory /* values */, + bytes memory /* data */ + ) public virtual override returns (bytes4) { + return + msg.sender == address(_token) && operator == address(this) + ? IERC1155Receiver.onERC1155BatchReceived.selector + : bytes4(0); + } +} diff --git a/contracts/crosschain/bridges/abstract/BridgeFungible.sol b/contracts/crosschain/bridges/abstract/BridgeFungible.sol index 801aa67900d..f31b4a87626 100644 --- a/contracts/crosschain/bridges/abstract/BridgeFungible.sol +++ b/contracts/crosschain/bridges/abstract/BridgeFungible.sol @@ -62,6 +62,8 @@ abstract contract BridgeFungible is Context, CrosschainLinked { bytes calldata /*sender*/, bytes calldata payload ) internal virtual override { + // 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)); diff --git a/contracts/crosschain/bridges/abstract/BridgeMultiToken.sol b/contracts/crosschain/bridges/abstract/BridgeMultiToken.sol new file mode 100644 index 00000000000..1e322f36d69 --- /dev/null +++ b/contracts/crosschain/bridges/abstract/BridgeMultiToken.sol @@ -0,0 +1,92 @@ +// 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-1155 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 {BridgeERC1155}, which interfaces with legacy ERC-1155 tokens. It is also used by + * the {ERC1155Crosschain} extension, which embeds the bridge logic directly in the token contract. + * + * This base contract implements the crosschain transfer operation though internal functions. It is for the the "child + * contracts" that inherit from this to implement the external interfaces and make this functions accessible. + */ +abstract contract BridgeMultiToken is Context, CrosschainLinked { + using InteroperableAddress for bytes; + + event CrosschainMultiTokenTransferSent( + bytes32 indexed sendId, + address indexed from, + bytes to, + uint256[] ids, + uint256[] values + ); + event CrosschainMultiTokenTransferReceived( + bytes32 indexed receiveId, + bytes from, + address indexed to, + uint256[] ids, + uint256[] values + ); + /** + * @dev Internal crosschain transfer function. + * + * Note: The `to` parameter is the full InteroperableAddress (chain ref + address). + */ + function _crosschainTransfer( + address from, + bytes memory to, + uint256[] memory ids, + uint256[] memory values + ) internal virtual returns (bytes32) { + _onSend(from, ids, values); + + (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, ids, values), + new bytes[](0) + ); + + emit CrosschainMultiTokenTransferSent(sendId, from, to, ids, values); + return sendId; + } + + /// @inheritdoc ERC7786Recipient + function _processMessage( + address /*gateway*/, + bytes32 receiveId, + bytes calldata /*sender*/, + bytes calldata payload + ) internal virtual override { + // NOTE: Gateway is validated by {_isAuthorizedGateway} (implemented in {CrosschainLinked}). No need to check here. + + // split payload + (bytes memory from, bytes memory toEvm, uint256[] memory ids, uint256[] memory values) = abi.decode( + payload, + (bytes, bytes, uint256[], uint256[]) + ); + address to = address(bytes20(toEvm)); + + _onReceive(to, ids, values); + + emit CrosschainMultiTokenTransferReceived(receiveId, from, to, ids, values); + } + + /// @dev Virtual function: implementation is required to handle token being burnt or locked on the source chain. + function _onSend(address from, uint256[] memory ids, uint256[] memory values) internal virtual; + + /// @dev Virtual function: implementation is required to handle token being minted or unlocked on the destination chain. + function _onReceive(address to, uint256[] memory ids, uint256[] memory values) internal virtual; +} diff --git a/contracts/token/ERC1155/README.adoc b/contracts/token/ERC1155/README.adoc index f8bf958f623..2b2e2dc3e0c 100644 --- a/contracts/token/ERC1155/README.adoc +++ b/contracts/token/ERC1155/README.adoc @@ -13,6 +13,7 @@ Additionally there are multiple custom extensions, including: * designation of addresses that can pause token transfers for all users ({ERC1155Pausable}). * destruction of own tokens ({ERC1155Burnable}). +* crosschain bridging of tokens through ERC-7786 gateways ({ERC1155Crosschain}). NOTE: This core set of contracts is designed to be unopinionated, allowing developers to access the internal functions in ERC-1155 (such as <>) and expose them as external functions in the way they prefer. @@ -28,10 +29,12 @@ NOTE: This core set of contracts is designed to be unopinionated, allowing devel == Extensions -{{ERC1155Pausable}} - {{ERC1155Burnable}} +{{ERC1155Crosschain}} + +{{ERC1155Pausable}} + {{ERC1155Supply}} {{ERC1155URIStorage}} diff --git a/contracts/token/ERC1155/extensions/ERC1155Crosschain.sol b/contracts/token/ERC1155/extensions/ERC1155Crosschain.sol new file mode 100644 index 00000000000..ebc3192c730 --- /dev/null +++ b/contracts/token/ERC1155/extensions/ERC1155Crosschain.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.26; + +import {ERC1155} from "../ERC1155.sol"; +import {BridgeMultiToken} from "../../../crosschain/bridges/abstract/BridgeMultiToken.sol"; + +/** + * @dev Extension of {ERC1155} that makes it natively cross-chain using the ERC-7786 based {BridgeMultiToken}. + * + * This extension makes the token compatible with: + * * {ERC1155Crosschain} instances on other chains, + * * {ERC1155} instances on other chains that are bridged using {BridgeERC1155}, + */ +// slither-disable-next-line locked-ether +abstract contract ERC1155Crosschain is ERC1155, BridgeMultiToken { + /// @dev TransferFrom variant of {crosschainTransferFrom}, using ERC1155 allowance from the sender to the caller. + function crosschainTransferFrom( + address from, + bytes memory to, + uint256 id, + uint256 value + ) public virtual returns (bytes32) { + _checkAuthorized(_msgSender(), from); + + uint256[] memory ids = new uint256[](1); + uint256[] memory values = new uint256[](1); + ids[0] = id; + values[0] = value; + return _crosschainTransfer(from, to, ids, values); + } + + /// @dev TransferFrom variant of {crosschainTransferFrom}, using ERC1155 allowance from the sender to the caller. + function crosschainTransferFrom( + address from, + bytes memory to, + uint256[] memory ids, + uint256[] memory values + ) public virtual returns (bytes32) { + _checkAuthorized(_msgSender(), from); + return _crosschainTransfer(from, to, ids, values); + } + + /// @dev "Locking" tokens is achieved through burning + function _onSend(address from, uint256[] memory ids, uint256[] memory values) internal virtual override { + _burnBatch(from, ids, values); + } + + /// @dev "Unlocking" tokens is achieved through minting + function _onReceive(address to, uint256[] memory ids, uint256[] memory values) internal virtual override { + _mintBatch(to, ids, values, ""); + } +} diff --git a/test/crosschain/BridgeERC1155.behavior.js b/test/crosschain/BridgeERC1155.behavior.js new file mode 100644 index 00000000000..909eac7bce9 --- /dev/null +++ b/test/crosschain/BridgeERC1155.behavior.js @@ -0,0 +1,361 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { anyValue } = require('@nomicfoundation/hardhat-chai-matchers/withArgs'); + +const ids = [17n, 42n]; +const values = [100n, 320n]; + +function shouldBehaveLikeBridgeERC1155({ chainAIsCustodial = false, chainBIsCustodial = false } = {}) { + describe('bridge ERC1155 like', function () { + beforeEach(function () { + // helper + this.encodePayload = (from, to, ids, values) => + ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes', 'bytes', 'uint256[]', 'uint256[]'], + [this.chain.toErc7930(from), to.target ?? to.address ?? to, ids, values], + ); + }); + + 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), + ]); + }); + + describe('crosschain send (both direction)', async function () { + it('single', async function () { + const [alice, bruce, chris] = this.accounts; + + await this.tokenA.$_mintBatch(alice, ids, values, '0x'); + 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).getFunction('crosschainTransferFrom(address,bytes,uint256,uint256)')( + alice, + this.chain.toErc7930(bruce), + ids[0], + values[0], + ), + ) + // bridge on chain A takes custody of the token + .to.emit(this.tokenA, 'TransferSingle') + .withArgs( + chainAIsCustodial ? this.bridgeA : alice, + alice, + chainAIsCustodial ? this.bridgeA : ethers.ZeroAddress, + ids[0], + values[0], + ) + // crosschain transfer sent + .to.emit(this.bridgeA, 'CrosschainMultiTokenTransferSent') + .withArgs(anyValue, alice, this.chain.toErc7930(bruce), ids.slice(0, 1), values.slice(0, 1)) + // ERC-7786 event + .to.emit(this.gateway, 'MessageSent') + // crosschain transfer received + .to.emit(this.bridgeB, 'CrosschainMultiTokenTransferReceived') + .withArgs(anyValue, this.chain.toErc7930(alice), bruce, ids.slice(0, 1), values.slice(0, 1)) + // tokens are minted on chain B + .to.emit(this.tokenB, 'TransferSingle') + .withArgs( + chainBIsCustodial ? this.bridgeB : this.gateway, + chainBIsCustodial ? this.bridgeB : ethers.ZeroAddress, + bruce, + ids[0], + values[0], + ); + + // Bruce sends tokens from chain B to Chris on chain A. + await expect( + this.bridgeB.connect(bruce).getFunction('crosschainTransferFrom(address,bytes,uint256,uint256)')( + bruce, + this.chain.toErc7930(chris), + ids[0], + values[0], + ), + ) + // tokens are burned on chain B + .to.emit(this.tokenB, 'TransferSingle') + .withArgs( + chainBIsCustodial ? this.bridgeB : bruce, + bruce, + chainBIsCustodial ? this.bridgeB : ethers.ZeroAddress, + ids[0], + values[0], + ) + // crosschain transfer sent + .to.emit(this.bridgeB, 'CrosschainMultiTokenTransferSent') + .withArgs(anyValue, bruce, this.chain.toErc7930(chris), ids.slice(0, 1), values.slice(0, 1)) + // ERC-7786 event + .to.emit(this.gateway, 'MessageSent') + // crosschain transfer received + .to.emit(this.bridgeA, 'CrosschainMultiTokenTransferReceived') + .withArgs(anyValue, this.chain.toErc7930(bruce), chris, ids.slice(0, 1), values.slice(0, 1)) + // bridge on chain A releases custody of the token + .to.emit(this.tokenA, 'TransferSingle') + .withArgs( + chainAIsCustodial ? this.bridgeA : this.gateway, + chainAIsCustodial ? this.bridgeA : ethers.ZeroAddress, + chris, + ids[0], + values[0], + ); + }); + + it('batch', async function () { + const [alice, bruce, chris] = this.accounts; + + await this.tokenA.$_mintBatch(alice, ids, values, '0x'); + 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).getFunction('crosschainTransferFrom(address,bytes,uint256[],uint256[])')( + alice, + this.chain.toErc7930(bruce), + ids, + values, + ), + ) + // bridge on chain A takes custody of the token + .to.emit(this.tokenA, 'TransferBatch') + .withArgs( + chainAIsCustodial ? this.bridgeA : alice, + alice, + chainAIsCustodial ? this.bridgeA : ethers.ZeroAddress, + ids, + values, + ) + // crosschain transfer sent + .to.emit(this.bridgeA, 'CrosschainMultiTokenTransferSent') + .withArgs(anyValue, alice, this.chain.toErc7930(bruce), ids, values) + // ERC-7786 event + .to.emit(this.gateway, 'MessageSent') + // crosschain transfer received + .to.emit(this.bridgeB, 'CrosschainMultiTokenTransferReceived') + .withArgs(anyValue, this.chain.toErc7930(alice), bruce, ids, values) + // tokens are minted on chain B + .to.emit(this.tokenB, 'TransferBatch') + .withArgs( + chainBIsCustodial ? this.bridgeB : this.gateway, + chainBIsCustodial ? this.bridgeB : ethers.ZeroAddress, + bruce, + ids, + values, + ); + + // Bruce sends tokens from chain B to Chris on chain A. + await expect( + this.bridgeB.connect(bruce).getFunction('crosschainTransferFrom(address,bytes,uint256[],uint256[])')( + bruce, + this.chain.toErc7930(chris), + ids, + values, + ), + ) + // tokens are burned on chain B + .to.emit(this.tokenB, 'TransferBatch') + .withArgs( + chainBIsCustodial ? this.bridgeB : bruce, + bruce, + chainBIsCustodial ? this.bridgeB : ethers.ZeroAddress, + ids, + values, + ) + // crosschain transfer sent + .to.emit(this.bridgeB, 'CrosschainMultiTokenTransferSent') + .withArgs(anyValue, bruce, this.chain.toErc7930(chris), ids, values) + // ERC-7786 event + .to.emit(this.gateway, 'MessageSent') + // crosschain transfer received + .to.emit(this.bridgeA, 'CrosschainMultiTokenTransferReceived') + .withArgs(anyValue, this.chain.toErc7930(bruce), chris, ids, values) + // bridge on chain A releases custody of the token + .to.emit(this.tokenA, 'TransferBatch') + .withArgs( + chainAIsCustodial ? this.bridgeA : this.gateway, + chainAIsCustodial ? this.bridgeA : ethers.ZeroAddress, + chris, + ids, + values, + ); + }); + }); + + describe('transfer with allowance', function () { + it('spender is owner', async function () { + const [alice, bruce] = this.accounts; + + await this.tokenA.$_mintBatch(alice, ids, values, '0x'); + await this.tokenA.connect(alice).setApprovalForAll(this.bridgeA, true); + + await expect( + this.bridgeA.connect(alice).getFunction('crosschainTransferFrom(address,bytes,uint256[],uint256[])')( + alice, + this.chain.toErc7930(bruce), + ids, + values, + ), + ) + .to.emit(this.bridgeA, 'CrosschainMultiTokenTransferSent') + .withArgs(anyValue, alice, this.chain.toErc7930(bruce), ids, values); + }); + + it('spender is allowed for all', async function () { + const [alice, bruce, chris] = this.accounts; + + await this.tokenA.$_mintBatch(alice, ids, values, '0x'); + await this.tokenA.connect(alice).setApprovalForAll(this.bridgeA, true); + await this.tokenA.connect(alice).setApprovalForAll(chris, true); + + await expect( + this.bridgeA.connect(chris).getFunction('crosschainTransferFrom(address,bytes,uint256[],uint256[])')( + alice, + this.chain.toErc7930(bruce), + ids, + values, + ), + ) + .to.emit(this.bridgeA, 'CrosschainMultiTokenTransferSent') + .withArgs(anyValue, alice, this.chain.toErc7930(bruce), ids, values); + }); + }); + + describe('invalid transfer', function () { + it('missing allowance', async function () { + const [alice, bruce, chris] = this.accounts; + + await this.tokenA.$_mintBatch(alice, ids, values, '0x'); + await this.tokenA.connect(alice).setApprovalForAll(this.bridgeA, true); + + // chris is not allowed + await expect( + this.bridgeA.connect(chris).getFunction('crosschainTransferFrom(address,bytes,uint256[],uint256[])')( + alice, + this.chain.toErc7930(bruce), + ids, + values, + ), + ) + .to.be.revertedWithCustomError(this.tokenA, 'ERC1155MissingApprovalForAll') + .withArgs(chris, alice); + }); + + it('insufficient balance', async function () { + const [alice, bruce] = this.accounts; + + await this.tokenA.connect(alice).setApprovalForAll(this.bridgeA, true); + + await expect( + this.bridgeA.connect(alice).getFunction('crosschainTransferFrom(address,bytes,uint256[],uint256[])')( + alice, + this.chain.toErc7930(bruce), + ids, + values, + ), + ) + .to.be.revertedWithCustomError(this.tokenA, 'ERC1155InsufficientBalance') + .withArgs(alice, 0n, values[0], ids[0]); + }); + }); + + 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, ids, values), + ), + ) + .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, ids, values), []), + ) + .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 () { + 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 = { + shouldBehaveLikeBridgeERC1155, +}; diff --git a/test/crosschain/BridgeERC1155.test.js b/test/crosschain/BridgeERC1155.test.js new file mode 100644 index 00000000000..03585a0308c --- /dev/null +++ b/test/crosschain/BridgeERC1155.test.js @@ -0,0 +1,70 @@ +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 { shouldBehaveLikeBridgeERC1155 } = require('./BridgeERC1155.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 ERC1155 with bridge + const tokenA = await ethers.deployContract('$ERC1155', ['https://token-cdn-domain/{id}.json']); + const bridgeA = await ethers.deployContract('$BridgeERC1155', [[], tokenA]); + + // Chain B: ERC1155 with native bridge integration + const tokenB = await ethers.deployContract('$ERC1155Crosschain', [ + 'https://token-cdn-domain/{id}.json', + [[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('CrosschainBridgeERC1155', 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); + }); + + shouldBehaveLikeBridgeERC1155({ chainAIsCustodial: true }); + + describe('direct transfer to bridge should fail', function () { + const ids = [17n, 42n]; + const values = [100n, 320n]; + + it('single', async function () { + const [alice] = this.accounts; + await this.tokenA.$_mintBatch(alice, ids, values, '0x'); + + await expect(this.tokenA.connect(alice).safeTransferFrom(alice, this.bridgeA, ids[0], values[0], '0x')) + .to.be.revertedWithCustomError(this.tokenA, 'ERC1155InvalidReceiver') + .withArgs(this.bridgeA); + }); + + it('batch', async function () { + const [alice] = this.accounts; + await this.tokenA.$_mintBatch(alice, ids, values, '0x'); + + await expect(this.tokenA.connect(alice).safeBatchTransferFrom(alice, this.bridgeA, ids, values, '0x')) + .to.be.revertedWithCustomError(this.tokenA, 'ERC1155InvalidReceiver') + .withArgs(this.bridgeA); + }); + }); +}); diff --git a/test/token/ERC1155/extensions/ERC1155Crosschain.test.js b/test/token/ERC1155/extensions/ERC1155Crosschain.test.js new file mode 100644 index 00000000000..7a50bf3fef3 --- /dev/null +++ b/test/token/ERC1155/extensions/ERC1155Crosschain.test.js @@ -0,0 +1,43 @@ +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 { shouldBehaveLikeBridgeERC1155 } = require('../../../crosschain/BridgeERC1155.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: ERC1155 with native bridge integration + const tokenA = await ethers.deployContract('$ERC1155Crosschain', ['https://token-cdn-domain/{id}.json', []]); + const bridgeA = tokenA; // self bridge + + // Chain B: ERC1155 with native bridge integration + const tokenB = await ethers.deployContract('$ERC1155Crosschain', [ + 'https://token-cdn-domain/{id}.json', + [[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('ERC1155Crosschain', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + shouldBehaveLikeBridgeERC1155(); +});