diff --git a/solidity/contracts/middleware/token-bridge/TokenBridgeRouter.sol b/solidity/contracts/middleware/token-bridge/TokenBridgeRouter.sol new file mode 100644 index 000000000..60712ee4d --- /dev/null +++ b/solidity/contracts/middleware/token-bridge/TokenBridgeRouter.sol @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.13; + +import {Router} from "../../Router.sol"; + +import {IMessageRecipient} from "../../../interfaces/IMessageRecipient.sol"; +import {ICircleBridge} from "./interfaces/circle/ICircleBridge.sol"; +import {ICircleMessageTransmitter} from "./interfaces/circle/ICircleMessageTransmitter.sol"; +import {ITokenBridgeAdapter} from "./interfaces/ITokenBridgeAdapter.sol"; +import {ITokenBridgeMessageRecipient} from "./interfaces/ITokenBridgeMessageRecipient.sol"; + +import {TypeCasts} from "../../libs/TypeCasts.sol"; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +contract TokenBridgeRouter is Router { + // Token bridge => adapter address + mapping(string => address) public tokenBridgeAdapters; + + event TokenBridgeAdapterSet(string indexed bridge, address adapter); + + function initialize( + address _owner, + address _abacusConnectionManager, + address _interchainGasPaymaster + ) public initializer { + // Transfer ownership of the contract to deployer + _transferOwnership(_owner); + // Set the addresses for the ACM and IGP + // Alternatively, this could be done later in an initialize method + _setAbacusConnectionManager(_abacusConnectionManager); + _setInterchainGasPaymaster(_interchainGasPaymaster); + } + + function dispatchWithTokens( + uint32 _destinationDomain, + bytes32 _recipientAddress, + bytes calldata _messageBody, + address _token, + uint256 _amount, + string calldata _bridge + ) external payable { + ITokenBridgeAdapter _adapter = _getAdapter(_bridge); + + // Transfer the tokens to the adapter + // TODO: use safeTransferFrom + // TODO: Are there scenarios where a transferFrom fails and it doesn't revert? + require( + IERC20(_token).transferFrom(msg.sender, address(_adapter), _amount), + "!transfer in" + ); + + // Reverts if the bridge was unsuccessful. + // Gets adapter-specific data that is encoded into the message + // ultimately sent via Hyperlane. + bytes memory _adapterData = _adapter.sendTokens( + _destinationDomain, + _recipientAddress, + _token, + _amount + ); + + // The user's message "wrapped" with metadata required by this middleware + bytes memory _messageWithMetadata = abi.encode( + TypeCasts.addressToBytes32(msg.sender), + _recipientAddress, // The "user" recipient + _amount, // The amount of the tokens sent over the bridge + _bridge, // The destination token bridge ID + _adapterData, // The adapter-specific data + _messageBody // The "user" message + ); + + // Dispatch the _messageWithMetadata to the destination's TokenBridgeRouter. + _dispatchWithGas(_destinationDomain, _messageWithMetadata, msg.value); + } + + // Handles a message from an enrolled remote TokenBridgeRouter + function _handle( + uint32 _origin, + bytes32, // _sender, unused + bytes calldata _message + ) internal override { + // Decode the message with metadata, "unwrapping" the user's message body + ( + bytes32 _originalSender, + bytes32 _userRecipientAddress, + uint256 _amount, + string memory _bridge, + bytes memory _adapterData, + bytes memory _userMessageBody + ) = abi.decode( + _message, + (bytes32, bytes32, uint256, string, bytes, bytes) + ); + + ITokenBridgeMessageRecipient _userRecipient = ITokenBridgeMessageRecipient( + TypeCasts.bytes32ToAddress(_userRecipientAddress) + ); + + // Reverts if the adapter hasn't received the bridged tokens yet + (address _token, uint256 _receivedAmount) = _getAdapter(_bridge) + .receiveTokens( + _origin, + address(_userRecipient), + _amount, + _adapterData + ); + + _userRecipient.handleWithTokens( + _origin, + _originalSender, + _userMessageBody, + _token, + _receivedAmount + ); + } + + function setTokenBridgeAdapter(string calldata _bridge, address _adapter) + external + onlyOwner + { + tokenBridgeAdapters[_bridge] = _adapter; + emit TokenBridgeAdapterSet(_bridge, _adapter); + } + + function _getAdapter(string memory _bridge) + internal + view + returns (ITokenBridgeAdapter _adapter) + { + _adapter = ITokenBridgeAdapter(tokenBridgeAdapters[_bridge]); + // Require the adapter to have been set + require(address(_adapter) != address(0), "No adapter found for bridge"); + } +} diff --git a/solidity/contracts/middleware/token-bridge/adapters/CircleBridgeAdapter.sol b/solidity/contracts/middleware/token-bridge/adapters/CircleBridgeAdapter.sol new file mode 100644 index 000000000..b7df31087 --- /dev/null +++ b/solidity/contracts/middleware/token-bridge/adapters/CircleBridgeAdapter.sol @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.13; + +import {Router} from "../../../Router.sol"; + +import {ICircleBridge} from "../interfaces/circle/ICircleBridge.sol"; +import {ICircleMessageTransmitter} from "../interfaces/circle/ICircleMessageTransmitter.sol"; +import {ITokenBridgeAdapter} from "../interfaces/ITokenBridgeAdapter.sol"; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +contract CircleBridgeAdapter is ITokenBridgeAdapter, Router { + /// @notice The CircleBridge contract. + ICircleBridge public circleBridge; + + /// @notice The Circle MessageTransmitter contract. + ICircleMessageTransmitter public circleMessageTransmitter; + + /// @notice The TokenBridgeRouter contract. + address public tokenBridgeRouter; + + /// @notice Hyperlane domain => Circle domain. + /// ATM, known Circle domains are Ethereum = 0 and Avalanche = 1. + /// Note this could result in ambiguity between the Circle domain being + /// Ethereum or unknown. TODO fix? + mapping(uint32 => uint32) public hyperlaneDomainToCircleDomain; + + /// @notice Token symbol => address of token on local chain. + mapping(string => IERC20) public tokenSymbolToAddress; + + /// @notice Local chain token address => token symbol. + mapping(address => string) public tokenAddressToSymbol; + + /** + * @notice Emits the nonce of the Circle message when a token is bridged. + * @param nonce The nonce of the Circle message. + */ + event BridgedToken(uint64 nonce); + + /** + * @notice Emitted when the Hyperlane domain to Circle domain mapping is updated. + * @param hyperlaneDomain The Hyperlane domain. + * @param circleDomain The Circle domain. + */ + event DomainAdded(uint32 indexed hyperlaneDomain, uint32 circleDomain); + + /** + * @notice Emitted when a local token and its token symbol have been added. + */ + event TokenAdded(address indexed token, string indexed symbol); + + /** + * @notice Emitted when a local token and its token symbol have been removed. + */ + event TokenRemoved(address indexed token, string indexed symbol); + + modifier onlyTokenBridgeRouter() { + require(msg.sender == tokenBridgeRouter, "!tokenBridgeRouter"); + _; + } + + /** + * @param _owner The new owner. + * @param _circleBridge The CircleBridge contract. + * @param _circleMessageTransmitter The Circle MessageTransmitter contract. + * @param _tokenBridgeRouter The TokenBridgeRouter contract. + */ + function initialize( + address _owner, + address _circleBridge, + address _circleMessageTransmitter, + address _tokenBridgeRouter + ) public initializer { + // Transfer ownership of the contract to deployer + _transferOwnership(_owner); + + // Set the addresses for the ACM and IGP to address(0) - they aren't used. + _setAbacusConnectionManager(address(0)); + _setInterchainGasPaymaster(address(0)); + + circleBridge = ICircleBridge(_circleBridge); + circleMessageTransmitter = ICircleMessageTransmitter( + _circleMessageTransmitter + ); + tokenBridgeRouter = _tokenBridgeRouter; + } + + function sendTokens( + uint32 _destinationDomain, + bytes32, // _recipientAddress, unused + address _token, + uint256 _amount + ) external onlyTokenBridgeRouter returns (bytes memory) { + string memory _tokenSymbol = tokenAddressToSymbol[_token]; + require( + bytes(_tokenSymbol).length > 0, + "CircleBridgeAdapter: Unknown token" + ); + + uint32 _circleDomain = hyperlaneDomainToCircleDomain[ + _destinationDomain + ]; + bytes32 _remoteRouter = routers[_destinationDomain]; + require( + _remoteRouter != bytes32(0), + "CircleBridgeAdapter: No router for domain" + ); + + // Approve the token to Circle. We assume that the TokenBridgeRouter + // has already transferred the token to this contract. + require( + IERC20(_token).approve(address(circleBridge), _amount), + "!approval" + ); + + uint64 _nonce = circleBridge.depositForBurn( + _amount, + _circleDomain, + _remoteRouter, // Mint to the remote router + _token + ); + + emit BridgedToken(_nonce); + + return abi.encodePacked(_nonce, _tokenSymbol); + } + + // Returns the token and amount sent + function receiveTokens( + uint32 _originDomain, // Hyperlane domain + address _recipient, + uint256 _amount, + bytes calldata _adapterData // The adapter data from the message + ) external onlyTokenBridgeRouter returns (address, uint256) { + // The origin Circle domain + uint32 _originCircleDomain = hyperlaneDomainToCircleDomain[ + _originDomain + ]; + // Get the token symbol and nonce of the transfer from the _adapterData + (uint64 _nonce, string memory _tokenSymbol) = abi.decode( + _adapterData, + (uint64, string) + ); + + // Require the circle message to have been processed + bytes32 _nonceId = _circleNonceId(_originCircleDomain, _nonce); + require( + circleMessageTransmitter.usedNonces(_nonceId), + "Circle message not processed yet" + ); + + IERC20 _token = tokenSymbolToAddress[_tokenSymbol]; + require( + address(_token) != address(0), + "CircleBridgeAdapter: Unknown token" + ); + + // Transfer the token out to the recipient + // TODO: use safeTransfer + // Circle doesn't charge any fee, so we can safely transfer out the + // exact amount that was bridged over. + require(_token.transfer(_recipient, _amount), "!transfer out"); + + return (address(_token), _amount); + } + + // This contract is only a Router to be aware of remote router addresses, + // and doesn't actually send/handle Hyperlane messages directly + function _handle( + uint32, // origin + bytes32, // sender + bytes calldata // message + ) internal pure override { + revert("No messages expected"); + } + + function addDomain(uint32 _hyperlaneDomain, uint32 _circleDomain) + external + onlyOwner + { + hyperlaneDomainToCircleDomain[_hyperlaneDomain] = _circleDomain; + + emit DomainAdded(_hyperlaneDomain, _circleDomain); + } + + function addToken(address _token, string calldata _tokenSymbol) + external + onlyOwner + { + require( + _token != address(0) && bytes(_tokenSymbol).length > 0, + "Cannot add default values" + ); + + // Require the token and token symbol to be unset. + address _existingToken = address(tokenSymbolToAddress[_tokenSymbol]); + require(_existingToken == address(0), "token symbol already has token"); + + string memory _existingSymbol = tokenAddressToSymbol[_token]; + require( + bytes(_existingSymbol).length == 0, + "token already has token symbol" + ); + + tokenAddressToSymbol[_token] = _tokenSymbol; + tokenSymbolToAddress[_tokenSymbol] = IERC20(_token); + + emit TokenAdded(_token, _tokenSymbol); + } + + function removeToken(address _token, string calldata _tokenSymbol) + external + onlyOwner + { + // Require the provided token and token symbols match what's in storage. + address _existingToken = address(tokenSymbolToAddress[_tokenSymbol]); + require(_existingToken == _token, "Token mismatch"); + + string memory _existingSymbol = tokenAddressToSymbol[_token]; + require( + keccak256(bytes(_existingSymbol)) == keccak256(bytes(_tokenSymbol)), + "Token symbol mismatch" + ); + + // Delete them from storage. + delete tokenSymbolToAddress[_tokenSymbol]; + delete tokenAddressToSymbol[_token]; + + emit TokenRemoved(_token, _tokenSymbol); + } + + /** + * @notice Gets the Circle nonce ID by hashing _originCircleDomain and _nonce. + * @param _originCircleDomain Domain of chain where the transfer originated + * @param _nonce The unique identifier for the message from source to + destination + * @return hash of source and nonce + */ + function _circleNonceId(uint32 _originCircleDomain, uint64 _nonce) + internal + pure + returns (bytes32) + { + // The hash is of a uint256 nonce, not a uint64 one. + return + keccak256(abi.encodePacked(_originCircleDomain, uint256(_nonce))); + } +} diff --git a/solidity/contracts/middleware/token-bridge/interfaces/ITokenBridgeAdapter.sol b/solidity/contracts/middleware/token-bridge/interfaces/ITokenBridgeAdapter.sol new file mode 100644 index 000000000..de8847a13 --- /dev/null +++ b/solidity/contracts/middleware/token-bridge/interfaces/ITokenBridgeAdapter.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.13; + +interface ITokenBridgeAdapter { + function sendTokens( + uint32 _destinationDomain, + bytes32 _recipientAddress, + address _token, + uint256 _amount + ) external returns (bytes memory _adapterData); + + function receiveTokens( + uint32 _originDomain, // Hyperlane domain + address _recipientAddress, + uint256 _amount, + bytes calldata _adapterData // The adapter data from the message + ) external returns (address, uint256); +} diff --git a/solidity/contracts/middleware/token-bridge/interfaces/ITokenBridgeMessageRecipient.sol b/solidity/contracts/middleware/token-bridge/interfaces/ITokenBridgeMessageRecipient.sol new file mode 100644 index 000000000..49264cda7 --- /dev/null +++ b/solidity/contracts/middleware/token-bridge/interfaces/ITokenBridgeMessageRecipient.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.13; + +interface ITokenBridgeMessageRecipient { + function handleWithTokens( + uint32 _origin, + bytes32 _sender, + bytes calldata _message, + address _token, + uint256 _amount + ) external; +} diff --git a/solidity/contracts/middleware/token-bridge/interfaces/circle/ICircleBridge.sol b/solidity/contracts/middleware/token-bridge/interfaces/circle/ICircleBridge.sol new file mode 100644 index 000000000..f4c7d7630 --- /dev/null +++ b/solidity/contracts/middleware/token-bridge/interfaces/circle/ICircleBridge.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.13; + +interface ICircleBridge { + event MessageSent(bytes message); + + /** + * @notice Deposits and burns tokens from sender to be minted on destination domain. + * Emits a `DepositForBurn` event. + * @dev reverts if: + * - given burnToken is not supported + * - given destinationDomain has no CircleBridge registered + * - transferFrom() reverts. For example, if sender's burnToken balance or approved allowance + * to this contract is less than `amount`. + * - burn() reverts. For example, if `amount` is 0. + * - MessageTransmitter returns false or reverts. + * @param _amount amount of tokens to burn + * @param _destinationDomain destination domain (ETH = 0, AVAX = 1) + * @param _mintRecipient address of mint recipient on destination domain + * @param _burnToken address of contract to burn deposited tokens, on local domain + * @return _nonce unique nonce reserved by message + */ + function depositForBurn( + uint256 _amount, + uint32 _destinationDomain, + bytes32 _mintRecipient, + address _burnToken + ) external returns (uint64 _nonce); + + /** + * @notice Deposits and burns tokens from sender to be minted on destination domain. The mint + * on the destination domain must be called by `_destinationCaller`. + * WARNING: if the `_destinationCaller` does not represent a valid address as bytes32, then it will not be possible + * to broadcast the message on the destination domain. This is an advanced feature, and the standard + * depositForBurn() should be preferred for use cases where a specific destination caller is not required. + * Emits a `DepositForBurn` event. + * @dev reverts if: + * - given destinationCaller is zero address + * - given burnToken is not supported + * - given destinationDomain has no CircleBridge registered + * - transferFrom() reverts. For example, if sender's burnToken balance or approved allowance + * to this contract is less than `amount`. + * - burn() reverts. For example, if `amount` is 0. + * - MessageTransmitter returns false or reverts. + * @param _amount amount of tokens to burn + * @param _destinationDomain destination domain + * @param _mintRecipient address of mint recipient on destination domain + * @param _burnToken address of contract to burn deposited tokens, on local domain + * @param _destinationCaller caller on the destination domain, as bytes32 + * @return _nonce unique nonce reserved by message + */ + function depositForBurnWithCaller( + uint256 _amount, + uint32 _destinationDomain, + bytes32 _mintRecipient, + address _burnToken, + bytes32 _destinationCaller + ) external returns (uint64 _nonce); +} diff --git a/solidity/contracts/middleware/token-bridge/interfaces/circle/ICircleMessageTransmitter.sol b/solidity/contracts/middleware/token-bridge/interfaces/circle/ICircleMessageTransmitter.sol new file mode 100644 index 000000000..8a649eaa3 --- /dev/null +++ b/solidity/contracts/middleware/token-bridge/interfaces/circle/ICircleMessageTransmitter.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.13; + +interface ICircleMessageTransmitter { + /** + * @notice Receive a message. Messages with a given nonce + * can only be broadcast once for a (sourceDomain, destinationDomain) + * pair. The message body of a valid message is passed to the + * specified recipient for further processing. + * + * @dev Attestation format: + * A valid attestation is the concatenated 65-byte signature(s) of exactly + * `thresholdSignature` signatures, in increasing order of attester address. + * ***If the attester addresses recovered from signatures are not in + * increasing order, signature verification will fail.*** + * If incorrect number of signatures or duplicate signatures are supplied, + * signature verification will fail. + * + * Message format: + * Field Bytes Type Index + * version 4 uint32 0 + * sourceDomain 4 uint32 4 + * destinationDomain 4 uint32 8 + * nonce 8 uint64 12 + * sender 32 bytes32 20 + * recipient 32 bytes32 52 + * messageBody dynamic bytes 84 + * @param _message Message bytes + * @param _attestation Concatenated 65-byte signature(s) of `_message`, in increasing order + * of the attester address recovered from signatures. + * @return success bool, true if successful + */ + function receiveMessage(bytes memory _message, bytes calldata _attestation) + external + returns (bool success); + + function usedNonces(bytes32 _nonceId) external view returns (bool); +} diff --git a/solidity/contracts/mock/MockToken.sol b/solidity/contracts/mock/MockToken.sol new file mode 100644 index 000000000..ad53f449f --- /dev/null +++ b/solidity/contracts/mock/MockToken.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.13; + +import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; + +contract MockToken is ERC20Upgradeable { + function mint(address account, uint256 amount) external { + _mint(account, amount); + } + + function burn(uint256 _amount) external { + _burn(msg.sender, _amount); + } +} diff --git a/solidity/contracts/mock/MockTokenBridgeAdapter.sol b/solidity/contracts/mock/MockTokenBridgeAdapter.sol new file mode 100644 index 000000000..08b04e651 --- /dev/null +++ b/solidity/contracts/mock/MockTokenBridgeAdapter.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.13; + +import {ITokenBridgeAdapter} from "../middleware/token-bridge/interfaces/ITokenBridgeAdapter.sol"; +import {MockToken} from "./MockToken.sol"; + +contract MockTokenBridgeAdapter is ITokenBridgeAdapter { + uint256 public nonce = 0; + MockToken token; + + mapping(uint256 => bool) public isProcessed; + + constructor(MockToken _token) { + token = _token; + } + + function sendTokens( + uint32, + bytes32, + address _token, + uint256 _amount + ) external override returns (bytes memory _adapterData) { + require(_token == address(token), "cant bridge this token"); + token.burn(_amount); + nonce = nonce + 1; + return abi.encode(nonce); + } + + function process(uint256 _nonce) public { + isProcessed[_nonce] = true; + } + + function receiveTokens( + uint32 _originDomain, // Hyperlane domain + address _recipientAddress, + uint256 _amount, + bytes calldata _adapterData // The adapter data from the message + ) external override returns (address, uint256) { + _originDomain; + uint256 _nonce = abi.decode(_adapterData, (uint256)); + // Check if the transfer was processed first + require(isProcessed[_nonce], "Transfer has not been processed yet"); + token.mint(_recipientAddress, _amount); + return (address(0), 0); + } +} diff --git a/solidity/contracts/test/TestTokenBridgeMessageRecipient.sol b/solidity/contracts/test/TestTokenBridgeMessageRecipient.sol new file mode 100644 index 000000000..a753c4b2b --- /dev/null +++ b/solidity/contracts/test/TestTokenBridgeMessageRecipient.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.13; + +import {ITokenBridgeMessageRecipient} from "../middleware/token-bridge/interfaces/ITokenBridgeMessageRecipient.sol"; + +contract TestTokenBridgeMessageRecipient is ITokenBridgeMessageRecipient { + event HandledWithTokens( + uint32 origin, + bytes32 sender, + bytes message, + address token, + uint256 amount + ); + + function handleWithTokens( + uint32 _origin, + bytes32 _sender, + bytes calldata _message, + address _token, + uint256 _amount + ) external { + emit HandledWithTokens(_origin, _sender, _message, _token, _amount); + } +} diff --git a/solidity/contracts/test/TestTokenRecipient.sol b/solidity/contracts/test/TestTokenRecipient.sol new file mode 100644 index 000000000..202abf4d5 --- /dev/null +++ b/solidity/contracts/test/TestTokenRecipient.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity >=0.8.0; + +import {ITokenBridgeMessageRecipient} from "../middleware/token-bridge/interfaces/ITokenBridgeMessageRecipient.sol"; + +contract TestTokenRecipient is ITokenBridgeMessageRecipient { + bytes32 public lastSender; + bytes public lastData; + address public lastToken; + uint256 public lastAmount; + + address public lastCaller; + string public lastCallMessage; + + event ReceivedMessage( + uint32 indexed origin, + bytes32 indexed sender, + string message, + address token, + uint256 amount + ); + + event ReceivedCall(address indexed caller, uint256 amount, string message); + + function handleWithTokens( + uint32 _origin, + bytes32 _sender, + bytes calldata _data, + address _token, + uint256 _amount + ) external override { + emit ReceivedMessage(_origin, _sender, string(_data), _token, _amount); + lastSender = _sender; + lastData = _data; + lastToken = _token; + lastAmount = _amount; + } + + function fooBar(uint256 amount, string calldata message) external { + emit ReceivedCall(msg.sender, amount, message); + lastCaller = msg.sender; + lastCallMessage = message; + } +} diff --git a/solidity/test/InterchainAccountRouter.t.sol b/solidity/test/InterchainAccountRouter.t.sol index 2d74f7404..811d19673 100644 --- a/solidity/test/InterchainAccountRouter.t.sol +++ b/solidity/test/InterchainAccountRouter.t.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: Apache-2.0 pragma solidity ^0.8.13; import "forge-std/Test.sol"; diff --git a/solidity/test/MockHyperlaneEnvironment.sol b/solidity/test/MockHyperlaneEnvironment.sol new file mode 100644 index 000000000..815fd2370 --- /dev/null +++ b/solidity/test/MockHyperlaneEnvironment.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.13; + +import "../contracts/mock/MockOutbox.sol"; +import "../contracts/mock/MockInbox.sol"; +import "../contracts/AbacusConnectionManager.sol"; + +contract MockHyperlaneEnvironment { + MockOutbox public outbox; + MockInbox public inbox; + + mapping(uint32 => AbacusConnectionManager) connectionManagers; + + constructor(uint32 _originDomain, uint32 _destinationDomain) { + inbox = new MockInbox(); + outbox = new MockOutbox(_originDomain, address(inbox)); + + AbacusConnectionManager originManager = new AbacusConnectionManager(); + AbacusConnectionManager destinationManager = new AbacusConnectionManager(); + + originManager.setOutbox(address(outbox)); + destinationManager.enrollInbox(_destinationDomain, address(inbox)); + + connectionManagers[_originDomain] = originManager; + connectionManagers[_destinationDomain] = destinationManager; + } + + function connectionManager(uint32 _domain) + public + view + returns (AbacusConnectionManager) + { + return connectionManagers[_domain]; + } + + function processNextPendingMessage() public { + inbox.processNextPendingMessage(); + } +} diff --git a/solidity/test/TokenBridgeRouter.t.sol b/solidity/test/TokenBridgeRouter.t.sol new file mode 100644 index 000000000..cb5e30672 --- /dev/null +++ b/solidity/test/TokenBridgeRouter.t.sol @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import {TokenBridgeRouter} from "../contracts/middleware/token-bridge/TokenBridgeRouter.sol"; +import {MockToken} from "../contracts/mock/MockToken.sol"; +import {TestTokenRecipient} from "../contracts/test/TestTokenRecipient.sol"; +import {MockTokenBridgeAdapter} from "../contracts/mock/MockTokenBridgeAdapter.sol"; +import {MockHyperlaneEnvironment} from "./MockHyperlaneEnvironment.sol"; + +import {TypeCasts} from "../contracts/libs/TypeCasts.sol"; + +contract TokenBridgeRouterTest is Test { + MockHyperlaneEnvironment testEnvironment; + + TokenBridgeRouter originTokenBridgeRouter; + TokenBridgeRouter destinationTokenBridgeRouter; + + // Origin bridge adapter + MockTokenBridgeAdapter bridgeAdapter; + string bridge = "FooBridge"; + + uint32 originDomain = 123; + uint32 destinationDomain = 321; + + TestTokenRecipient recipient; + MockToken token; + bytes messageBody = hex"beefdead"; + uint256 amount = 420000; + + event TokenBridgeAdapterSet(string indexed bridge, address adapter); + + function setUp() public { + token = new MockToken(); + bridgeAdapter = new MockTokenBridgeAdapter(token); + recipient = new TestTokenRecipient(); + + originTokenBridgeRouter = new TokenBridgeRouter(); + destinationTokenBridgeRouter = new TokenBridgeRouter(); + + testEnvironment = new MockHyperlaneEnvironment( + originDomain, + destinationDomain + ); + + // TODO: set IGP? + originTokenBridgeRouter.initialize( + address(this), + address(testEnvironment.connectionManager(originDomain)), + address(0) + ); + destinationTokenBridgeRouter.initialize( + address(this), + address(testEnvironment.connectionManager(destinationDomain)), + address(0) + ); + + originTokenBridgeRouter.enrollRemoteRouter( + destinationDomain, + TypeCasts.addressToBytes32(address(destinationTokenBridgeRouter)) + ); + destinationTokenBridgeRouter.enrollRemoteRouter( + originDomain, + TypeCasts.addressToBytes32(address(originTokenBridgeRouter)) + ); + + originTokenBridgeRouter.setTokenBridgeAdapter( + bridge, + address(bridgeAdapter) + ); + + destinationTokenBridgeRouter.setTokenBridgeAdapter( + bridge, + address(bridgeAdapter) + ); + + token.mint(address(this), amount); + } + + function testSetTokenBridgeAdapter() public { + // Expect the TokenBridgeAdapterSet event. + // Expect topic0 & data to match + vm.expectEmit(true, false, false, true); + emit TokenBridgeAdapterSet(bridge, address(bridgeAdapter)); + + // Set the token bridge adapter + originTokenBridgeRouter.setTokenBridgeAdapter( + bridge, + address(bridgeAdapter) + ); + + // Expect the bridge adapter to have been set + assertEq( + originTokenBridgeRouter.tokenBridgeAdapters(bridge), + address(bridgeAdapter) + ); + } + + // ==== dispatchWithTokens ==== + + function testDispatchWithTokensRevertsWithUnkownBridgeAdapter() public { + vm.expectRevert("No adapter found for bridge"); + originTokenBridgeRouter.dispatchWithTokens( + destinationDomain, + TypeCasts.addressToBytes32(address(recipient)), + messageBody, + address(token), + amount, + "BazBridge" // some unknown bridge name + ); + } + + function testDispatchWithTokensRevertsWithFailedTransferIn() public { + vm.expectRevert("ERC20: insufficient allowance"); + originTokenBridgeRouter.dispatchWithTokens( + destinationDomain, + TypeCasts.addressToBytes32(address(recipient)), + messageBody, + address(token), + amount, + bridge + ); + } + + function testDispatchWithTokenTransfersMovesTokens() public { + token.approve(address(originTokenBridgeRouter), amount); + originTokenBridgeRouter.dispatchWithTokens( + destinationDomain, + TypeCasts.addressToBytes32(address(recipient)), + messageBody, + address(token), + amount, + bridge + ); + } + + function testDispatchWithTokensCallsAdapter() public { + vm.expectCall( + address(bridgeAdapter), + abi.encodeWithSelector( + bridgeAdapter.sendTokens.selector, + destinationDomain, + TypeCasts.addressToBytes32(address(recipient)), + address(token), + amount + ) + ); + token.approve(address(originTokenBridgeRouter), amount); + originTokenBridgeRouter.dispatchWithTokens( + destinationDomain, + TypeCasts.addressToBytes32(address(recipient)), + messageBody, + address(token), + amount, + bridge + ); + } + + function testProcessingRevertsIfBridgeAdapterReverts() public { + token.approve(address(originTokenBridgeRouter), amount); + originTokenBridgeRouter.dispatchWithTokens( + destinationDomain, + TypeCasts.addressToBytes32(address(recipient)), + messageBody, + address(token), + amount, + bridge + ); + + vm.expectRevert("Transfer has not been processed yet"); + testEnvironment.processNextPendingMessage(); + } + + function testDispatchWithTokensTransfersOnDestination() public { + token.approve(address(originTokenBridgeRouter), amount); + originTokenBridgeRouter.dispatchWithTokens( + destinationDomain, + TypeCasts.addressToBytes32(address(recipient)), + messageBody, + address(token), + amount, + bridge + ); + + bridgeAdapter.process(bridgeAdapter.nonce()); + testEnvironment.processNextPendingMessage(); + assertEq(recipient.lastData(), messageBody); + assertEq(token.balanceOf(address(recipient)), amount); + } +}