diff --git a/contracts/crosschain/ERC7802Bridge.sol b/contracts/crosschain/ERC7802Bridge.sol new file mode 100644 index 00000000..3ef3b257 --- /dev/null +++ b/contracts/crosschain/ERC7802Bridge.sol @@ -0,0 +1,293 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +// Interfaces +import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol"; +import {IERC7802} from "@openzeppelin/contracts/interfaces/draft-IERC7802.sol"; +import {IERC7786GatewaySource, IERC7786Receiver} from "../interfaces/IERC7786.sol"; + +// Utilities +import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import {Arrays} from "@openzeppelin/contracts/utils/Arrays.sol"; +import {BitMaps} from "@openzeppelin/contracts/utils/structs/BitMaps.sol"; +import {Bytes} from "@openzeppelin/contracts/utils/Bytes.sol"; +import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import {InteroperableAddress} from "@openzeppelin/contracts/utils/draft-InteroperableAddress.sol"; +import {IndirectCall} from "../utils/IndirectCall.sol"; + +abstract contract ERC7802Bridge is ERC721("ERC7802Bridge", "ERC7802Bridge"), IERC7786Receiver { + using BitMaps for BitMaps.BitMap; + using InteroperableAddress for bytes; + + struct BridgeMetadata { + address token; + bool isPaused; + bool isCustodial; + mapping(bytes chain => address) gateway; + mapping(bytes chain => bytes) remote; + } + + mapping(bytes32 bridgeId => BridgeMetadata) private _bridges; + BitMaps.BitMap private _processed; + + event Sent(address token, address from, bytes to, uint256 amount); + event Received(address token, bytes from, address to, uint256 amount); + event BridgePaused(bytes32 indexed bridgeId, bool isPaused); + event BridgeLinkSet(bytes32 indexed bridgeId, address gateway, bytes remote); + + error ERC7802BridgePaused(bytes32 bridgeId); + error ERC7802BridgeInvalidBidgeId(bytes32 bridgeId); + error ERC7802BridgeMissingGateway(bytes32 bridgeId, bytes chain); + error ERC7802BridgeMissingRemote(bytes32 bridgeId, bytes chain); + error ERC7802BridgeDuplicate(); + error ERC7802BridgeInvalidGateway(); + error ERC7802BridgeInvalidSender(); + + modifier bridgeAdminRestricted(bytes32 bridgeId) { + _checkAuthorized(ownerOf(uint256(bridgeId)), msg.sender, uint256(bridgeId)); + _; + } + + // ┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ + // │ Getters │ + // └─────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ + function getBridgeEndpoint(bytes32 bridgeId) public returns (address) { + return IndirectCall.getRelayer(bridgeId); + } + + function getBridgeToken(bytes32 bridgeId) public view returns (address token, bool isCustodial) { + _requireOwned(uint256(bridgeId)); + return (_bridges[bridgeId].token, _bridges[bridgeId].isCustodial); + } + + function getBridgeGateway(bytes32 bridgeId, bytes memory chain) public view returns (address) { + _requireOwned(uint256(bridgeId)); + return _bridges[bridgeId].gateway[chain]; + } + + function getBridgeRemote(bytes32 bridgeId, bytes memory chain) public view returns (bytes memory) { + _requireOwned(uint256(bridgeId)); + return _bridges[bridgeId].remote[chain]; + } + + // ┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ + // │ Bridge management │ + // └─────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ + + function setPaused(bytes32 bridgeId, bool isPaused) public bridgeAdminRestricted(bridgeId) { + _setPaused(bridgeId, isPaused); + } + + function updateGateway( + bytes32 bridgeId, + bytes memory chain, + address gateway, + bytes memory remote + ) public virtual bridgeAdminRestricted(bridgeId) { + _setGateway(bridgeId, chain, gateway, remote); + } + + function _setBridge(bytes32 bridgeId, address token, address admin, bool isCustodial) internal { + _safeMint(admin == address(0) ? address(1) : admin, uint256(bridgeId)); + _bridges[bridgeId].token = token; + _bridges[bridgeId].isCustodial = isCustodial; + } + + function _setGateway(bytes32 bridgeId, bytes memory chain, address gateway, bytes memory remote) internal { + _bridges[bridgeId].gateway[chain] = gateway; + _bridges[bridgeId].remote[chain] = remote; + emit BridgeLinkSet(bridgeId, gateway, remote); + } + + function _setPaused(bytes32 bridgeId, bool isPaused) internal { + _bridges[bridgeId].isPaused = isPaused; + emit BridgePaused(bridgeId, isPaused); + } + + // ┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ + // │ Send / Receive tokens │ + // └─────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ + function send( + bytes32 bridgeId, + bytes memory to, + uint256 amount, + bytes[] memory attributes + ) public payable virtual returns (bytes32) { + _requireOwned(uint256(bridgeId)); + + require(!_bridges[bridgeId].isPaused, ERC7802BridgePaused(bridgeId)); + + address token = _fetchTokens(bridgeId, msg.sender, amount); + + // identify destination chain + (bytes2 chainType, bytes memory chainReference, bytes memory recipient) = to.parseV1(); + bytes memory destChain = InteroperableAddress.formatV1(chainType, chainReference, ""); + + // get details for that bridge: gateway, remote bridge, remote token + address gateway = _bridges[bridgeId].gateway[destChain]; + bytes memory bridge = _bridges[bridgeId].remote[destChain]; + + // prepare payload + bytes memory payload = abi.encode( + bridgeId, + InteroperableAddress.formatEvmV1(block.chainid, msg.sender), + recipient, + amount + ); + + // send crosschain signal + bytes32 sendId = IERC7786GatewaySource(gateway).sendMessage{value: msg.value}(bridge, payload, attributes); + emit Sent(token, msg.sender, to, amount); + + return sendId; + } + + function receiveMessage( + bytes32 receiveId, + bytes memory sender, + bytes memory payload + ) public payable virtual returns (bytes4) { + // prevent duplicate + require(!_processed.get(uint256(receiveId)), ERC7802BridgeDuplicate()); + _processed.set(uint256(receiveId)); + + // parse payload + (bytes32 bridgeId, bytes memory from, bytes memory recipient, uint256 amount) = abi.decode( + payload, + (bytes32, bytes, bytes, uint256) + ); + + _requireOwned(uint256(bridgeId)); + + // identify source chain and validate corresponding gateway + (bytes2 chainType, bytes memory chainReference, ) = from.parseV1(); + bytes memory srcChain = InteroperableAddress.formatV1(chainType, chainReference, ""); + + require(msg.sender == _bridges[bridgeId].gateway[srcChain], ERC7802BridgeInvalidGateway()); + require(Bytes.equal(sender, _bridges[bridgeId].remote[srcChain]), ERC7802BridgeInvalidSender()); + + // get recipient + address to = address(bytes20(recipient)); + + // distribute bridged tokens + address token = _distributeTokens(bridgeId, to, amount); + emit Received(token, from, to, amount); + + return IERC7786Receiver.receiveMessage.selector; + } + + function _fetchTokens(bytes32 bridgeId, address from, uint256 amount) private returns (address) { + address token = _bridges[bridgeId].token; + if (_bridges[bridgeId].isCustodial) { + (bool success, bytes memory returndata) = IndirectCall.indirectCall( + token, + abi.encodeCall(IERC20.transferFrom, (from, getBridgeEndpoint(bridgeId), amount)), + bridgeId + ); + require(success && (returndata.length == 0 ? token.code.length == 0 : uint256(bytes32(returndata)) == 1)); + } else { + (bool success, ) = IndirectCall.indirectCall( + token, + abi.encodeCall(IERC7802.crosschainBurn, (from, amount)), + bridgeId + ); + require(success); + } + return token; + } + + function _distributeTokens(bytes32 bridgeId, address to, uint256 amount) private returns (address) { + address token = _bridges[bridgeId].token; + if (_bridges[bridgeId].isCustodial) { + (bool success, bytes memory returndata) = IndirectCall.indirectCall( + token, + abi.encodeCall(IERC20.transfer, (to, amount)), + bridgeId + ); + require(success && (returndata.length == 0 ? token.code.length == 0 : uint256(bytes32(returndata)) == 1)); + } else { + (bool success, ) = IndirectCall.indirectCall( + token, + abi.encodeCall(IERC7802.crosschainMint, (to, amount)), + bridgeId + ); + require(success); + } + return token; + } +} + +contract ERC7802BridgeLinks is ERC7802Bridge { + function createBridge(address token, bool isCustodial, bytes32 salt) public returns (bytes32) { + bytes32 bridgeId = keccak256(abi.encodePacked(msg.sender, salt)); + + _setBridge(bridgeId, token, msg.sender, isCustodial); + + return bridgeId; + } +} + +contract ERC7802BridgeCounterfactual is ERC7802Bridge { + using InteroperableAddress for bytes; + + struct Foreign { + bytes32 id; + address gateway; + bytes remote; + } + + function createBridge( + address token, + address admin, + bool isCustodial, + Foreign[] calldata foreign + ) public returns (bytes32) { + bytes32 bridgeId = _counterfactualBridgeId( + token, + bytes32(bytes20(admin)) | bytes32(SafeCast.toUint(isCustodial)), + foreign + ); + + _setBridge(bridgeId, token, admin, isCustodial); + for (uint256 i = 0; i < foreign.length; ++i) { + (bytes2 chainType, bytes memory chainReference, ) = foreign[i].remote.parseV1Calldata(); + bytes memory chain = InteroperableAddress.formatV1(chainType, chainReference, ""); + _setGateway(bridgeId, chain, foreign[i].gateway, foreign[i].remote); + } + + return bridgeId; + } + + function updateGateway( + bytes32 bridgeId, + bytes memory chain, + address gateway, + bytes memory remote + ) public virtual override { + require(gateway != address(0) && remote.length > 0); + // super call is bridgeAdminRestricted(bridgeId) + super.updateGateway(bridgeId, chain, gateway, remote); + } + + function _counterfactualBridgeId( + address token, + bytes32 opts, + Foreign[] calldata foreign + ) private view returns (bytes32) { + bytes32[] memory ids = new bytes32[](foreign.length + 1); + bytes32[] memory links = new bytes32[](foreign.length); + for (uint256 i = 0; i < foreign.length; ++i) { + require(foreign[i].gateway != address(0) && foreign[i].remote.length > 0); + ids[i] = foreign[i].id; + links[i] = keccak256( + abi.encode(InteroperableAddress.formatEvmV1(block.chainid, foreign[i].gateway), foreign[i].remote) + ); + } + ids[foreign.length] = keccak256( + abi.encode(InteroperableAddress.formatEvmV1(block.chainid, token), opts, Arrays.sort(links)) + ); + + return keccak256(abi.encodePacked(Arrays.sort(ids))); + } +} diff --git a/contracts/mocks/token/ERC20BridgeableMock.sol b/contracts/mocks/token/ERC20BridgeableMock.sol new file mode 100644 index 00000000..6b2f0567 --- /dev/null +++ b/contracts/mocks/token/ERC20BridgeableMock.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; +import {ERC20Bridgeable} from "@openzeppelin/contracts/token/ERC20/extensions/draft-ERC20Bridgeable.sol"; + +abstract contract ERC20BridgeableMock is ERC20Bridgeable, AccessControl { + bytes32 public constant BRIDGE_ROLE = keccak256("BRIDGE"); + + constructor(address admin) { + _grantRole(DEFAULT_ADMIN_ROLE, admin); + } + + function supportsInterface( + bytes4 interfaceId + ) public view virtual override(AccessControl, ERC20Bridgeable) returns (bool) { + return super.supportsInterface(interfaceId); + } + + function _checkTokenBridge(address sender) internal view override onlyRole(BRIDGE_ROLE) {} +} diff --git a/contracts/utils/IndirectCall.sol b/contracts/utils/IndirectCall.sol new file mode 100644 index 00000000..77aaff79 --- /dev/null +++ b/contracts/utils/IndirectCall.sol @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +/** + * @dev Helper contract for performing potentially dangerous calls through a relay the hide the address of the + * original sender. + * + * Some contract are required to perform arbitrary action controlled by user input. This is dangerous if the contract + * has special permissions, or holds assets. In such cases, using a relay contract can be useful to change the + * msg.sender of the outgoing call. This pattern is used in the ERC-4337 entrypoint that relies on a helper called the + * "senderCreator" when calling account factories. Similarly ERC-6942 does factory calls that could be dangerous if + * performed directly. + * + * This contract provides a `indirectCall` that can be used to perform dangerous calls. These calls are indirect + * through a minimal relayer. + */ +library IndirectCall { + function indirectCall(address target, bytes memory data) internal returns (bool, bytes memory) { + return indirectCall(target, 0, data); + } + + function indirectCall(address target, uint256 value, bytes memory data) internal returns (bool, bytes memory) { + return indirectCall(target, value, data, bytes32(0)); + } + + function indirectCall(address target, bytes memory data, bytes32 salt) internal returns (bool, bytes memory) { + return indirectCall(target, 0, data, salt); + } + + function indirectCall( + address target, + uint256 value, + bytes memory data, + bytes32 salt + ) internal returns (bool, bytes memory) { + return getRelayer(salt).call{value: value}(abi.encodePacked(target, data)); + } + + function getRelayer() internal returns (address) { + return getRelayer(bytes32(0)); + } + + function getRelayer(bytes32 salt) internal returns (address relayer) { + // [Relayer details] + // + // deployment prefix: 5f604780600a5f3981f3 + // deployed bytecode: 73331460133611166022575f5ffd5b6014360360145f375f5f601436035f345f3560601c5af13d5f5f3e5f3d91604557fd5bf3 + // + // offset | bytecode | opcode | stack + // -------|-------------|----------------|-------- + // 0x0000 | 73 | push20 | + // 0x0015 | 33 | address | + // 0x0016 | 14 | eq | access + // 0x0017 | 6013 | push1 0x13 | 0x13 access + // 0x0019 | 36 | calldatasize | cds 0x13 access + // 0x001a | 11 | gt | (cds>0x13) access + // 0x001b | 16 | and | (cds>0x13 && access) + // 0x001c | 6022 | push1 0x22 | 0x22 (cds>0x13 && access) + // 0x001e | 57 | jumpi | 0x22 (cds>0x13 && access) + // 0x001f | 5f | push0 | 0 + // 0x0020 | 5f | push0 | 0 0 + // 0x0021 | fd | revert | + // 0x0022 | 5b | jumpdest | + // 0x0023 | 6014 | push1 0x14 | 0x14 + // 0x0025 | 36 | calldatasize | cds 0x14 + // 0x0026 | 03 | sub | (cds-0x14) + // 0x0027 | 6014 | push1 0x14 | 0x14 (cds-0x14) + // 0x0029 | 5f | push0 | 0 0x14 (cds-0x14) + // 0x002a | 37 | calldatacopy | + // 0x002b | 5f | push0 | 0 + // 0x002c | 5f | push0 | 0 0 + // 0x002d | 6014 | push1 0x14 | 0x14 0 0 + // 0x002f | 36 | calldatasize | cds 0x14 0 0 + // 0x0030 | 03 | sub | (cds-0x14) 0 0 + // 0x0031 | 5f | push0 | 0 (cds-0x14) 0 0 + // 0x0032 | 34 | callvalue | value 0 (cds-0x14) 0 0 + // 0x0033 | 5f | push0 | 0 value 0 (cds-0x14) 0 0 + // 0x0034 | 35 | calldataload | cd[0] value 0 (cds-0x14) 0 0 + // 0x0035 | 6060 | push1 0x60 | 0x60 cd[0] value 0 (cds-0x14) 0 0 + // 0x0037 | 1c | shr | target value 0 (cds-0x14) 0 0 + // 0x0038 | 5a | gas | gas target value 0 (cds-0x14) 0 0 + // 0x0039 | f1 | call | suc + // 0x003a | 3d | returndatasize | rds suc + // 0x003b | 5f | push0 | 0 rds suc + // 0x003c | 5f | push0 | 0 0 rds suc + // 0x003d | 3e | returndatacopy | suc + // 0x003e | 5f | push0 | 0 suc + // 0x003f | 3d | returndatasize | rds 0 suc + // 0x0040 | 91 | swap2 | suc 0 rds + // 0x0041 | 6045 | push1 0x45 | 0x45 suc 0 rds + // 0x0043 | 57 | jumpi | 0 rds + // 0x0044 | fd | revert | + // 0x0045 | 5b | jumpdest | 0 rds + // 0x0046 | f3 | return | + + assembly ("memory-safe") { + let fmp := mload(0x40) + + // build initcode at FMP + mstore(add(fmp, 0x46), 0x60145f375f5f601436035f345f3560601c5af13d5f5f3e5f3d91604557fd5bf3) + mstore(add(fmp, 0x26), 0x331460133611166022575f5ffd5b60143603) + mstore(add(fmp, 0x14), address()) + mstore(add(fmp, 0), 0x5f604780600a5f3981f373) + let initcodehash := keccak256(add(fmp, 0x15), 0x51) + + // compute create2 address + mstore(0x40, initcodehash) + mstore(0x20, salt) + mstore(0x00, address()) + mstore8(0x0b, 0xff) + relayer := and(keccak256(0x0b, 0x55), shr(96, not(0))) + + // is relayer not yet deployed, deploy it + if iszero(extcodesize(relayer)) { + if iszero(create2(0, add(fmp, 0x15), 0x51, salt)) { + returndatacopy(fmp, 0, returndatasize()) + revert(fmp, returndatasize()) + } + } + + // cleanup fmp space used as scratch + mstore(0x40, fmp) + } + + // For reference: equivalent in solidity + // bytes memory initcode = abi.encodePacked(hex"5f604780600a5f3981f373", address(this), hex"331460133611166022575f5ffd5b6014360360145f375f5f601436035f345f3560601c5af13d5f5f3e5f3d91604557fd5bf3"); + // address relayer = Create2.computeAddress(salt, keccak256(initcode)); + // if (relayer.code.length == 0) { + // Create2.deploy(0, salt, initcode); + // } + // return relayer; + } +} diff --git a/test/crosschain/ERC7802Bridge.test.js b/test/crosschain/ERC7802Bridge.test.js new file mode 100644 index 00000000..58b31e76 --- /dev/null +++ b/test/crosschain/ERC7802Bridge.test.js @@ -0,0 +1,183 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { impersonate } = require('@openzeppelin/contracts/test/helpers/account'); + +const AxelarHelper = require('./axelar/AxelarHelper'); + +const buildBridgeHash = (...chains) => { + const cmp = (a, b) => (BigInt(a) < BigInt(b) ? -1 : 1); + const chainIds = chains.map(({ token, flags = ethers.ZeroHash, links }) => + ethers.keccak256( + ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes', 'bytes32', 'bytes32[]'], + [ + token, + flags, + links + .map(({ gateway, remote }) => + ethers.keccak256(ethers.AbiCoder.defaultAbiCoder().encode(['bytes', 'bytes'], [gateway, remote])), + ) + .sort(cmp), + ], + ), + ), + ); + return { + id: ethers.solidityPackedKeccak256(['bytes32[]'], [chainIds.toSorted(cmp)]), + chainIds, + }; +}; + +async function fixture() { + const [admin, ...accounts] = await ethers.getSigners(); + + const { chain, gatewayA, gatewayB } = await AxelarHelper.deploy(admin); + + // On chain A, we have a "legacy" token + const bridgeA = await ethers.deployContract('$ERC7802BridgeCounterfactual'); + const tokenA = await ethers.deployContract('$ERC20', ['Token A', 'TA']); + + // On chain B we have a bridgeable token + const bridgeB = await ethers.deployContract('$ERC7802BridgeCounterfactual'); + const tokenB = await ethers.deployContract('$ERC20BridgeableMock', ['Token B', 'TB', admin]); + + // Compute bridge identifier and local hashes + const { id, chainIds } = buildBridgeHash( + { + token: chain.toErc7930(tokenA), + flags: ethers.solidityPacked(['address', 'uint88', 'bool'], [admin.address, 0n, true]), + links: [{ gateway: chain.toErc7930(gatewayA), remote: chain.toErc7930(bridgeB) }], + }, + { + token: chain.toErc7930(tokenB), + flags: ethers.solidityPacked(['address', 'uint88', 'bool'], [ethers.ZeroAddress, 0n, false]), + links: [{ gateway: chain.toErc7930(gatewayB), remote: chain.toErc7930(bridgeA) }], + }, + ); + + // Register bridge + await expect( + bridgeA.createBridge( + tokenA, + admin, // with admin + true, // is custodial + [{ id: chainIds[1], gateway: gatewayA, remote: chain.toErc7930(bridgeB) }], // link to B + id of B + ), + ) + .to.emit(bridgeA, 'Transfer') + .withArgs(ethers.ZeroAddress, admin, id); + + await expect( + bridgeB.createBridge( + tokenB, + ethers.ZeroAddress, // no admin + false, // is crosschain + [{ id: chainIds[0], gateway: gatewayB, remote: chain.toErc7930(bridgeA) }], // link to B + id of B + ), + ) + .to.emit(bridgeB, 'Transfer') + .withArgs(ethers.ZeroAddress, '0x0000000000000000000000000000000000000001', id); + + // Get endpoint for that bridge + const endpointA = await bridgeA.getBridgeEndpoint.staticCall(id); + const endpointB = await bridgeB.getBridgeEndpoint.staticCall(id); + + // Whitelist + await tokenB.connect(admin).grantRole(ethers.id('BRIDGE'), endpointB); + + return { admin, accounts, chain, tokenA, tokenB, gatewayA, gatewayB, bridgeA, bridgeB, endpointA, endpointB, id }; +} + +describe('ERC7802Bridge', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + it('initial setup', async function () { + await expect(this.bridgeA.getBridgeToken(this.id)).to.eventually.deep.equal([this.tokenA.target, true]); + await expect(this.bridgeA.getBridgeGateway(this.id, this.chain.erc7930)).to.eventually.equal(this.gatewayA); + await expect(this.bridgeA.getBridgeRemote(this.id, this.chain.erc7930)).to.eventually.equal( + this.chain.toErc7930(this.bridgeB), + ); + + await expect(this.bridgeB.getBridgeToken(this.id)).to.eventually.deep.equal([this.tokenB.target, false]); + await expect(this.bridgeB.getBridgeGateway(this.id, this.chain.erc7930)).to.eventually.equal(this.gatewayB); + await expect(this.bridgeB.getBridgeRemote(this.id, this.chain.erc7930)).to.eventually.equal( + this.chain.toErc7930(this.bridgeA), + ); + }); + + it('crosschain send', async function () { + const [alice, bruce, chris] = this.accounts; + const amount = 100n; + + await this.tokenA.$_mint(alice, amount); + await this.tokenA.connect(alice).approve(this.endpointA, ethers.MaxUint256); + + await expect(this.tokenA.balanceOf(this.bridgeA)).to.eventually.equal(0n); + await expect(this.tokenB.balanceOf(this.bridgeB)).to.eventually.equal(0n); + await expect(this.tokenA.balanceOf(this.endpointA)).to.eventually.equal(0n); + await expect(this.tokenA.balanceOf(this.endpointB)).to.eventually.equal(0n); + + // Alice sends tokens from chain A to Bruce on chain B. The 7802 custodian bridge on chain A takes ownership of Alice's tokens. + await expect(this.bridgeA.connect(alice).send(this.id, this.chain.toErc7930(bruce), amount, [])) + .to.emit(this.tokenA, 'Transfer') + .withArgs(alice, this.endpointA, amount) // endpoint on chain A takes custody of the funds + .to.emit(this.bridgeA, 'Sent') + .withArgs(this.tokenA, alice, this.chain.toErc7930(bruce), amount) + .to.emit(this.gatewayA, 'MessageSent') + .to.emit(this.bridgeB, 'Received') + .withArgs(this.tokenB, this.chain.toErc7930(alice), bruce, amount) + .to.emit(this.tokenB, 'Transfer') + .withArgs(ethers.ZeroAddress, bruce, amount); + + await expect(this.tokenA.balanceOf(this.bridgeA)).to.eventually.equal(0n); + await expect(this.tokenB.balanceOf(this.bridgeB)).to.eventually.equal(0n); + await expect(this.tokenA.balanceOf(this.endpointA)).to.eventually.equal(amount); // custody + await expect(this.tokenA.balanceOf(this.endpointB)).to.eventually.equal(0n); + + // Bruce sends tokens from chain B to Chris on chain B. The 7802 custodian bridge on chain A releases ownership of the tokens to Chris. + await expect(this.bridgeB.connect(bruce).send(this.id, this.chain.toErc7930(chris), amount, [])) + .to.emit(this.tokenB, 'Transfer') + .withArgs(bruce, ethers.ZeroAddress, amount) // bridge B burns the tokens + .to.emit(this.bridgeB, 'Sent') + .withArgs(this.tokenB, bruce, this.chain.toErc7930(chris), amount) + .to.emit(this.gatewayB, 'MessageSent') + .to.emit(this.bridgeA, 'Received') + .withArgs(this.tokenA, this.chain.toErc7930(bruce), chris, amount) + .to.emit(this.tokenA, 'Transfer') + .withArgs(this.endpointA, chris, amount); + + await expect(this.tokenA.balanceOf(this.bridgeA)).to.eventually.equal(0n); + await expect(this.tokenB.balanceOf(this.bridgeB)).to.eventually.equal(0n); + await expect(this.tokenA.balanceOf(this.endpointA)).to.eventually.equal(0n); + await expect(this.tokenA.balanceOf(this.endpointB)).to.eventually.equal(0n); + }); + + it('cannot call endpoint directly to mint tokens', async function () { + const [receiver, malicious] = this.accounts; + const value = 1_000_000_000n; + + // Deploy endpoint (that is whitelisted by tokenB) + await this.bridgeB.getBridgeEndpoint(this.id); + + const tx = { + to: this.endpointB, + data: ethers.concat([ + this.tokenB.target, + this.tokenB.interface.encodeFunctionData('crosschainMint', [receiver.address, value]), + ]), + }; + + // The bridge is able to make that call + const bridgeAsWallet = await impersonate(this.bridgeB.target); + await expect(bridgeAsWallet.sendTransaction(tx)) + .to.emit(this.tokenB, 'Transfer') + .withArgs(ethers.ZeroAddress, receiver, value); + + // An malicious user cannot + await expect(malicious.sendTransaction(tx)).to.be.revertedWithoutReason(); + }); +});