From 3304012b1e776ea1ac82b240363c56a33233e16e Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Mon, 12 Jan 2026 12:52:15 +0100 Subject: [PATCH 01/12] ERC-1155 crosschain bridge --- .../crosschain/bridges/BridgeERC1155.sol | 115 ++++++++++++++++++ .../crosschain/bridges/BridgeERC1155Core.sol | 87 +++++++++++++ .../ERC1155/extensions/ERC1155Crosschain.sol | 53 ++++++++ 3 files changed, 255 insertions(+) create mode 100644 contracts/crosschain/bridges/BridgeERC1155.sol create mode 100644 contracts/crosschain/bridges/BridgeERC1155Core.sol create mode 100644 contracts/token/ERC1155/extensions/ERC1155Crosschain.sol diff --git a/contracts/crosschain/bridges/BridgeERC1155.sol b/contracts/crosschain/bridges/BridgeERC1155.sol new file mode 100644 index 00000000000..c7c3e4ab1d9 --- /dev/null +++ b/contracts/crosschain/bridges/BridgeERC1155.sol @@ -0,0 +1,115 @@ +// 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 {BridgeERC1155Core} from "./BridgeERC1155Core.sol"; + +/** + * @dev This is a variant of {BridgeERC1155Core} 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 IERC1155Receiver, BridgeERC1155Core { + IERC1155 private immutable _token; + + error BridgeERC1155Unauthorized(address caller); + + 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 + * + * 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[] memory ids, uint256[] memory values) internal virtual override { + token().safeBatchTransferFrom(address(this), to, ids, values, ""); + } + + /** + * @dev Transfer a token received using an ERC-1155 safeTransferFrom + * + * Note: The `data` must contain the `to` as a full InteroperableAddress (chain ref + address). + */ + function onERC1155Received( + address operator, + address /* from */, + uint256 /* id */, + uint256 /* value */, + bytes calldata /* data */ + ) public virtual override returns (bytes4) { + return + (msg.sender == address(_token) && operator == address(this)) + ? IERC1155Receiver.onERC1155Received.selector + : bytes4(0xffffffff); + } + + /** + * @dev Transfer a batch of tokens received using an ERC-1155 safeBatchTransferFrom + * + * Note: The `data` must contain the `to` as a full InteroperableAddress (chain ref + address). + */ + function onERC1155BatchReceived( + address operator, + address /* from */, + uint256[] calldata /* ids */, + uint256[] calldata /* values */, + bytes calldata /* data */ + ) public virtual override returns (bytes4) { + return + (msg.sender == address(_token) && operator == address(this)) + ? IERC1155Receiver.onERC1155BatchReceived.selector + : bytes4(0xffffffff); + } +} diff --git a/contracts/crosschain/bridges/BridgeERC1155Core.sol b/contracts/crosschain/bridges/BridgeERC1155Core.sol new file mode 100644 index 00000000000..b2759dedfdd --- /dev/null +++ b/contracts/crosschain/bridges/BridgeERC1155Core.sol @@ -0,0 +1,87 @@ +// 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. + */ +abstract contract BridgeERC1155Core is Context, CrosschainLinked { + using InteroperableAddress for bytes; + + event CrosschainERC1155TransferSent( + bytes32 indexed sendId, + address indexed from, + bytes to, + uint256[] ids, + uint256[] values + ); + event CrosschainERC1155TransferReceived( + 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 CrosschainERC1155TransferSent(sendId, from, to, ids, values); + 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[] memory ids, uint256[] memory values) = abi.decode( + payload, + (bytes, bytes, uint256[], uint256[]) + ); + address to = address(bytes20(toBinary)); + + _onReceive(to, ids, values); + + emit CrosschainERC1155TransferReceived(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/extensions/ERC1155Crosschain.sol b/contracts/token/ERC1155/extensions/ERC1155Crosschain.sol new file mode 100644 index 00000000000..1f855c74ef1 --- /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 {BridgeERC1155Core} from "../../../crosschain/bridges/BridgeERC1155Core.sol"; + +/** + * @dev Extension of {ERC1155} that makes it natively cross-chain using the ERC-7786 based {BridgeERC1155Core}. + * + * 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, BridgeERC1155Core { + /// @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, ""); + } +} From 0081d473564cc4586c3e3c9fdb83f62f3a0028ac Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Tue, 13 Jan 2026 18:12:26 +0100 Subject: [PATCH 02/12] add tests --- .../crosschain/bridges/BridgeERC1155.sol | 15 +- test/crosschain/BridgeERC1155.behavior.js | 280 ++++++++++++++++++ test/crosschain/BridgeERC1155.test.js | 47 +++ .../extensions/ERC1155Crosschain.test.js | 43 +++ 4 files changed, 378 insertions(+), 7 deletions(-) create mode 100644 test/crosschain/BridgeERC1155.behavior.js create mode 100644 test/crosschain/BridgeERC1155.test.js create mode 100644 test/token/ERC1155/extensions/ERC1155Crosschain.test.js diff --git a/contracts/crosschain/bridges/BridgeERC1155.sol b/contracts/crosschain/bridges/BridgeERC1155.sol index c7c3e4ab1d9..1d3472d94f3 100644 --- a/contracts/crosschain/bridges/BridgeERC1155.sol +++ b/contracts/crosschain/bridges/BridgeERC1155.sol @@ -5,6 +5,7 @@ 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 {BridgeERC1155Core} from "./BridgeERC1155Core.sol"; /** @@ -12,7 +13,7 @@ import {BridgeERC1155Core} from "./BridgeERC1155Core.sol"; * a crosschain mint and burn mechanism. Instead, it takes custody of bridged assets. */ // slither-disable-next-line locked-ether -abstract contract BridgeERC1155 is IERC1155Receiver, BridgeERC1155Core { +abstract contract BridgeERC1155 is BridgeERC1155Core, ERC1155Holder { IERC1155 private immutable _token; error BridgeERC1155Unauthorized(address caller); @@ -87,10 +88,10 @@ abstract contract BridgeERC1155 is IERC1155Receiver, BridgeERC1155Core { address /* from */, uint256 /* id */, uint256 /* value */, - bytes calldata /* data */ + bytes memory /* data */ ) public virtual override returns (bytes4) { return - (msg.sender == address(_token) && operator == address(this)) + msg.sender == address(_token) && operator == address(this) ? IERC1155Receiver.onERC1155Received.selector : bytes4(0xffffffff); } @@ -103,12 +104,12 @@ abstract contract BridgeERC1155 is IERC1155Receiver, BridgeERC1155Core { function onERC1155BatchReceived( address operator, address /* from */, - uint256[] calldata /* ids */, - uint256[] calldata /* values */, - bytes calldata /* data */ + uint256[] memory /* ids */, + uint256[] memory /* values */, + bytes memory /* data */ ) public virtual override returns (bytes4) { return - (msg.sender == address(_token) && operator == address(this)) + msg.sender == address(_token) && operator == address(this) ? IERC1155Receiver.onERC1155BatchReceived.selector : bytes4(0xffffffff); } diff --git a/test/crosschain/BridgeERC1155.behavior.js b/test/crosschain/BridgeERC1155.behavior.js new file mode 100644 index 00000000000..861807dbc32 --- /dev/null +++ b/test/crosschain/BridgeERC1155.behavior.js @@ -0,0 +1,280 @@ +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), + ]); + }); + + it('crosschain send (both direction)', 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, 'CrosschainERC1155TransferSent') + .withArgs(anyValue, alice, this.chain.toErc7930(bruce), ids, values) + // ERC-7786 event + .to.emit(this.gateway, 'MessageSent') + // crosschain transfer received + .to.emit(this.bridgeB, 'CrosschainERC1155TransferReceived') + .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[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, 'CrosschainERC1155TransferSent') + .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, 'CrosschainERC1155TransferReceived') + .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], + ); + }); + + 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, 'CrosschainERC1155TransferSent') + .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, 'CrosschainERC1155TransferSent') + .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, 'CrosschainERC1155TransferReceived'); + + // 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..fa4c90a148c --- /dev/null +++ b/test/crosschain/BridgeERC1155.test.js @@ -0,0 +1,47 @@ +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 }); +}); 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(); +}); From 20d1fb10b2ada23cff2433e33f7c64e5f1dc026e Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Tue, 13 Jan 2026 18:17:34 +0100 Subject: [PATCH 03/12] add changeset --- .changeset/blue-jars-lay.md | 5 +++++ .changeset/sweet-houses-cheer.md | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 .changeset/blue-jars-lay.md create mode 100644 .changeset/sweet-houses-cheer.md 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..5a41c844abb --- /dev/null +++ b/.changeset/sweet-houses-cheer.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`BridgeERC1155Core` and `BridgeERC1155`: Added bridge contracts to handle crosschain movements of ERC-1155 tokens. From 333ca55b3c252c5d819ebe450f843f372710d4aa Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Tue, 13 Jan 2026 18:21:42 +0100 Subject: [PATCH 04/12] documentation --- contracts/crosschain/README.adoc | 8 +++++++- contracts/token/ERC1155/README.adoc | 7 +++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/contracts/crosschain/README.adoc b/contracts/crosschain/README.adoc index 4f79f53eaff..e18f685db42 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. +* {BridgeERC1155Core}: 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}} + +{{BridgeERC1155Core}} + +{{BridgeERC1155}} 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}} From 549e17925c8d774393a7fd1783abed4440ea220a Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Tue, 13 Jan 2026 18:25:37 +0100 Subject: [PATCH 05/12] update --- contracts/crosschain/bridges/BridgeERC1155.sol | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/contracts/crosschain/bridges/BridgeERC1155.sol b/contracts/crosschain/bridges/BridgeERC1155.sol index 1d3472d94f3..a46bd967ff9 100644 --- a/contracts/crosschain/bridges/BridgeERC1155.sol +++ b/contracts/crosschain/bridges/BridgeERC1155.sol @@ -68,12 +68,7 @@ abstract contract BridgeERC1155 is BridgeERC1155Core, ERC1155Holder { token().safeBatchTransferFrom(from, address(this), ids, values, ""); } - /** - * @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[] memory ids, uint256[] memory values) internal virtual override { token().safeBatchTransferFrom(address(this), to, ids, values, ""); } From 2c7147dde587b6cbd993f763d9b9514bc3fe9e75 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Tue, 13 Jan 2026 18:26:50 +0100 Subject: [PATCH 06/12] update --- contracts/crosschain/bridges/BridgeERC1155.sol | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/contracts/crosschain/bridges/BridgeERC1155.sol b/contracts/crosschain/bridges/BridgeERC1155.sol index a46bd967ff9..95f89cad4db 100644 --- a/contracts/crosschain/bridges/BridgeERC1155.sol +++ b/contracts/crosschain/bridges/BridgeERC1155.sol @@ -73,11 +73,7 @@ abstract contract BridgeERC1155 is BridgeERC1155Core, ERC1155Holder { token().safeBatchTransferFrom(address(this), to, ids, values, ""); } - /** - * @dev Transfer a token received using an ERC-1155 safeTransferFrom - * - * Note: The `data` must contain the `to` as a full InteroperableAddress (chain ref + address). - */ + /// @dev Support receiving tokens only if the transfer was initiated by the bridge itself. function onERC1155Received( address operator, address /* from */, @@ -91,11 +87,7 @@ abstract contract BridgeERC1155 is BridgeERC1155Core, ERC1155Holder { : bytes4(0xffffffff); } - /** - * @dev Transfer a batch of tokens received using an ERC-1155 safeBatchTransferFrom - * - * Note: The `data` must contain the `to` as a full InteroperableAddress (chain ref + address). - */ + /// @dev Support receiving tokens only if the transfer was initiated by the bridge itself. function onERC1155BatchReceived( address operator, address /* from */, From 8c64a33ac2c3fa612fd48a5862b175b830e98132 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 29 Jan 2026 17:32:48 +0100 Subject: [PATCH 07/12] Update contracts/crosschain/bridges/BridgeERC1155.sol --- contracts/crosschain/bridges/BridgeERC1155.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/crosschain/bridges/BridgeERC1155.sol b/contracts/crosschain/bridges/BridgeERC1155.sol index 95f89cad4db..c5f69018de3 100644 --- a/contracts/crosschain/bridges/BridgeERC1155.sol +++ b/contracts/crosschain/bridges/BridgeERC1155.sol @@ -98,6 +98,6 @@ abstract contract BridgeERC1155 is BridgeERC1155Core, ERC1155Holder { return msg.sender == address(_token) && operator == address(this) ? IERC1155Receiver.onERC1155BatchReceived.selector - : bytes4(0xffffffff); + : bytes4(0); } } From c5adcee34e74ca3b89fb37d853840cd7201fc7d3 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 29 Jan 2026 17:58:14 +0100 Subject: [PATCH 08/12] update --- contracts/crosschain/bridges/BridgeERC1155.sol | 2 +- contracts/crosschain/bridges/BridgeERC1155Core.sol | 5 +++++ contracts/crosschain/bridges/BridgeERC20Core.sol | 2 ++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/contracts/crosschain/bridges/BridgeERC1155.sol b/contracts/crosschain/bridges/BridgeERC1155.sol index c5f69018de3..505385a9244 100644 --- a/contracts/crosschain/bridges/BridgeERC1155.sol +++ b/contracts/crosschain/bridges/BridgeERC1155.sol @@ -84,7 +84,7 @@ abstract contract BridgeERC1155 is BridgeERC1155Core, ERC1155Holder { return msg.sender == address(_token) && operator == address(this) ? IERC1155Receiver.onERC1155Received.selector - : bytes4(0xffffffff); + : bytes4(0); } /// @dev Support receiving tokens only if the transfer was initiated by the bridge itself. diff --git a/contracts/crosschain/bridges/BridgeERC1155Core.sol b/contracts/crosschain/bridges/BridgeERC1155Core.sol index b2759dedfdd..cd71dcf60da 100644 --- a/contracts/crosschain/bridges/BridgeERC1155Core.sol +++ b/contracts/crosschain/bridges/BridgeERC1155Core.sol @@ -16,6 +16,9 @@ import {CrosschainLinked} from "../CrosschainLinked.sol"; * * 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 BridgeERC1155Core is Context, CrosschainLinked { using InteroperableAddress for bytes; @@ -67,6 +70,8 @@ abstract contract BridgeERC1155Core 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[] memory ids, uint256[] memory values) = abi.decode( payload, diff --git a/contracts/crosschain/bridges/BridgeERC20Core.sol b/contracts/crosschain/bridges/BridgeERC20Core.sol index b1667a4495c..faed77184ac 100644 --- a/contracts/crosschain/bridges/BridgeERC20Core.sol +++ b/contracts/crosschain/bridges/BridgeERC20Core.sol @@ -62,6 +62,8 @@ abstract contract BridgeERC20Core 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)); From ff273ece3905d43d8e3eb628950716e1bed5ddcd Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Mon, 2 Feb 2026 14:00:01 +0100 Subject: [PATCH 09/12] Update BridgeERC1155Core.sol --- contracts/crosschain/bridges/BridgeERC1155Core.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/crosschain/bridges/BridgeERC1155Core.sol b/contracts/crosschain/bridges/BridgeERC1155Core.sol index cd71dcf60da..2bb676d9c6c 100644 --- a/contracts/crosschain/bridges/BridgeERC1155Core.sol +++ b/contracts/crosschain/bridges/BridgeERC1155Core.sol @@ -73,11 +73,11 @@ abstract contract BridgeERC1155Core 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[] memory ids, uint256[] memory values) = abi.decode( + (bytes memory from, bytes memory toEvm, uint256[] memory ids, uint256[] memory values) = abi.decode( payload, (bytes, bytes, uint256[], uint256[]) ); - address to = address(bytes20(toBinary)); + address to = address(bytes20(toEvm)); _onReceive(to, ids, values); From 59307fd00a33e06cd86b7ab13446eebc7cb964e2 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Tue, 3 Feb 2026 17:46:24 +0100 Subject: [PATCH 10/12] extended coverage --- test/crosschain/BridgeERC1155.behavior.js | 229 +++++++++++++++------- test/crosschain/BridgeERC1155.test.js | 23 +++ 2 files changed, 178 insertions(+), 74 deletions(-) diff --git a/test/crosschain/BridgeERC1155.behavior.js b/test/crosschain/BridgeERC1155.behavior.js index 861807dbc32..8f372513cbf 100644 --- a/test/crosschain/BridgeERC1155.behavior.js +++ b/test/crosschain/BridgeERC1155.behavior.js @@ -27,83 +27,164 @@ function shouldBehaveLikeBridgeERC1155({ chainAIsCustodial = false, chainBIsCust ]); }); - it('crosschain send (both direction)', 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, + 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], + ), ) - // crosschain transfer sent - .to.emit(this.bridgeA, 'CrosschainERC1155TransferSent') - .withArgs(anyValue, alice, this.chain.toErc7930(bruce), ids, values) - // ERC-7786 event - .to.emit(this.gateway, 'MessageSent') - // crosschain transfer received - .to.emit(this.bridgeB, 'CrosschainERC1155TransferReceived') - .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, - ); + // 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, 'CrosschainERC1155TransferSent') + .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, 'CrosschainERC1155TransferReceived') + .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, 'CrosschainERC1155TransferSent') + .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, 'CrosschainERC1155TransferReceived') + .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, 'CrosschainERC1155TransferSent') + .withArgs(anyValue, alice, this.chain.toErc7930(bruce), ids, values) + // ERC-7786 event + .to.emit(this.gateway, 'MessageSent') + // crosschain transfer received + .to.emit(this.bridgeB, 'CrosschainERC1155TransferReceived') + .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[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], + // 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, + ), ) - // crosschain transfer sent - .to.emit(this.bridgeB, 'CrosschainERC1155TransferSent') - .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, 'CrosschainERC1155TransferReceived') - .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], - ); + // 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, 'CrosschainERC1155TransferSent') + .withArgs(anyValue, bruce, this.chain.toErc7930(chris), ids, values) + // ERC-7786 event + .to.emit(this.gateway, 'MessageSent') + // crosschain transfer received + .to.emit(this.bridgeA, 'CrosschainERC1155TransferReceived') + .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 () { diff --git a/test/crosschain/BridgeERC1155.test.js b/test/crosschain/BridgeERC1155.test.js index fa4c90a148c..03585a0308c 100644 --- a/test/crosschain/BridgeERC1155.test.js +++ b/test/crosschain/BridgeERC1155.test.js @@ -44,4 +44,27 @@ describe('CrosschainBridgeERC1155', function () { }); 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); + }); + }); }); From 4de449e10540276acc395875df80556512ef03f5 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 5 Feb 2026 16:28:06 +0100 Subject: [PATCH 11/12] Apply suggestion from @Amxx --- contracts/crosschain/bridges/BridgeERC1155.sol | 2 -- 1 file changed, 2 deletions(-) diff --git a/contracts/crosschain/bridges/BridgeERC1155.sol b/contracts/crosschain/bridges/BridgeERC1155.sol index 505385a9244..766d78c7021 100644 --- a/contracts/crosschain/bridges/BridgeERC1155.sol +++ b/contracts/crosschain/bridges/BridgeERC1155.sol @@ -16,8 +16,6 @@ import {BridgeERC1155Core} from "./BridgeERC1155Core.sol"; abstract contract BridgeERC1155 is BridgeERC1155Core, ERC1155Holder { IERC1155 private immutable _token; - error BridgeERC1155Unauthorized(address caller); - constructor(IERC1155 token_) { _token = token_; } From f1049d70e8cec4530804239cb107105ceb4163ba Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Sat, 7 Feb 2026 15:58:58 +0100 Subject: [PATCH 12/12] rename / reorganise following the ERC20 changes --- .changeset/sweet-houses-cheer.md | 2 +- contracts/crosschain/README.adoc | 4 ++-- .../crosschain/bridges/BridgeERC1155.sol | 6 ++--- .../BridgeMultiToken.sol} | 18 +++++++-------- .../ERC1155/extensions/ERC1155Crosschain.sol | 6 ++--- test/crosschain/BridgeERC1155.behavior.js | 22 +++++++++---------- 6 files changed, 29 insertions(+), 29 deletions(-) rename contracts/crosschain/bridges/{BridgeERC1155Core.sol => abstract/BridgeMultiToken.sol} (84%) diff --git a/.changeset/sweet-houses-cheer.md b/.changeset/sweet-houses-cheer.md index 5a41c844abb..968c2c026b2 100644 --- a/.changeset/sweet-houses-cheer.md +++ b/.changeset/sweet-houses-cheer.md @@ -2,4 +2,4 @@ 'openzeppelin-solidity': minor --- -`BridgeERC1155Core` and `BridgeERC1155`: Added bridge contracts to handle crosschain movements of ERC-1155 tokens. +`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 3ac51614e48..5ed313ca0e8 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. -* {BridgeERC1155Core}: Core bridging logic for crosschain ERC-1155 transfer. Used by {BridgeERC1155} and {ERC1155Crosschain}, +* {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 @@ -30,6 +30,6 @@ Additionally there are multiple bridge constructions: {{BridgeERC7802}} -{{BridgeERC1155Core}} +{{BridgeMultiToken}} {{BridgeERC1155}} diff --git a/contracts/crosschain/bridges/BridgeERC1155.sol b/contracts/crosschain/bridges/BridgeERC1155.sol index 766d78c7021..10060579777 100644 --- a/contracts/crosschain/bridges/BridgeERC1155.sol +++ b/contracts/crosschain/bridges/BridgeERC1155.sol @@ -6,14 +6,14 @@ 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 {BridgeERC1155Core} from "./BridgeERC1155Core.sol"; +import {BridgeMultiToken} from "./abstract/BridgeMultiToken.sol"; /** - * @dev This is a variant of {BridgeERC1155Core} that implements the bridge logic for ERC-1155 tokens that do not expose + * @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 BridgeERC1155Core, ERC1155Holder { +abstract contract BridgeERC1155 is BridgeMultiToken, ERC1155Holder { IERC1155 private immutable _token; constructor(IERC1155 token_) { diff --git a/contracts/crosschain/bridges/BridgeERC1155Core.sol b/contracts/crosschain/bridges/abstract/BridgeMultiToken.sol similarity index 84% rename from contracts/crosschain/bridges/BridgeERC1155Core.sol rename to contracts/crosschain/bridges/abstract/BridgeMultiToken.sol index 2bb676d9c6c..1e322f36d69 100644 --- a/contracts/crosschain/bridges/BridgeERC1155Core.sol +++ b/contracts/crosschain/bridges/abstract/BridgeMultiToken.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-1155 between chains using an ERC-7786 gateway. @@ -20,17 +20,17 @@ import {CrosschainLinked} from "../CrosschainLinked.sol"; * 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 BridgeERC1155Core is Context, CrosschainLinked { +abstract contract BridgeMultiToken is Context, CrosschainLinked { using InteroperableAddress for bytes; - event CrosschainERC1155TransferSent( + event CrosschainMultiTokenTransferSent( bytes32 indexed sendId, address indexed from, bytes to, uint256[] ids, uint256[] values ); - event CrosschainERC1155TransferReceived( + event CrosschainMultiTokenTransferReceived( bytes32 indexed receiveId, bytes from, address indexed to, @@ -59,7 +59,7 @@ abstract contract BridgeERC1155Core is Context, CrosschainLinked { new bytes[](0) ); - emit CrosschainERC1155TransferSent(sendId, from, to, ids, values); + emit CrosschainMultiTokenTransferSent(sendId, from, to, ids, values); return sendId; } @@ -81,7 +81,7 @@ abstract contract BridgeERC1155Core is Context, CrosschainLinked { _onReceive(to, ids, values); - emit CrosschainERC1155TransferReceived(receiveId, from, 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. diff --git a/contracts/token/ERC1155/extensions/ERC1155Crosschain.sol b/contracts/token/ERC1155/extensions/ERC1155Crosschain.sol index 1f855c74ef1..ebc3192c730 100644 --- a/contracts/token/ERC1155/extensions/ERC1155Crosschain.sol +++ b/contracts/token/ERC1155/extensions/ERC1155Crosschain.sol @@ -3,17 +3,17 @@ pragma solidity ^0.8.26; import {ERC1155} from "../ERC1155.sol"; -import {BridgeERC1155Core} from "../../../crosschain/bridges/BridgeERC1155Core.sol"; +import {BridgeMultiToken} from "../../../crosschain/bridges/abstract/BridgeMultiToken.sol"; /** - * @dev Extension of {ERC1155} that makes it natively cross-chain using the ERC-7786 based {BridgeERC1155Core}. + * @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, BridgeERC1155Core { +abstract contract ERC1155Crosschain is ERC1155, BridgeMultiToken { /// @dev TransferFrom variant of {crosschainTransferFrom}, using ERC1155 allowance from the sender to the caller. function crosschainTransferFrom( address from, diff --git a/test/crosschain/BridgeERC1155.behavior.js b/test/crosschain/BridgeERC1155.behavior.js index 8f372513cbf..909eac7bce9 100644 --- a/test/crosschain/BridgeERC1155.behavior.js +++ b/test/crosschain/BridgeERC1155.behavior.js @@ -53,12 +53,12 @@ function shouldBehaveLikeBridgeERC1155({ chainAIsCustodial = false, chainBIsCust values[0], ) // crosschain transfer sent - .to.emit(this.bridgeA, 'CrosschainERC1155TransferSent') + .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, 'CrosschainERC1155TransferReceived') + .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') @@ -89,12 +89,12 @@ function shouldBehaveLikeBridgeERC1155({ chainAIsCustodial = false, chainBIsCust values[0], ) // crosschain transfer sent - .to.emit(this.bridgeB, 'CrosschainERC1155TransferSent') + .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, 'CrosschainERC1155TransferReceived') + .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') @@ -132,12 +132,12 @@ function shouldBehaveLikeBridgeERC1155({ chainAIsCustodial = false, chainBIsCust values, ) // crosschain transfer sent - .to.emit(this.bridgeA, 'CrosschainERC1155TransferSent') + .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, 'CrosschainERC1155TransferReceived') + .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') @@ -168,12 +168,12 @@ function shouldBehaveLikeBridgeERC1155({ chainAIsCustodial = false, chainBIsCust values, ) // crosschain transfer sent - .to.emit(this.bridgeB, 'CrosschainERC1155TransferSent') + .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, 'CrosschainERC1155TransferReceived') + .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') @@ -202,7 +202,7 @@ function shouldBehaveLikeBridgeERC1155({ chainAIsCustodial = false, chainBIsCust values, ), ) - .to.emit(this.bridgeA, 'CrosschainERC1155TransferSent') + .to.emit(this.bridgeA, 'CrosschainMultiTokenTransferSent') .withArgs(anyValue, alice, this.chain.toErc7930(bruce), ids, values); }); @@ -221,7 +221,7 @@ function shouldBehaveLikeBridgeERC1155({ chainAIsCustodial = false, chainBIsCust values, ), ) - .to.emit(this.bridgeA, 'CrosschainERC1155TransferSent') + .to.emit(this.bridgeA, 'CrosschainMultiTokenTransferSent') .withArgs(anyValue, alice, this.chain.toErc7930(bruce), ids, values); }); }); @@ -309,7 +309,7 @@ function shouldBehaveLikeBridgeERC1155({ chainAIsCustodial = false, chainBIsCust this.bridgeA .connect(this.gatewayAsEOA) .receiveMessage(receiveId, this.chain.toErc7930(this.bridgeB), payload), - ).to.emit(this.bridgeA, 'CrosschainERC1155TransferReceived'); + ).to.emit(this.bridgeA, 'CrosschainMultiTokenTransferReceived'); // second time fails await expect(