diff --git a/contracts/crosschain/bridges/BridgeCore.sol b/contracts/crosschain/bridges/BridgeCore.sol new file mode 100644 index 00000000000..ea91c8a81e7 --- /dev/null +++ b/contracts/crosschain/bridges/BridgeCore.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {IERC7786GatewaySource} from "../../interfaces/draft-IERC7786.sol"; +import {InteroperableAddress} from "../../utils/draft-InteroperableAddress.sol"; +import {Bytes} from "../../utils/Bytes.sol"; +import {ERC7786Recipient} from "../ERC7786Recipient.sol"; + +/** + * @dev Core bridging mechanism. + * + * This contract contains the logic to register and send messages to counterparts on remote chains using ERC-7786 + * gateways. It ensure received messages originate from a counterpart. This is the base of token bridges such as``` + * {BridgeERC20}. + * + * Contract that inherit from this contract can use the internal {_sendMessage} to send messages to their counterpart``` + * on a foreign chain. They must override the {_processMessage} function to handle the message that have been verified. + */ +abstract contract BridgeCore is ERC7786Recipient { + using Bytes for bytes; + using InteroperableAddress for bytes; + + struct Link { + address gateway; + bytes remote; + } + mapping(bytes chain => Link) private _links; + + event RemoteRegistered(address gateway, bytes remote); + + error RemoteAlreadyRegistered(bytes chain); + + constructor(Link[] memory links) { + for (uint256 i = 0; i < links.length; ++i) { + _setLink(links[i].gateway, links[i].remote, false); + } + } + + /// @dev Returns the ERC-7786 gateway used for sending and receiving cross-chain messages to a given chain + function link(bytes memory chain) public view virtual returns (address gateway, bytes memory remote) { + Link storage self = _links[chain]; + return (self.gateway, self.remote); + } + + /// @dev Internal setter to change the ERC-7786 gateway and remote for a given chain. Called at construction. + function _setLink(address gateway, bytes memory remote, bool allowOverride) internal virtual { + // Sanity check, this should revert if gateway is not an ERC-7786 implementation. Note that since + // supportsAttribute returns data, an EOA would fail that test (nothing returned). + IERC7786GatewaySource(gateway).supportsAttribute(bytes4(0)); + + bytes memory chain = _extractChain(remote); + if (allowOverride || _links[chain].gateway == address(0)) { + _links[chain] = Link(gateway, remote); + emit RemoteRegistered(gateway, remote); + } else { + revert RemoteAlreadyRegistered(chain); + } + } + + /// @dev Internal messaging function. + function _sendMessage( + bytes memory chain, + bytes memory payload, + bytes[] memory attributes + ) internal virtual returns (bytes32) { + (address gateway, bytes memory remote) = link(chain); + return IERC7786GatewaySource(gateway).sendMessage(remote, payload, attributes); + } + + /// @inheritdoc ERC7786Recipient + function _isAuthorizedGateway( + address instance, + bytes calldata sender + ) internal view virtual override returns (bool) { + (address gateway, bytes memory router) = link(_extractChain(sender)); + return instance == gateway && sender.equal(router); + } + + function _extractChain(bytes memory self) private pure returns (bytes memory) { + (bytes2 chainType, bytes memory chainReference, ) = self.parseV1(); + return InteroperableAddress.formatV1(chainType, chainReference, hex""); + } +} diff --git a/contracts/crosschain/bridges/BridgeERC20.sol b/contracts/crosschain/bridges/BridgeERC20.sol new file mode 100644 index 00000000000..e3b754f745b --- /dev/null +++ b/contracts/crosschain/bridges/BridgeERC20.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {InteroperableAddress} from "../../utils/draft-InteroperableAddress.sol"; +import {ERC7786Recipient} from "../ERC7786Recipient.sol"; +import {BridgeCore} from "./BridgeCore.sol"; + +/** + * @dev Base contract for bridging ERC-20 between chains using an ERC-7786 gateway. + * + * In order to use this contract, two function must be implemented to link it to the token: + * * {lock}: called when a crosschain transfer is going out. Must take the sender tokens or revert. + * * {unlock}: called when a crosschain transfer is coming it. Must give tokens to the receiver. + * + * This base contract is used by the {BridgeERC20Custodial}, which interfaces with legacy ERC-20 tokens, and + * {BridgeERC20Bridgeable}, which interface with ERC-7802 to provide an approve-free user experience. It is also used``` + * by the {ERC20Crosschain} extension, which embeds the bridge logic directly in the token contract. + */ +abstract contract BridgeERC20 is BridgeCore { + using InteroperableAddress for bytes; + + event CrossChainTransferSent(bytes32 indexed sendId, address indexed from, bytes to, uint256 amount); + event CrossChainTransferReceived(bytes32 indexed receiveId, bytes from, address indexed to, uint256 amount); + + /// @dev Transfer `amount` tokens to a crosschain receiver. + function crosschainTransfer(bytes memory to, uint256 amount) public virtual returns (bytes32) { + return _crosschainTransfer(msg.sender, to, amount); + } + + /// @dev Internal crosschain transfer function. + function _crosschainTransfer(address from, bytes memory to, uint256 amount) internal virtual returns (bytes32) { + _lock(from, amount); + + (bytes2 chainType, bytes memory chainReference, bytes memory addr) = to.parseV1(); + bytes memory chain = InteroperableAddress.formatV1(chainType, chainReference, hex""); + + bytes32 sendId = _sendMessage( + chain, + abi.encode(InteroperableAddress.formatEvmV1(block.chainid, from), addr, amount), + new bytes[](0) + ); + + emit CrossChainTransferSent(sendId, from, to, amount); + + 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 amount) = abi.decode(payload, (bytes, bytes, uint256)); + address to = address(bytes20(toBinary)); + + _unlock(to, amount); + + emit CrossChainTransferReceived(receiveId, from, to, amount); + } + + /// @dev Virtual function: implementation is required to handle token being burnt or locked on the source chain. + function _lock(address from, uint256 amount) internal virtual; + + /// @dev Virtual function: implementation is required to handle token being minted or unlocked on the destination chain. + function _unlock(address to, uint256 amount) internal virtual; +} diff --git a/contracts/crosschain/bridges/BridgeERC20Bridgeable.sol b/contracts/crosschain/bridges/BridgeERC20Bridgeable.sol new file mode 100644 index 00000000000..4d593ae2c97 --- /dev/null +++ b/contracts/crosschain/bridges/BridgeERC20Bridgeable.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {IERC7802} from "../../interfaces/draft-IERC7802.sol"; +import {BridgeERC20} from "./BridgeERC20.sol"; + +/** + * @dev This is a variant of {BridgeERC20} that implements the bridge logic for ERC-7802 compliant tokens. + */ +abstract contract BridgeERC20Bridgeable is BridgeERC20 { + IERC7802 private immutable _token; + + constructor(IERC7802 token_) { + _token = token_; + } + + /// @dev Return the address of the ERC20 token this bridge operates on. + function token() public view virtual returns (IERC7802) { + return _token; + } + + /// @dev "Locking" tokens using an ERC-7802 crosschain burn + function _lock(address from, uint256 amount) internal virtual override { + token().crosschainBurn(from, amount); + } + + /// @dev "Unlocking" tokens using an ERC-7802 crosschain mint + function _unlock(address to, uint256 amount) internal virtual override { + token().crosschainMint(to, amount); + } +} diff --git a/contracts/crosschain/bridges/BridgeERC20Custodial.sol b/contracts/crosschain/bridges/BridgeERC20Custodial.sol new file mode 100644 index 00000000000..e53361b9d34 --- /dev/null +++ b/contracts/crosschain/bridges/BridgeERC20Custodial.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {IERC20, SafeERC20} from "../../token/ERC20/utils/SafeERC20.sol"; +import {BridgeERC20} from "./BridgeERC20.sol"; + +/** + * @dev This is a variant of {BridgeERC20} that implements the bridge logic for ERC-20 tokens that do not expose mint + * and burn mechanism. Instead it takes custody of bridged assets. + */ +abstract contract BridgeERC20Custodial is BridgeERC20 { + using SafeERC20 for IERC20; + + IERC20 private immutable _token; + + constructor(IERC20 token_) { + _token = token_; + } + + /// @dev Return the address of the ERC20 token this bridge operates on. + function token() public view virtual returns (IERC20) { + return _token; + } + + /// @dev "Locking" tokens is done by taking custody + function _lock(address from, uint256 amount) internal virtual override { + token().safeTransferFrom(from, address(this), amount); + } + + /// @dev "Unlocking" tokens is done by releasing custody + function _unlock(address to, uint256 amount) internal virtual override { + token().safeTransfer(to, amount); + } +} diff --git a/contracts/mocks/token/ERC20BridgeableMock.sol b/contracts/mocks/token/ERC20BridgeableMock.sol index ef99f2e9d80..38abbd2cef6 100644 --- a/contracts/mocks/token/ERC20BridgeableMock.sol +++ b/contracts/mocks/token/ERC20BridgeableMock.sol @@ -10,7 +10,11 @@ abstract contract ERC20BridgeableMock is ERC20Bridgeable { error OnlyTokenBridge(); event OnlyTokenBridgeFnCalled(address caller); - constructor(address bridge) { + constructor(address initialBridge) { + _setBridge(initialBridge); + } + + function _setBridge(address bridge) internal { _bridge = bridge; } diff --git a/contracts/token/ERC20/extensions/ERC20Crosschain.sol b/contracts/token/ERC20/extensions/ERC20Crosschain.sol new file mode 100644 index 00000000000..1984c542f4a --- /dev/null +++ b/contracts/token/ERC20/extensions/ERC20Crosschain.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {ERC20} from "../ERC20.sol"; +import {BridgeERC20} from "../../../crosschain/bridges/BridgeERC20.sol"; + +abstract contract ERC20Crosschain is ERC20, BridgeERC20 { + /// @dev TransferFrom variant of {crosschainTransferFrom}, using ERC20 allowance from the sender to the caller. + function crosschainTransferFrom(address from, bytes memory to, uint256 amount) public virtual returns (bytes32) { + _spendAllowance(from, msg.sender, amount); + return _crosschainTransfer(from, to, amount); + } + + /// @dev "Locking" tokens is achieved through burning + function _lock(address from, uint256 amount) internal virtual override { + _burn(from, amount); + } + + /// @dev "Unlocking" tokens is achieved through minting + function _unlock(address to, uint256 amount) internal virtual override { + _mint(to, amount); + } +} diff --git a/test/crosschain/BridgeERC20.behavior.js b/test/crosschain/BridgeERC20.behavior.js new file mode 100644 index 00000000000..372bc442c5b --- /dev/null +++ b/test/crosschain/BridgeERC20.behavior.js @@ -0,0 +1,160 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +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.link(this.chain.erc7930)).to.eventually.deep.equal([ + this.gateway.target, + this.chain.toErc7930(this.bridgeB), + ]); + await expect(this.bridgeB.link(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, 'CrossChainTransferSent') + .withArgs(anyValue, alice, this.chain.toErc7930(bruce), amount) + // ERC-7786 event + .to.emit(this.gateway, 'MessageSent') + // crosschain transfer received + .to.emit(this.bridgeB, 'CrossChainTransferReceived') + .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, 'CrossChainTransferSent') + .withArgs(anyValue, bruce, this.chain.toErc7930(chris), amount) + // ERC-7786 event + .to.emit(this.gateway, 'MessageSent') + // crosschain transfer received + .to.emit(this.bridgeA, 'CrossChainTransferReceived') + .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); + }); + + 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 remote can send a crosschain message', async function () { + const [notRemote] = this.accounts; + + await expect( + this.gateway + .connect(notRemote) + .sendMessage(this.chain.toErc7930(this.bridgeA), this.encodePayload(notRemote, notRemote, amount), []), + ) + .to.be.revertedWithCustomError(this.bridgeA, 'ERC7786RecipientUnauthorizedGateway') + .withArgs(this.gateway, this.chain.toErc7930(notRemote)); + }); + + it('cannot replay message', async function () { + const [from, to] = this.accounts; + + const id = ethers.ZeroHash; + const payload = this.encodePayload(from, to, amount); + + // first time works + await expect( + this.bridgeA.connect(this.gatewayAsEOA).receiveMessage(id, this.chain.toErc7930(this.bridgeB), payload), + ).to.emit(this.bridgeA, 'CrossChainTransferReceived'); + + // second time fails + await expect( + this.bridgeA.connect(this.gatewayAsEOA).receiveMessage(id, this.chain.toErc7930(this.bridgeB), payload), + ) + .to.be.revertedWithCustomError(this.bridgeA, 'ERC7786RecipientMessageAlreadyProcessed') + .withArgs(this.gateway, id); + }); + }); + + describe('reconfiguration', function () { + it('updating a link emits an event', async function () { + const newGateway = await ethers.deployContract('$ERC7786GatewayMock'); + const newRemote = this.chain.toErc7930(this.accounts[0]); + + await expect(this.bridgeA.$_setLink(newGateway, newRemote, true)) + .to.emit(this.bridgeA, 'RemoteRegistered') + .withArgs(newGateway, newRemote); + + await expect(this.bridgeA.link(this.chain.erc7930)).to.eventually.deep.equal([newGateway.target, newRemote]); + }); + + it('cannot override configuration is "allowOverride" is false', async function () { + const newGateway = await ethers.deployContract('$ERC7786GatewayMock'); + const newRemote = this.chain.toErc7930(this.accounts[0]); + + await expect(this.bridgeA.$_setLink(newGateway, newRemote, false)) + .to.be.revertedWithCustomError(this.bridgeA, 'RemoteAlreadyRegistered') + .withArgs(this.chain.erc7930); + }); + + it('reject invalid gateway', async function () { + const notAGateway = this.accounts[0]; + const newRemote = this.chain.toErc7930(this.accounts[0]); + + await expect(this.bridgeA.$_setLink(notAGateway, newRemote, false)).to.be.revertedWithoutReason(); + }); + }); +} + +module.exports = { + shouldBehaveLikeBridgeERC20, +}; diff --git a/test/crosschain/BridgeERC20.test.js b/test/crosschain/BridgeERC20.test.js new file mode 100644 index 00000000000..173dba8beca --- /dev/null +++ b/test/crosschain/BridgeERC20.test.js @@ -0,0 +1,49 @@ +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 { shouldBehaveLikeBridgeERC20 } = require('./BridgeERC20.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 ERC20 with bridge + const tokenA = await ethers.deployContract('$ERC20', ['Token1', 'T1']); + const bridgeA = await ethers.deployContract('$BridgeERC20Custodial', [[], tokenA]); + + // Chain B: ERC7802 with bridge + const tokenB = await ethers.deployContract('$ERC20BridgeableMock', ['Token2', 'T2', ethers.ZeroAddress]); + const bridgeB = await ethers.deployContract('$BridgeERC20Bridgeable', [[], tokenB]); + + // deployment check + remote setup + await expect(bridgeA.$_setLink(gateway, chain.toErc7930(bridgeB), false)) + .to.emit(bridgeA, 'RemoteRegistered') + .withArgs(gateway, chain.toErc7930(bridgeB)); + await expect(bridgeB.$_setLink(gateway, chain.toErc7930(bridgeA), false)) + .to.emit(bridgeB, 'RemoteRegistered') + .withArgs(gateway, chain.toErc7930(bridgeA)); + await tokenB.$_setBridge(bridgeB); + + return { chain, accounts, gateway, gatewayAsEOA, tokenA, tokenB, bridgeA, bridgeB }; +} + +describe('CrosschainBridgeERC20', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + it('token getters', async function () { + await expect(this.bridgeA.token()).to.eventually.equal(this.tokenA); + await expect(this.bridgeB.token()).to.eventually.equal(this.tokenB); + }); + + shouldBehaveLikeBridgeERC20({ chainAIsCustodial: true }); +}); diff --git a/test/helpers/account.js b/test/helpers/account.js index 96874b16b75..f3753098586 100644 --- a/test/helpers/account.js +++ b/test/helpers/account.js @@ -4,10 +4,12 @@ const { impersonateAccount, setBalance } = require('@nomicfoundation/hardhat-net // Hardhat default balance const DEFAULT_BALANCE = 10000n * ethers.WeiPerEther; -const impersonate = (account, balance = DEFAULT_BALANCE) => - impersonateAccount(account) - .then(() => setBalance(account, balance)) - .then(() => ethers.getSigner(account)); +const impersonate = (account, balance = DEFAULT_BALANCE) => { + const address = account.target ?? account.address ?? account; + return impersonateAccount(address) + .then(() => setBalance(address, balance)) + .then(() => ethers.getSigner(address)); +}; module.exports = { impersonate, diff --git a/test/token/ERC20/extensions/ERC20Crosschain.test.js b/test/token/ERC20/extensions/ERC20Crosschain.test.js new file mode 100644 index 00000000000..0bc06349821 --- /dev/null +++ b/test/token/ERC20/extensions/ERC20Crosschain.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 { shouldBehaveLikeBridgeERC20 } = require('../../../crosschain/BridgeERC20.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 ERC20 with bridge + const tokenA = await ethers.deployContract('$ERC20Crosschain', ['Token1', 'T1', []]); + const bridgeA = tokenA; // self bridge + + // Chain B: ERC7802 with bridge + const tokenB = await ethers.deployContract('$ERC20BridgeableMock', ['Token2', 'T2', ethers.ZeroAddress]); + const bridgeB = await ethers.deployContract('$BridgeERC20Bridgeable', [[], tokenB]); + + // deployment check + remote setup + await expect(bridgeA.$_setLink(gateway, chain.toErc7930(bridgeB), false)) + .to.emit(bridgeA, 'RemoteRegistered') + .withArgs(gateway, chain.toErc7930(bridgeB)); + await expect(bridgeB.$_setLink(gateway, chain.toErc7930(bridgeA), false)) + .to.emit(bridgeB, 'RemoteRegistered') + .withArgs(gateway, chain.toErc7930(bridgeA)); + await tokenB.$_setBridge(bridgeB); + + return { chain, accounts, gateway, gatewayAsEOA, tokenA, tokenB, bridgeA, bridgeB }; +} + +describe('ERC20Crosschain', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + shouldBehaveLikeBridgeERC20(); +});