diff --git a/solidity/.gas-snapshot b/solidity/.gas-snapshot index be6e1dfd5..2e09fd8a7 100644 --- a/solidity/.gas-snapshot +++ b/solidity/.gas-snapshot @@ -1,15 +1,18 @@ InterchainAccountRouterTest:testOwner() (gas: 210381) -InterchainAccountRouterTest:testSetOwner(address) (runs: 256, μ: 990801, ~: 990801) +InterchainAccountRouterTest:testSetOwner(address) (runs: 256, μ: 990723, ~: 990801) InterchainQueryRouterTest:testQueryAddress(address) (runs: 256, μ: 1345905, ~: 1345905) InterchainQueryRouterTest:testQueryUint256(uint256) (runs: 256, μ: 1686587, ~: 1686587) LiquidityLayerRouterTest:testDispatchWithTokenTransfersMovesTokens() (gas: 552484) LiquidityLayerRouterTest:testDispatchWithTokensCallsAdapter() (gas: 558572) LiquidityLayerRouterTest:testDispatchWithTokensRevertsWithFailedTransferIn() (gas: 28626) LiquidityLayerRouterTest:testDispatchWithTokensRevertsWithUnkownBridgeAdapter() (gas: 20611) -LiquidityLayerRouterTest:testDispatchWithTokensTransfersOnDestination() (gas: 788665) -LiquidityLayerRouterTest:testProcessingRevertsIfBridgeAdapterReverts() (gas: 603576) +LiquidityLayerRouterTest:testDispatchWithTokensTransfersOnDestination() (gas: 788693) +LiquidityLayerRouterTest:testProcessingRevertsIfBridgeAdapterReverts() (gas: 603550) LiquidityLayerRouterTest:testSetLiquidityLayerAdapter() (gas: 23429) -MessagingTest:testSendMessage(string) (runs: 256, μ: 277677, ~: 296009) +MessagingTest:testSendMessage(string) (runs: 256, μ: 276993, ~: 296009) PausableReentrancyGuardTest:testNonreentrant() (gas: 14428) PausableReentrancyGuardTest:testNonreentrantNotPaused() (gas: 14163) PausableReentrancyGuardTest:testPause() (gas: 13635) +PortalAdapterTest:testAdapter(uint256) (runs: 256, μ: 135467, ~: 135583) +PortalAdapterTest:testReceivingRevertsWithoutTransferCompletion(uint256) (runs: 256, μ: 140406, ~: 140522) +PortalAdapterTest:testReceivingWorks(uint256) (runs: 256, μ: 229403, ~: 229520) diff --git a/solidity/contracts/middleware/liquidity-layer/LiquidityLayerRouter.sol b/solidity/contracts/middleware/liquidity-layer/LiquidityLayerRouter.sol index 88d5a14a8..356dd7c1b 100644 --- a/solidity/contracts/middleware/liquidity-layer/LiquidityLayerRouter.sol +++ b/solidity/contracts/middleware/liquidity-layer/LiquidityLayerRouter.sol @@ -4,7 +4,6 @@ pragma solidity ^0.8.13; import {Router} from "../../Router.sol"; import {ILiquidityLayerRouter} from "../../../interfaces/ILiquidityLayerRouter.sol"; -import {ICircleBridge} from "./interfaces/circle/ICircleBridge.sol"; import {ICircleMessageTransmitter} from "./interfaces/circle/ICircleMessageTransmitter.sol"; import {ILiquidityLayerAdapter} from "./interfaces/ILiquidityLayerAdapter.sol"; import {ILiquidityLayerMessageRecipient} from "../../../interfaces/ILiquidityLayerMessageRecipient.sol"; diff --git a/solidity/contracts/middleware/liquidity-layer/adapters/CircleBridgeAdapter.sol b/solidity/contracts/middleware/liquidity-layer/adapters/CircleBridgeAdapter.sol index ca0c59fab..13150bff3 100644 --- a/solidity/contracts/middleware/liquidity-layer/adapters/CircleBridgeAdapter.sol +++ b/solidity/contracts/middleware/liquidity-layer/adapters/CircleBridgeAdapter.sol @@ -3,15 +3,15 @@ pragma solidity ^0.8.13; import {Router} from "../../../Router.sol"; -import {ICircleBridge} from "../interfaces/circle/ICircleBridge.sol"; +import {ITokenMessenger} from "../interfaces/circle/ITokenMessenger.sol"; import {ICircleMessageTransmitter} from "../interfaces/circle/ICircleMessageTransmitter.sol"; import {ILiquidityLayerAdapter} from "../interfaces/ILiquidityLayerAdapter.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; contract CircleBridgeAdapter is ILiquidityLayerAdapter, Router { - /// @notice The CircleBridge contract. - ICircleBridge public circleBridge; + /// @notice The TokenMessenger contract. + ITokenMessenger public tokenMessenger; /// @notice The Circle MessageTransmitter contract. ICircleMessageTransmitter public circleMessageTransmitter; @@ -61,20 +61,20 @@ contract CircleBridgeAdapter is ILiquidityLayerAdapter, Router { /** * @param _owner The new owner. - * @param _circleBridge The CircleBridge contract. + * @param _tokenMessenger The TokenMessenger contract. * @param _circleMessageTransmitter The Circle MessageTransmitter contract. * @param _liquidityLayerRouter The LiquidityLayerRouter contract. */ function initialize( address _owner, - address _circleBridge, + address _tokenMessenger, address _circleMessageTransmitter, address _liquidityLayerRouter ) public initializer { // Transfer ownership of the contract to deployer _transferOwnership(_owner); - circleBridge = ICircleBridge(_circleBridge); + tokenMessenger = ITokenMessenger(_tokenMessenger); circleMessageTransmitter = ICircleMessageTransmitter( _circleMessageTransmitter ); @@ -105,11 +105,11 @@ contract CircleBridgeAdapter is ILiquidityLayerAdapter, Router { // Approve the token to Circle. We assume that the LiquidityLayerRouter // has already transferred the token to this contract. require( - IERC20(_token).approve(address(circleBridge), _amount), + IERC20(_token).approve(address(tokenMessenger), _amount), "!approval" ); - uint64 _nonce = circleBridge.depositForBurn( + uint64 _nonce = tokenMessenger.depositForBurn( _amount, _circleDomain, _remoteRouter, // Mint to the remote router @@ -236,8 +236,6 @@ contract CircleBridgeAdapter is ILiquidityLayerAdapter, Router { pure returns (bytes32) { - // The hash is of a uint256 nonce, not a uint64 one. - return - keccak256(abi.encodePacked(_originCircleDomain, uint256(_nonce))); + return keccak256(abi.encodePacked(_originCircleDomain, _nonce)); } } diff --git a/solidity/contracts/middleware/liquidity-layer/adapters/PortalAdapter.sol b/solidity/contracts/middleware/liquidity-layer/adapters/PortalAdapter.sol new file mode 100644 index 000000000..c6688d9a5 --- /dev/null +++ b/solidity/contracts/middleware/liquidity-layer/adapters/PortalAdapter.sol @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.13; + +import {Router} from "../../../Router.sol"; + +import {IPortalTokenBridge} from "../interfaces/portal/IPortalTokenBridge.sol"; +import {ILiquidityLayerAdapter} from "../interfaces/ILiquidityLayerAdapter.sol"; +import {TypeCasts} from "../../../libs/TypeCasts.sol"; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +contract PortalAdapter is ILiquidityLayerAdapter, Router { + /// @notice The Portal TokenBridge contract. + IPortalTokenBridge public portalTokenBridge; + + /// @notice The LiquidityLayerRouter contract. + address public liquidityLayerRouter; + + /// @notice Hyperlane domain => Wormhole domain. + mapping(uint32 => uint16) public hyperlaneDomainToWormholeDomain; + /// @notice transferId => token address + mapping(bytes32 => address) public portalTransfersProcessed; + + uint32 localDomain; + + // We could technically use Portal's sequence number here but it doesn't + // get passed through, so we would have to parse the VAA twice + // 224 bits should be large enough and allows us to pack into a single slot + // with a Hyperlane domain + uint224 public nonce = 0; + + /** + * @notice Emits the nonce of the Portal message when a token is bridged. + * @param nonce The nonce of the Portal message. + * @param portalSequence The sequence of the Portal message. + * @param destination The hyperlane domain of the destination + */ + event BridgedToken( + uint256 nonce, + uint64 portalSequence, + uint32 destination + ); + + /** + * @notice Emitted when the Hyperlane domain to Wormhole domain mapping is updated. + * @param hyperlaneDomain The Hyperlane domain. + * @param wormholeDomain The Wormhole domain. + */ + event DomainAdded(uint32 indexed hyperlaneDomain, uint32 wormholeDomain); + + modifier onlyLiquidityLayerRouter() { + require(msg.sender == liquidityLayerRouter, "!liquidityLayerRouter"); + _; + } + + /** + * @param _localDomain The local hyperlane domain + * @param _owner The new owner. + * @param _portalTokenBridge The Portal TokenBridge contract. + * @param _liquidityLayerRouter The LiquidityLayerRouter contract. + */ + function initialize( + uint32 _localDomain, + address _owner, + address _portalTokenBridge, + address _liquidityLayerRouter + ) public initializer { + // Transfer ownership of the contract to deployer + _transferOwnership(_owner); + + localDomain = _localDomain; + portalTokenBridge = IPortalTokenBridge(_portalTokenBridge); + liquidityLayerRouter = _liquidityLayerRouter; + } + + /** + * Sends tokens as requested by the router + * @param _destinationDomain The hyperlane domain of the destination + * @param _token The token address + * @param _amount The amount of tokens to send + */ + function sendTokens( + uint32 _destinationDomain, + bytes32, // _recipientAddress, unused + address _token, + uint256 _amount + ) external onlyLiquidityLayerRouter returns (bytes memory) { + nonce = nonce + 1; + uint16 _wormholeDomain = hyperlaneDomainToWormholeDomain[ + _destinationDomain + ]; + + bytes32 _remoteRouter = _mustHaveRemoteRouter(_destinationDomain); + + // Approve the token to Portal. We assume that the LiquidityLayerRouter + // has already transferred the token to this contract. + require( + IERC20(_token).approve(address(portalTokenBridge), _amount), + "!approval" + ); + + uint64 _portalSequence = portalTokenBridge.transferTokensWithPayload( + _token, + _amount, + _wormholeDomain, + _remoteRouter, + // Nonce for grouping Portal messages in the same tx, not relevant for us + // https://book.wormhole.com/technical/evm/coreLayer.html#emitting-a-vaa + 0, + // Portal Payload used in completeTransfer + abi.encode(localDomain, nonce) + ); + + emit BridgedToken(nonce, _portalSequence, _destinationDomain); + return abi.encode(nonce); + } + + /** + * Sends the tokens to the recipient as requested by the router + * @param _originDomain The hyperlane domain of the origin + * @param _recipient The address of the recipient + * @param _amount The amount of tokens to send + * @param _adapterData The adapter data from the origin chain, containing the nonce + */ + function receiveTokens( + uint32 _originDomain, // Hyperlane domain + address _recipient, + uint256 _amount, + bytes calldata _adapterData // The adapter data from the message + ) external onlyLiquidityLayerRouter returns (address, uint256) { + // Get the nonce information from the adapterData + uint224 _nonce = abi.decode(_adapterData, (uint224)); + + address _tokenAddress = portalTransfersProcessed[ + transferId(_originDomain, _nonce) + ]; + + require( + _tokenAddress != address(0x0), + "Portal Transfer has not yet been completed" + ); + + IERC20 _token = IERC20(_tokenAddress); + + // Transfer the token out to the recipient + // TODO: use safeTransfer + // Portal 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 (_tokenAddress, _amount); + } + + /** + * Completes the Portal transfer which sends the funds to this adapter. + * The router can call receiveTokens to move those funds to the ultimate recipient. + * @param encodedVm The VAA from the Wormhole Guardians + */ + function completeTransfer(bytes memory encodedVm) public { + bytes memory _tokenBridgeTransferWithPayload = portalTokenBridge + .completeTransferWithPayload(encodedVm); + IPortalTokenBridge.TransferWithPayload + memory _transfer = portalTokenBridge.parseTransferWithPayload( + _tokenBridgeTransferWithPayload + ); + + (uint32 _originDomain, uint224 _nonce) = abi.decode( + _transfer.payload, + (uint32, uint224) + ); + + // Logic taken from here https://github.com/wormhole-foundation/wormhole/blob/dev.v2/ethereum/contracts/bridge/Bridge.sol#L503 + address tokenAddress = _transfer.tokenChain == + hyperlaneDomainToWormholeDomain[localDomain] + ? TypeCasts.bytes32ToAddress(_transfer.tokenAddress) + : portalTokenBridge.wrappedAsset( + _transfer.tokenChain, + _transfer.tokenAddress + ); + + portalTransfersProcessed[ + transferId(_originDomain, _nonce) + ] = tokenAddress; + } + + // 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, uint16 _wormholeDomain) + external + onlyOwner + { + hyperlaneDomainToWormholeDomain[_hyperlaneDomain] = _wormholeDomain; + + emit DomainAdded(_hyperlaneDomain, _wormholeDomain); + } + + /** + * The key that is used to track fulfilled Portal transfers + * @param _hyperlaneDomain The hyperlane of the origin + * @param _nonce The nonce of the adapter on the origin + */ + function transferId(uint32 _hyperlaneDomain, uint224 _nonce) + public + pure + returns (bytes32) + { + return bytes32(abi.encodePacked(_hyperlaneDomain, _nonce)); + } +} diff --git a/solidity/contracts/middleware/liquidity-layer/interfaces/circle/ICircleBridge.sol b/solidity/contracts/middleware/liquidity-layer/interfaces/circle/ITokenMessenger.sol similarity index 94% rename from solidity/contracts/middleware/liquidity-layer/interfaces/circle/ICircleBridge.sol rename to solidity/contracts/middleware/liquidity-layer/interfaces/circle/ITokenMessenger.sol index f4c7d7630..4eb9fa58f 100644 --- a/solidity/contracts/middleware/liquidity-layer/interfaces/circle/ICircleBridge.sol +++ b/solidity/contracts/middleware/liquidity-layer/interfaces/circle/ITokenMessenger.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 pragma solidity ^0.8.13; -interface ICircleBridge { +interface ITokenMessenger { event MessageSent(bytes message); /** @@ -9,7 +9,7 @@ interface ICircleBridge { * Emits a `DepositForBurn` event. * @dev reverts if: * - given burnToken is not supported - * - given destinationDomain has no CircleBridge registered + * - given destinationDomain has no TokenMessenger 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. @@ -37,7 +37,7 @@ interface ICircleBridge { * @dev reverts if: * - given destinationCaller is zero address * - given burnToken is not supported - * - given destinationDomain has no CircleBridge registered + * - given destinationDomain has no TokenMessenger 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. diff --git a/solidity/contracts/middleware/liquidity-layer/interfaces/portal/IPortalTokenBridge.sol b/solidity/contracts/middleware/liquidity-layer/interfaces/portal/IPortalTokenBridge.sol new file mode 100644 index 000000000..8c3f77d48 --- /dev/null +++ b/solidity/contracts/middleware/liquidity-layer/interfaces/portal/IPortalTokenBridge.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.13; + +// Portal's interface from their docs +interface IPortalTokenBridge { + struct Transfer { + uint8 payloadID; + uint256 amount; + bytes32 tokenAddress; + uint16 tokenChain; + bytes32 to; + uint16 toChain; + uint256 fee; + } + + struct TransferWithPayload { + uint8 payloadID; + uint256 amount; + bytes32 tokenAddress; + uint16 tokenChain; + bytes32 to; + uint16 toChain; + bytes32 fromAddress; + bytes payload; + } + + struct AssetMeta { + uint8 payloadID; + bytes32 tokenAddress; + uint16 tokenChain; + uint8 decimals; + bytes32 symbol; + bytes32 name; + } + + struct RegisterChain { + bytes32 module; + uint8 action; + uint16 chainId; + uint16 emitterChainID; + bytes32 emitterAddress; + } + + struct UpgradeContract { + bytes32 module; + uint8 action; + uint16 chainId; + bytes32 newContract; + } + + struct RecoverChainId { + bytes32 module; + uint8 action; + uint256 evmChainId; + uint16 newChainId; + } + + event ContractUpgraded( + address indexed oldContract, + address indexed newContract + ); + + function transferTokensWithPayload( + address token, + uint256 amount, + uint16 recipientChain, + bytes32 recipient, + uint32 nonce, + bytes memory payload + ) external payable returns (uint64 sequence); + + function completeTransferWithPayload(bytes memory encodedVm) + external + returns (bytes memory); + + function parseTransferWithPayload(bytes memory encoded) + external + pure + returns (TransferWithPayload memory transfer); + + function wrappedAsset(uint16 tokenChainId, bytes32 tokenAddress) + external + view + returns (address); + + function isWrappedAsset(address token) external view returns (bool); +} diff --git a/solidity/contracts/mock/MockCircleMessageTransmitter.sol b/solidity/contracts/mock/MockCircleMessageTransmitter.sol index d883d9949..8a627d790 100644 --- a/solidity/contracts/mock/MockCircleMessageTransmitter.sol +++ b/solidity/contracts/mock/MockCircleMessageTransmitter.sol @@ -20,7 +20,7 @@ contract MockCircleMessageTransmitter is ICircleMessageTransmitter { success = true; } - function hashSourceAndNonce(uint32 _source, uint256 _nonce) + function hashSourceAndNonce(uint32 _source, uint64 _nonce) public pure returns (bytes32) diff --git a/solidity/contracts/mock/MockCircleBridge.sol b/solidity/contracts/mock/MockCircleTokenMessenger.sol similarity index 84% rename from solidity/contracts/mock/MockCircleBridge.sol rename to solidity/contracts/mock/MockCircleTokenMessenger.sol index 071087a05..5b2e46eb8 100644 --- a/solidity/contracts/mock/MockCircleBridge.sol +++ b/solidity/contracts/mock/MockCircleTokenMessenger.sol @@ -1,10 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 pragma solidity ^0.8.13; -import {ICircleBridge} from "../middleware/liquidity-layer/interfaces/circle/ICircleBridge.sol"; +import {ITokenMessenger} from "../middleware/liquidity-layer/interfaces/circle/ITokenMessenger.sol"; import {MockToken} from "./MockToken.sol"; -contract MockCircleBridge is ICircleBridge { +contract MockCircleTokenMessenger is ITokenMessenger { uint64 public nextNonce = 0; MockToken token; diff --git a/solidity/contracts/mock/MockPortalBridge.sol b/solidity/contracts/mock/MockPortalBridge.sol new file mode 100644 index 000000000..b2b7e8704 --- /dev/null +++ b/solidity/contracts/mock/MockPortalBridge.sol @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.13; + +import {IPortalTokenBridge} from "../middleware/liquidity-layer/interfaces/portal/IPortalTokenBridge.sol"; +import {MockToken} from "./MockToken.sol"; +import {TypeCasts} from "../libs/TypeCasts.sol"; + +contract MockPortalBridge is IPortalTokenBridge { + uint256 nextNonce = 0; + MockToken token; + + constructor(MockToken _token) { + token = _token; + } + + function transferTokensWithPayload( + address, + uint256 amount, + uint16, + bytes32, + uint32, + bytes memory + ) external payable returns (uint64 sequence) { + nextNonce = nextNonce + 1; + token.transferFrom(msg.sender, address(this), amount); + token.burn(amount); + return uint64(nextNonce); + } + + function wrappedAsset(uint16, bytes32) external view returns (address) { + return address(token); + } + + function isWrappedAsset(address) external pure returns (bool) { + return true; + } + + function completeTransferWithPayload(bytes memory encodedVm) + external + returns (bytes memory) + { + (uint32 _originDomain, uint224 _nonce, uint256 _amount) = abi.decode( + encodedVm, + (uint32, uint224, uint256) + ); + + token.mint(msg.sender, _amount); + // Format it so that parseTransferWithPayload returns the desired payload + return + abi.encode( + TypeCasts.addressToBytes32(address(token)), + adapterData(_originDomain, _nonce, address(token)) + ); + } + + function parseTransferWithPayload(bytes memory encoded) + external + pure + returns (TransferWithPayload memory transfer) + { + (bytes32 tokenAddress, bytes memory payload) = abi.decode( + encoded, + (bytes32, bytes) + ); + transfer.payload = payload; + transfer.tokenAddress = tokenAddress; + } + + function adapterData( + uint32 _originDomain, + uint224 _nonce, + address _token + ) public pure returns (bytes memory) { + return + abi.encode( + _originDomain, + _nonce, + TypeCasts.addressToBytes32(_token) + ); + } + + function mockPortalVaa( + uint32 _originDomain, + uint224 _nonce, + uint256 _amount + ) public pure returns (bytes memory) { + return abi.encode(_originDomain, _nonce, _amount); + } +} diff --git a/solidity/test/LiquidityLayerRouter.t.sol b/solidity/test/LiquidityLayerRouter.t.sol index 5fbb0f4f8..f6f8337f7 100644 --- a/solidity/test/LiquidityLayerRouter.t.sol +++ b/solidity/test/LiquidityLayerRouter.t.sol @@ -7,7 +7,7 @@ import {CircleBridgeAdapter} from "../contracts/middleware/liquidity-layer/adapt import {MockToken} from "../contracts/mock/MockToken.sol"; import {TestTokenRecipient} from "../contracts/test/TestTokenRecipient.sol"; import {MockCircleMessageTransmitter} from "../contracts/mock/MockCircleMessageTransmitter.sol"; -import {MockCircleBridge} from "../contracts/mock/MockCircleBridge.sol"; +import {MockCircleTokenMessenger} from "../contracts/mock/MockCircleTokenMessenger.sol"; import {MockHyperlaneEnvironment} from "../contracts/mock/MockHyperlaneEnvironment.sol"; import {TypeCasts} from "../contracts/libs/TypeCasts.sol"; @@ -19,7 +19,7 @@ contract LiquidityLayerRouterTest is Test { LiquidityLayerRouter destinationLiquidityLayerRouter; MockCircleMessageTransmitter messageTransmitter; - MockCircleBridge circleBridge; + MockCircleTokenMessenger tokenMessenger; CircleBridgeAdapter originBridgeAdapter; CircleBridgeAdapter destinationBridgeAdapter; @@ -38,7 +38,7 @@ contract LiquidityLayerRouterTest is Test { function setUp() public { token = new MockToken(); - circleBridge = new MockCircleBridge(token); + tokenMessenger = new MockCircleTokenMessenger(token); messageTransmitter = new MockCircleMessageTransmitter(token); originBridgeAdapter = new CircleBridgeAdapter(); destinationBridgeAdapter = new CircleBridgeAdapter(); @@ -78,14 +78,14 @@ contract LiquidityLayerRouterTest is Test { originBridgeAdapter.initialize( owner, - address(circleBridge), + address(tokenMessenger), address(messageTransmitter), address(originLiquidityLayerRouter) ); destinationBridgeAdapter.initialize( owner, - address(circleBridge), + address(tokenMessenger), address(messageTransmitter), address(destinationLiquidityLayerRouter) ); @@ -224,7 +224,7 @@ contract LiquidityLayerRouterTest is Test { destinationBridgeAdapter.hyperlaneDomainToCircleDomain( originDomain ), - circleBridge.nextNonce() + tokenMessenger.nextNonce() ); messageTransmitter.process( diff --git a/solidity/test/middleware/liquidity-layer/PortalAdapter.t.sol b/solidity/test/middleware/liquidity-layer/PortalAdapter.t.sol new file mode 100644 index 000000000..03a313890 --- /dev/null +++ b/solidity/test/middleware/liquidity-layer/PortalAdapter.t.sol @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import {TypeCasts} from "../../../contracts/libs/TypeCasts.sol"; +import {IPortalTokenBridge} from "../../../contracts/middleware/liquidity-layer/interfaces/portal/IPortalTokenBridge.sol"; +import {PortalAdapter} from "../../../contracts/middleware/liquidity-layer/adapters/PortalAdapter.sol"; +import {TestTokenRecipient} from "../../../contracts/test/TestTokenRecipient.sol"; +import {MockToken} from "../../../contracts/mock/MockToken.sol"; +import {MockPortalBridge} from "../../../contracts/mock/MockPortalBridge.sol"; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +contract PortalAdapterTest is Test { + PortalAdapter originAdapter; + PortalAdapter destinationAdapter; + + MockPortalBridge portalBridge; + + uint32 originDomain = 123; + uint32 destinationDomain = 321; + + TestTokenRecipient recipient; + MockToken token; + + function setUp() public { + token = new MockToken(); + recipient = new TestTokenRecipient(); + + originAdapter = new PortalAdapter(); + destinationAdapter = new PortalAdapter(); + + portalBridge = new MockPortalBridge(token); + + originAdapter.initialize( + originDomain, + address(this), + address(portalBridge), + address(this) + ); + destinationAdapter.initialize( + destinationDomain, + address(this), + address(portalBridge), + address(this) + ); + + originAdapter.enrollRemoteRouter( + destinationDomain, + TypeCasts.addressToBytes32(address(destinationAdapter)) + ); + destinationAdapter.enrollRemoteRouter( + destinationDomain, + TypeCasts.addressToBytes32(address(originAdapter)) + ); + } + + function testAdapter(uint256 amount) public { + // Transfers of 0 are invalid + vm.assume(amount > 0); + // Calls MockPortalBridge with the right paramters + vm.expectCall( + address(portalBridge), + abi.encodeCall( + portalBridge.transferTokensWithPayload, + ( + address(token), + amount, + 0, + TypeCasts.addressToBytes32(address(destinationAdapter)), + 0, + abi.encode(originDomain, originAdapter.nonce() + 1) + ) + ) + ); + token.mint(address(originAdapter), amount); + originAdapter.sendTokens( + destinationDomain, + TypeCasts.addressToBytes32(address(recipient)), + address(token), + amount + ); + } + + function testReceivingRevertsWithoutTransferCompletion(uint256 amount) + public + { + // Transfers of 0 are invalid + vm.assume(amount > 0); + token.mint(address(originAdapter), amount); + bytes memory adapterData = originAdapter.sendTokens( + destinationDomain, + TypeCasts.addressToBytes32(address(recipient)), + address(token), + amount + ); + + vm.expectRevert("Portal Transfer has not yet been completed"); + + destinationAdapter.receiveTokens( + originDomain, + address(recipient), + amount, + adapterData + ); + } + + function testReceivingWorks(uint256 amount) public { + // Transfers of 0 are invalid + vm.assume(amount > 0); + token.mint(address(originAdapter), amount); + bytes memory adapterData = originAdapter.sendTokens( + destinationDomain, + TypeCasts.addressToBytes32(address(recipient)), + address(token), + amount + ); + destinationAdapter.completeTransfer( + portalBridge.mockPortalVaa( + originDomain, + originAdapter.nonce(), + amount + ) + ); + + destinationAdapter.receiveTokens( + originDomain, + address(recipient), + amount, + adapterData + ); + } +} diff --git a/typescript/infra/config/environments/mainnet2/token-bridge.ts b/typescript/infra/config/environments/mainnet2/token-bridge.ts index 56b75515f..83ab5daf3 100644 --- a/typescript/infra/config/environments/mainnet2/token-bridge.ts +++ b/typescript/infra/config/environments/mainnet2/token-bridge.ts @@ -18,14 +18,14 @@ export const circleBridgeAdapterConfig: ChainMap< > = { [Chains.goerli]: { type: BridgeAdapterType.Circle, - circleBridgeAddress: '0xdabec94b97f7b5fca28f050cc8eeac2dc9920476', + tokenMessengerAddress: '0xdabec94b97f7b5fca28f050cc8eeac2dc9920476', messageTransmitterAddress: '0x40a61d3d2afcf5a5d31fcdf269e575fb99dd87f7', usdcAddress: '0x07865c6e87b9f70255377e024ace6630c1eaa37f', circleDomainMapping, }, [Chains.fuji]: { type: BridgeAdapterType.Circle, - circleBridgeAddress: '0x0fc1103927af27af808d03135214718bcedbe9ad', + tokenMessengerAddress: '0x0fc1103927af27af808d03135214718bcedbe9ad', messageTransmitterAddress: '0x52fffb3ee8fa7838e9858a2d5e454007b9027c3c', usdcAddress: '0x5425890298aed601595a70ab815c96711a31bc65', circleDomainMapping, diff --git a/typescript/infra/config/environments/test/liquidityLayer.ts b/typescript/infra/config/environments/test/liquidityLayer.ts index fe9429ecd..5e0ec10aa 100644 --- a/typescript/infra/config/environments/test/liquidityLayer.ts +++ b/typescript/infra/config/environments/test/liquidityLayer.ts @@ -1,8 +1,8 @@ import { + BridgeAdapterConfig, BridgeAdapterType, ChainMap, Chains, - CircleBridgeAdapterConfig, chainMetadata, } from '@hyperlane-xyz/sdk'; @@ -11,22 +11,62 @@ const circleDomainMapping = [ { hyperlaneDomain: chainMetadata[Chains.fuji].id, circleDomain: 1 }, ]; -export const circleBridgeAdapterConfig: ChainMap< - any, - CircleBridgeAdapterConfig -> = { +const wormholeDomainMapping = [ + { hyperlaneDomain: chainMetadata[Chains.goerli].id, wormholeDomain: 2 }, + { hyperlaneDomain: chainMetadata[Chains.fuji].id, wormholeDomain: 6 }, + { hyperlaneDomain: chainMetadata[Chains.mumbai].id, wormholeDomain: 5 }, + { hyperlaneDomain: chainMetadata[Chains.bsctestnet].id, wormholeDomain: 4 }, + { hyperlaneDomain: chainMetadata[Chains.alfajores].id, wormholeDomain: 14 }, +]; + +export const bridgeAdapterConfigs: ChainMap = { [Chains.goerli]: { - type: BridgeAdapterType.Circle, - circleBridgeAddress: '0xdabec94b97f7b5fca28f050cc8eeac2dc9920476', - messageTransmitterAddress: '0x40a61d3d2afcf5a5d31fcdf269e575fb99dd87f7', - usdcAddress: '0x07865c6e87b9f70255377e024ace6630c1eaa37f', - circleDomainMapping, + portal: { + type: BridgeAdapterType.Portal, + portalBridgeAddress: '0xF890982f9310df57d00f659cf4fd87e65adEd8d7', + wormholeDomainMapping, + }, + circle: { + type: BridgeAdapterType.Circle, + tokenMessengerAddress: '0xd0c3da58f55358142b8d3e06c1c30c5c6114efe8', + messageTransmitterAddress: '0x26413e8157cd32011e726065a5462e97dd4d03d9', + usdcAddress: '0x07865c6e87b9f70255377e024ace6630c1eaa37f', + circleDomainMapping, + }, }, [Chains.fuji]: { - type: BridgeAdapterType.Circle, - circleBridgeAddress: '0x0fc1103927af27af808d03135214718bcedbe9ad', - messageTransmitterAddress: '0x52fffb3ee8fa7838e9858a2d5e454007b9027c3c', - usdcAddress: '0x5425890298aed601595a70ab815c96711a31bc65', - circleDomainMapping, + portal: { + type: BridgeAdapterType.Portal, + portalBridgeAddress: '0x61E44E506Ca5659E6c0bba9b678586fA2d729756', + wormholeDomainMapping, + }, + circle: { + type: BridgeAdapterType.Circle, + tokenMessengerAddress: '0xeb08f243e5d3fcff26a9e38ae5520a669f4019d0', + messageTransmitterAddress: '0xa9fb1b3009dcb79e2fe346c16a604b8fa8ae0a79', + usdcAddress: '0x5425890298aed601595a70ab815c96711a31bc65', + circleDomainMapping, + }, + }, + [Chains.mumbai]: { + portal: { + type: BridgeAdapterType.Portal, + portalBridgeAddress: '0x377D55a7928c046E18eEbb61977e714d2a76472a', + wormholeDomainMapping, + }, + }, + [Chains.bsctestnet]: { + portal: { + type: BridgeAdapterType.Portal, + portalBridgeAddress: '0x9dcF9D205C9De35334D646BeE44b2D2859712A09', + wormholeDomainMapping, + }, + }, + [Chains.alfajores]: { + portal: { + type: BridgeAdapterType.Portal, + portalBridgeAddress: '0x05ca6037eC51F8b712eD2E6Fa72219FEaE74E153', + wormholeDomainMapping, + }, }, }; diff --git a/typescript/infra/config/environments/test/middleware/liquidity-layer/addresses.json b/typescript/infra/config/environments/test/middleware/liquidity-layer/addresses.json index cc13cb5b5..8fe20c0ff 100644 --- a/typescript/infra/config/environments/test/middleware/liquidity-layer/addresses.json +++ b/typescript/infra/config/environments/test/middleware/liquidity-layer/addresses.json @@ -1,10 +1,24 @@ { + "fuji": { + "circleBridgeAdapter": "0x17EB33454AAEF8E91510540a0ebF4a8213dd740D", + "portalAdapter": "0xe589a05be1304b43A6FEb9c5D6a6EEEA35656271", + "router": "0x3428e12EfDb2446c1E7feC3f1CED099A8a7cD541" + }, "goerli": { - "circleBridgeAdapter": "0xc262a656c99B3a2f1B196dc5BeDa8f4f80D4a878", - "router": "0x952228cA63f85130534981844050c82b89f373E7" + "circleBridgeAdapter": "0x17EB33454AAEF8E91510540a0ebF4a8213dd740D", + "portalAdapter": "0xe589a05be1304b43A6FEb9c5D6a6EEEA35656271", + "router": "0x3428e12EfDb2446c1E7feC3f1CED099A8a7cD541" }, - "fuji": { - "circleBridgeAdapter": "0xc262a656c99B3a2f1B196dc5BeDa8f4f80D4a878", - "router": "0x952228cA63f85130534981844050c82b89f373E7" + "mumbai": { + "portalAdapter": "0xe589a05be1304b43A6FEb9c5D6a6EEEA35656271", + "router": "0x3428e12EfDb2446c1E7feC3f1CED099A8a7cD541" + }, + "bsctestnet": { + "portalAdapter": "0xe589a05be1304b43A6FEb9c5D6a6EEEA35656271", + "router": "0x3428e12EfDb2446c1E7feC3f1CED099A8a7cD541" + }, + "alfajores": { + "portalAdapter": "0xe589a05be1304b43A6FEb9c5D6a6EEEA35656271", + "router": "0x3428e12EfDb2446c1E7feC3f1CED099A8a7cD541" } } diff --git a/typescript/infra/config/environments/test/middleware/liquidity-layer/verification.json b/typescript/infra/config/environments/test/middleware/liquidity-layer/verification.json index 9d80efc5e..20d474c4a 100644 --- a/typescript/infra/config/environments/test/middleware/liquidity-layer/verification.json +++ b/typescript/infra/config/environments/test/middleware/liquidity-layer/verification.json @@ -1,28 +1,106 @@ { - "goerli": [ + "fuji": [ + { + "name": "LiquidityLayerRouter", + "address": "0x3428e12EfDb2446c1E7feC3f1CED099A8a7cD541", + "isProxy": false, + "constructorArguments": "" + }, + { + "name": "CircleBridgeAdapter", + "address": "0xb54AD7AE42B7c505100594365CdBC4b28Ef51FE6", + "isProxy": false, + "constructorArguments": "" + }, + { + "name": "PortalAdapter", + "address": "0xe589a05be1304b43A6FEb9c5D6a6EEEA35656271", + "isProxy": false, + "constructorArguments": "" + }, { - "name": "TokenBridgeRouter", - "address": "0x952228cA63f85130534981844050c82b89f373E7", + "name": "CircleBridgeAdapter", + "address": "0x54FCA26E5FF828847D8caF471e44cD5727C73B0d", "isProxy": false, "constructorArguments": "" }, { "name": "CircleBridgeAdapter", - "address": "0xc262a656c99B3a2f1B196dc5BeDa8f4f80D4a878", + "address": "0x17EB33454AAEF8E91510540a0ebF4a8213dd740D", "isProxy": false, "constructorArguments": "" } ], - "fuji": [ + "goerli": [ { - "name": "TokenBridgeRouter", - "address": "0x952228cA63f85130534981844050c82b89f373E7", + "name": "LiquidityLayerRouter", + "address": "0x3428e12EfDb2446c1E7feC3f1CED099A8a7cD541", "isProxy": false, "constructorArguments": "" }, { "name": "CircleBridgeAdapter", - "address": "0xc262a656c99B3a2f1B196dc5BeDa8f4f80D4a878", + "address": "0xb54AD7AE42B7c505100594365CdBC4b28Ef51FE6", + "isProxy": false, + "constructorArguments": "" + }, + { + "name": "PortalAdapter", + "address": "0xe589a05be1304b43A6FEb9c5D6a6EEEA35656271", + "isProxy": false, + "constructorArguments": "" + }, + { + "name": "CircleBridgeAdapter", + "address": "0x54FCA26E5FF828847D8caF471e44cD5727C73B0d", + "isProxy": false, + "constructorArguments": "" + }, + { + "name": "CircleBridgeAdapter", + "address": "0x17EB33454AAEF8E91510540a0ebF4a8213dd740D", + "isProxy": false, + "constructorArguments": "" + } + ], + "mumbai": [ + { + "name": "LiquidityLayerRouter", + "address": "0x3428e12EfDb2446c1E7feC3f1CED099A8a7cD541", + "isProxy": false, + "constructorArguments": "" + }, + { + "name": "PortalAdapter", + "address": "0xe589a05be1304b43A6FEb9c5D6a6EEEA35656271", + "isProxy": false, + "constructorArguments": "" + } + ], + "bsctestnet": [ + { + "name": "LiquidityLayerRouter", + "address": "0x3428e12EfDb2446c1E7feC3f1CED099A8a7cD541", + "isProxy": false, + "constructorArguments": "" + }, + { + "name": "PortalAdapter", + "address": "0xe589a05be1304b43A6FEb9c5D6a6EEEA35656271", + "isProxy": false, + "constructorArguments": "" + } + ], + "alfajores": [ + { + "name": "LiquidityLayerRouter", + "address": "0x3428e12EfDb2446c1E7feC3f1CED099A8a7cD541", + "isProxy": false, + "constructorArguments": "" + }, + { + "name": "PortalAdapter", + "address": "0xe589a05be1304b43A6FEb9c5D6a6EEEA35656271", "isProxy": false, "constructorArguments": "" } diff --git a/typescript/infra/config/environments/testnet3/index.ts b/typescript/infra/config/environments/testnet3/index.ts index b71136777..8e432b992 100644 --- a/typescript/infra/config/environments/testnet3/index.ts +++ b/typescript/infra/config/environments/testnet3/index.ts @@ -14,6 +14,7 @@ import { core } from './core'; import { keyFunderConfig } from './funding'; import { helloWorld } from './helloworld'; import { infrastructure } from './infrastructure'; +import { liquidityLayerRelayerConfig } from './middleware'; export const environment: CoreEnvironmentConfig = { environment: environmentName, @@ -36,4 +37,5 @@ export const environment: CoreEnvironmentConfig = { infra: infrastructure, helloWorld, keyFunderConfig, + liquidityLayerRelayerConfig, }; diff --git a/typescript/infra/config/environments/testnet3/middleware.ts b/typescript/infra/config/environments/testnet3/middleware.ts new file mode 100644 index 000000000..a17df72c8 --- /dev/null +++ b/typescript/infra/config/environments/testnet3/middleware.ts @@ -0,0 +1,15 @@ +import { ConnectionType } from '../../../src/config/agent'; +import { LiquidityLayerRelayerConfig } from '../../../src/config/middleware'; + +import { environment } from './chains'; + +export const liquidityLayerRelayerConfig: LiquidityLayerRelayerConfig = { + docker: { + repo: 'gcr.io/abacus-labs-dev/hyperlane-monorepo', + tag: 'sha-760a16b', + }, + namespace: environment, + prometheusPushGateway: + 'http://prometheus-pushgateway.monitoring.svc.cluster.local:9091', + connectionType: ConnectionType.Http, +}; diff --git a/typescript/infra/config/environments/testnet3/token-bridge.ts b/typescript/infra/config/environments/testnet3/token-bridge.ts index fe9429ecd..5e0ec10aa 100644 --- a/typescript/infra/config/environments/testnet3/token-bridge.ts +++ b/typescript/infra/config/environments/testnet3/token-bridge.ts @@ -1,8 +1,8 @@ import { + BridgeAdapterConfig, BridgeAdapterType, ChainMap, Chains, - CircleBridgeAdapterConfig, chainMetadata, } from '@hyperlane-xyz/sdk'; @@ -11,22 +11,62 @@ const circleDomainMapping = [ { hyperlaneDomain: chainMetadata[Chains.fuji].id, circleDomain: 1 }, ]; -export const circleBridgeAdapterConfig: ChainMap< - any, - CircleBridgeAdapterConfig -> = { +const wormholeDomainMapping = [ + { hyperlaneDomain: chainMetadata[Chains.goerli].id, wormholeDomain: 2 }, + { hyperlaneDomain: chainMetadata[Chains.fuji].id, wormholeDomain: 6 }, + { hyperlaneDomain: chainMetadata[Chains.mumbai].id, wormholeDomain: 5 }, + { hyperlaneDomain: chainMetadata[Chains.bsctestnet].id, wormholeDomain: 4 }, + { hyperlaneDomain: chainMetadata[Chains.alfajores].id, wormholeDomain: 14 }, +]; + +export const bridgeAdapterConfigs: ChainMap = { [Chains.goerli]: { - type: BridgeAdapterType.Circle, - circleBridgeAddress: '0xdabec94b97f7b5fca28f050cc8eeac2dc9920476', - messageTransmitterAddress: '0x40a61d3d2afcf5a5d31fcdf269e575fb99dd87f7', - usdcAddress: '0x07865c6e87b9f70255377e024ace6630c1eaa37f', - circleDomainMapping, + portal: { + type: BridgeAdapterType.Portal, + portalBridgeAddress: '0xF890982f9310df57d00f659cf4fd87e65adEd8d7', + wormholeDomainMapping, + }, + circle: { + type: BridgeAdapterType.Circle, + tokenMessengerAddress: '0xd0c3da58f55358142b8d3e06c1c30c5c6114efe8', + messageTransmitterAddress: '0x26413e8157cd32011e726065a5462e97dd4d03d9', + usdcAddress: '0x07865c6e87b9f70255377e024ace6630c1eaa37f', + circleDomainMapping, + }, }, [Chains.fuji]: { - type: BridgeAdapterType.Circle, - circleBridgeAddress: '0x0fc1103927af27af808d03135214718bcedbe9ad', - messageTransmitterAddress: '0x52fffb3ee8fa7838e9858a2d5e454007b9027c3c', - usdcAddress: '0x5425890298aed601595a70ab815c96711a31bc65', - circleDomainMapping, + portal: { + type: BridgeAdapterType.Portal, + portalBridgeAddress: '0x61E44E506Ca5659E6c0bba9b678586fA2d729756', + wormholeDomainMapping, + }, + circle: { + type: BridgeAdapterType.Circle, + tokenMessengerAddress: '0xeb08f243e5d3fcff26a9e38ae5520a669f4019d0', + messageTransmitterAddress: '0xa9fb1b3009dcb79e2fe346c16a604b8fa8ae0a79', + usdcAddress: '0x5425890298aed601595a70ab815c96711a31bc65', + circleDomainMapping, + }, + }, + [Chains.mumbai]: { + portal: { + type: BridgeAdapterType.Portal, + portalBridgeAddress: '0x377D55a7928c046E18eEbb61977e714d2a76472a', + wormholeDomainMapping, + }, + }, + [Chains.bsctestnet]: { + portal: { + type: BridgeAdapterType.Portal, + portalBridgeAddress: '0x9dcF9D205C9De35334D646BeE44b2D2859712A09', + wormholeDomainMapping, + }, + }, + [Chains.alfajores]: { + portal: { + type: BridgeAdapterType.Portal, + portalBridgeAddress: '0x05ca6037eC51F8b712eD2E6Fa72219FEaE74E153', + wormholeDomainMapping, + }, }, }; diff --git a/typescript/infra/helm/liquidity-layer-relayers/Chart.yaml b/typescript/infra/helm/liquidity-layer-relayers/Chart.yaml new file mode 100644 index 000000000..ef2f1888a --- /dev/null +++ b/typescript/infra/helm/liquidity-layer-relayers/Chart.yaml @@ -0,0 +1,24 @@ +apiVersion: v2 +name: liquidity-layer-relayers +description: Liquidity Layer Relayers + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: '1.16.0' diff --git a/typescript/infra/helm/liquidity-layer-relayers/templates/_helpers.tpl b/typescript/infra/helm/liquidity-layer-relayers/templates/_helpers.tpl new file mode 100644 index 000000000..f0752fcf9 --- /dev/null +++ b/typescript/infra/helm/liquidity-layer-relayers/templates/_helpers.tpl @@ -0,0 +1,42 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "hyperlane.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "hyperlane.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "hyperlane.labels" -}} +helm.sh/chart: {{ include "hyperlane.chart" . }} +hyperlane/deployment: {{ .Values.hyperlane.runEnv | quote }} +hyperlane/context: "hyperlane" +{{ include "hyperlane.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "hyperlane.selectorLabels" -}} +app.kubernetes.io/name: {{ include "hyperlane.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +The name of the ClusterSecretStore +*/}} +{{- define "hyperlane.cluster-secret-store.name" -}} +{{- default "external-secrets-gcp-cluster-secret-store" .Values.externalSecrets.clusterSecretStore }} +{{- end }} diff --git a/typescript/infra/helm/liquidity-layer-relayers/templates/circle-relayer-deployment.yaml b/typescript/infra/helm/liquidity-layer-relayers/templates/circle-relayer-deployment.yaml new file mode 100644 index 000000000..5d24455f8 --- /dev/null +++ b/typescript/infra/helm/liquidity-layer-relayers/templates/circle-relayer-deployment.yaml @@ -0,0 +1,30 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: circle-relayer +spec: + replicas: 1 + selector: + matchLabels: + name: circle-relayer + template: + metadata: + labels: + name: circle-relayer + spec: + containers: + - name: circle-relayer + image: {{ .Values.image.repository }}:{{ .Values.image.tag }} + imagePullPolicy: IfNotPresent + command: + - ./node_modules/.bin/ts-node + - ./typescript/infra/scripts/middleware/circle-relayer.ts + - -e + - {{ .Values.hyperlane.runEnv }} +{{- if .Values.hyperlane.connectionType }} + - --connection-type + - {{ .Values.hyperlane.connectionType }} +{{- end }} + envFrom: + - secretRef: + name: liquidity-layer-env-var-secret diff --git a/typescript/infra/helm/liquidity-layer-relayers/templates/env-var-external-secret.yaml b/typescript/infra/helm/liquidity-layer-relayers/templates/env-var-external-secret.yaml new file mode 100644 index 000000000..cdc281350 --- /dev/null +++ b/typescript/infra/helm/liquidity-layer-relayers/templates/env-var-external-secret.yaml @@ -0,0 +1,55 @@ +apiVersion: external-secrets.io/v1beta1 +kind: ExternalSecret +metadata: + name: liquidity-layer-env-var-external-secret + labels: + {{- include "hyperlane.labels" . | nindent 4 }} +spec: + secretStoreRef: + name: {{ include "hyperlane.cluster-secret-store.name" . }} + kind: ClusterSecretStore + refreshInterval: "1h" + # The secret that will be created + target: + name: liquidity-layer-env-var-secret + template: + type: Opaque + metadata: + labels: + {{- include "hyperlane.labels" . | nindent 10 }} + annotations: + update-on-redeploy: "{{ now }}" + data: + GCP_SECRET_OVERRIDES_ENABLED: "true" + GCP_SECRET_OVERRIDE_ABACUS_{{ .Values.hyperlane.runEnv | upper }}_KEY_DEPLOYER: {{ print "'{{ .deployer_key | toString }}'" }} +{{/* + * For each network, create an environment variable with the RPC endpoint. + * The templating of external-secrets will use the data section below to know how + * to replace the correct value in the created secret. + */}} + {{- range .Values.hyperlane.chains }} + {{- if eq $.Values.hyperlane.connectionType "httpQuorum" }} + GCP_SECRET_OVERRIDE_{{ $.Values.hyperlane.runEnv | upper }}_RPC_ENDPOINTS_{{ . | upper }}: {{ printf "'{{ .%s_rpcs | toString }}'" . }} + {{- else }} + GCP_SECRET_OVERRIDE_{{ $.Values.hyperlane.runEnv | upper }}_RPC_ENDPOINT_{{ . | upper }}: {{ printf "'{{ .%s_rpc | toString }}'" . }} + {{- end }} + {{- end }} + data: + - secretKey: deployer_key + remoteRef: + key: {{ printf "hyperlane-%s-key-deployer" .Values.hyperlane.runEnv }} +{{/* + * For each network, load the secret in GCP secret manager with the form: environment-rpc-endpoint-network, + * and associate it with the secret key networkname_rpc. + */}} + {{- range .Values.hyperlane.chains }} + {{- if eq $.Values.hyperlane.connectionType "httpQuorum" }} + - secretKey: {{ printf "%s_rpcs" . }} + remoteRef: + key: {{ printf "%s-rpc-endpoints-%s" $.Values.hyperlane.runEnv . }} + {{- else }} + - secretKey: {{ printf "%s_rpc" . }} + remoteRef: + key: {{ printf "%s-rpc-endpoint-%s" $.Values.hyperlane.runEnv . }} + {{- end }} + {{- end }} diff --git a/typescript/infra/helm/liquidity-layer-relayers/templates/portal-relayer-deployment.yaml b/typescript/infra/helm/liquidity-layer-relayers/templates/portal-relayer-deployment.yaml new file mode 100644 index 000000000..d43ee5bf4 --- /dev/null +++ b/typescript/infra/helm/liquidity-layer-relayers/templates/portal-relayer-deployment.yaml @@ -0,0 +1,30 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: portal-relayer +spec: + replicas: 1 + selector: + matchLabels: + name: portal-relayer + template: + metadata: + labels: + name: portal-relayer + spec: + containers: + - name: portal-relayer + image: {{ .Values.image.repository }}:{{ .Values.image.tag }} + imagePullPolicy: IfNotPresent + command: + - ./node_modules/.bin/ts-node + - ./typescript/infra/scripts/middleware/portal-relayer.ts + - -e + - {{ .Values.hyperlane.runEnv }} +{{- if .Values.hyperlane.connectionType }} + - --connection-type + - {{ .Values.hyperlane.connectionType }} +{{- end }} + envFrom: + - secretRef: + name: liquidity-layer-env-var-secret diff --git a/typescript/infra/helm/liquidity-layer-relayers/values.yaml b/typescript/infra/helm/liquidity-layer-relayers/values.yaml new file mode 100644 index 000000000..8872b897a --- /dev/null +++ b/typescript/infra/helm/liquidity-layer-relayers/values.yaml @@ -0,0 +1,9 @@ +image: + repository: gcr.io/abacus-labs-dev/hyperlane-monorepo + tag: +abacus: + runEnv: testnet2 + # Used for fetching secrets + chains: [] +externalSecrets: + clusterSecretStore: diff --git a/typescript/infra/scripts/helloworld/kathy.ts b/typescript/infra/scripts/helloworld/kathy.ts index 5c331a780..57e01b20e 100644 --- a/typescript/infra/scripts/helloworld/kathy.ts +++ b/typescript/infra/scripts/helloworld/kathy.ts @@ -16,7 +16,7 @@ import { ConnectionType } from '../../src/config/agent'; import { deployEnvToSdkEnv } from '../../src/config/environment'; import { startMetricsServer } from '../../src/utils/metrics'; import { assertChain, diagonalize, sleep } from '../../src/utils/utils'; -import { getArgs, getCoreEnvironmentConfig } from '../utils'; +import { getArgsWithContext, getCoreEnvironmentConfig } from '../utils'; import { getApp } from './utils'; @@ -64,7 +64,7 @@ const walletBalance = new Gauge({ const MAX_MESSAGES_ALLOWED_TO_SEND = 5; function getKathyArgs() { - const args = getArgs() + const args = getArgsWithContext() .boolean('cycle-once') .describe( 'cycle-once', diff --git a/typescript/infra/scripts/circle-relayer.ts b/typescript/infra/scripts/middleware/circle-relayer.ts similarity index 76% rename from typescript/infra/scripts/circle-relayer.ts rename to typescript/infra/scripts/middleware/circle-relayer.ts index a1b4ec448..47fb04f73 100644 --- a/typescript/infra/scripts/circle-relayer.ts +++ b/typescript/infra/scripts/middleware/circle-relayer.ts @@ -6,17 +6,15 @@ import { LiquidityLayerApp, buildContracts, liquidityLayerFactories, - objMap, } from '@hyperlane-xyz/sdk'; -import { circleBridgeAdapterConfig } from '../config/environments/test/liquidityLayer'; -import { readJSON, sleep } from '../src/utils/utils'; - +import { bridgeAdapterConfigs } from '../../config/environments/testnet3/token-bridge'; +import { readJSON, sleep } from '../../src/utils/utils'; import { getCoreEnvironmentConfig, getEnvironment, getEnvironmentDirectory, -} from './utils'; +} from '../utils'; async function check() { const environment = await getEnvironment(); @@ -24,7 +22,7 @@ async function check() { const multiProvider = await config.getMultiProvider(); const dir = path.join( __dirname, - '../', + '../../', getEnvironmentDirectory(environment), 'middleware/liquidity-layer', ); @@ -37,7 +35,7 @@ async function check() { const app = new LiquidityLayerApp( contracts, multiProvider, - objMap(circleBridgeAdapterConfig, (_chain, conf) => [conf]), + bridgeAdapterConfigs, ); while (true) { @@ -51,11 +49,9 @@ async function check() { ).flat(); // Poll for attestation data and submit - await Promise.all( - circleDispatches.map((message) => - app.attemptCircleAttestationSubmission(message), - ), - ); + for (const message of circleDispatches) { + await app.attemptCircleAttestationSubmission(message); + } await sleep(6000); } diff --git a/typescript/infra/scripts/middleware/deploy-liquidity-layer.ts b/typescript/infra/scripts/middleware/deploy-liquidity-layer.ts index e374a033e..9e94dde89 100644 --- a/typescript/infra/scripts/middleware/deploy-liquidity-layer.ts +++ b/typescript/infra/scripts/middleware/deploy-liquidity-layer.ts @@ -7,7 +7,7 @@ import { objMap, } from '@hyperlane-xyz/sdk'; -import { circleBridgeAdapterConfig } from '../../config/environments/test/liquidityLayer'; +import { bridgeAdapterConfigs } from '../../config/environments/testnet3/token-bridge'; import { deployEnvToSdkEnv } from '../../src/config/environment'; import { deployWithArtifacts } from '../../src/deploy'; import { getConfiguration } from '../helloworld/utils'; @@ -33,17 +33,16 @@ async function main() { // config gcp deployer key as owner const ownerConfigMap = await getConfiguration(environment, multiProvider); - + const config = objMap(bridgeAdapterConfigs, (chain, conf) => ({ + ...conf, + ...ownerConfigMap[chain], + })); const deployer = new LiquidityLayerDeployer( multiProvider, - objMap(circleBridgeAdapterConfig, (chain, conf) => ({ - bridgeAdapterConfigs: [conf], - ...ownerConfigMap[chain], - })), + config, core, 'LiquidityLayerDeploy2', ); - await deployWithArtifacts(dir, liquidityLayerFactories, deployer); } diff --git a/typescript/infra/scripts/middleware/deploy-relayers.ts b/typescript/infra/scripts/middleware/deploy-relayers.ts new file mode 100644 index 000000000..05dda8cd4 --- /dev/null +++ b/typescript/infra/scripts/middleware/deploy-relayers.ts @@ -0,0 +1,34 @@ +import { Contexts } from '../../config/contexts'; +import { + getLiquidityLayerRelayerConfig, + runLiquidityLayerRelayerHelmCommand, +} from '../../src/middleware/liquidity-layer-relayer'; +import { HelmCommand } from '../../src/utils/helm'; +import { + assertCorrectKubeContext, + getContextAgentConfig, + getEnvironmentConfig, +} from '../utils'; + +async function main() { + const coreConfig = await getEnvironmentConfig(); + + await assertCorrectKubeContext(coreConfig); + + const liquidityLayerRelayerConfig = + getLiquidityLayerRelayerConfig(coreConfig); + const agentConfig = await getContextAgentConfig( + coreConfig, + Contexts.Hyperlane, + ); + + await runLiquidityLayerRelayerHelmCommand( + HelmCommand.InstallOrUpgrade, + agentConfig, + liquidityLayerRelayerConfig, + ); +} + +main() + .then(() => console.log('Deploy successful!')) + .catch(console.error); diff --git a/typescript/infra/scripts/middleware/portal-relayer.ts b/typescript/infra/scripts/middleware/portal-relayer.ts new file mode 100644 index 000000000..414194d28 --- /dev/null +++ b/typescript/infra/scripts/middleware/portal-relayer.ts @@ -0,0 +1,58 @@ +import path from 'path'; + +import { + ChainMap, + LiquidityLayerApp, + buildContracts, + liquidityLayerFactories, +} from '@hyperlane-xyz/sdk'; + +import { bridgeAdapterConfigs } from '../../config/environments/testnet3/token-bridge'; +import { readJSON, sleep } from '../../src/utils/utils'; +import { + getCoreEnvironmentConfig, + getEnvironment, + getEnvironmentDirectory, +} from '../utils'; + +async function relayPortalTransfers() { + const environment = await getEnvironment(); + const config = getCoreEnvironmentConfig(environment); + const multiProvider = await config.getMultiProvider(); + const dir = path.join( + __dirname, + '../../', + getEnvironmentDirectory(environment), + 'middleware/liquidity-layer', + ); + const addresses = readJSON(dir, 'addresses.json'); + // @ts-ignore + const contracts: ChainMap = buildContracts( + addresses, + liquidityLayerFactories, + ); + const app = new LiquidityLayerApp( + contracts, + multiProvider, + bridgeAdapterConfigs, + ); + + while (true) { + for (const chain of Object.keys(bridgeAdapterConfigs)) { + const txHashes = await app.fetchPortalBridgeTransactions(chain); + const portalMessages = ( + await Promise.all( + txHashes.map((txHash) => app.parsePortalMessages(chain, txHash)), + ) + ).flat(); + + // Poll for attestation data and submit + for (const message of portalMessages) { + await app.attemptPortalTransferCompletion(message); + } + await sleep(10000); + } + } +} + +relayPortalTransfers().then(console.log).catch(console.error); diff --git a/typescript/infra/scripts/utils.ts b/typescript/infra/scripts/utils.ts index fa1c6f49b..13f9f9ac8 100644 --- a/typescript/infra/scripts/utils.ts +++ b/typescript/infra/scripts/utils.ts @@ -23,16 +23,20 @@ import { fetchProvider } from '../src/config/chain'; import { EnvironmentNames } from '../src/config/environment'; import { assertContext } from '../src/utils/utils'; +export function getArgsWithContext() { + return getArgs() + .describe('context', 'deploy context') + .coerce('context', assertContext) + .demandOption('context') + .alias('c', 'context'); +} + export function getArgs() { return yargs(process.argv.slice(2)) .describe('environment', 'deploy environment') .coerce('environment', assertEnvironment) .demandOption('environment') - .alias('e', 'environment') - .describe('context', 'deploy context') - .coerce('context', assertContext) - .demandOption('context') - .alias('c', 'context'); + .alias('e', 'environment'); } export async function getEnvironmentFromArgs(): Promise { @@ -64,7 +68,8 @@ export async function getEnvironmentConfig() { } export async function getContext(defaultContext?: string): Promise { - const argv = await getArgs().argv; + const argv = await getArgsWithContext().argv; + // @ts-ignore return assertContext(argv.context! || defaultContext!); } diff --git a/typescript/infra/src/config/environment.ts b/typescript/infra/src/config/environment.ts index 65e6120f3..6a8a4e6a3 100644 --- a/typescript/infra/src/config/environment.ts +++ b/typescript/infra/src/config/environment.ts @@ -15,6 +15,7 @@ import { AgentConfig, ConnectionType } from './agent'; import { KeyFunderConfig } from './funding'; import { HelloWorldConfig } from './helloworld'; import { InfrastructureConfig } from './infrastructure'; +import { LiquidityLayerRelayerConfig } from './middleware'; export const EnvironmentNames = Object.keys(environments); export type DeployEnvironment = keyof typeof environments; @@ -37,6 +38,7 @@ export type CoreEnvironmentConfig = { ) => Promise>; helloWorld?: Partial>>; keyFunderConfig?: KeyFunderConfig; + liquidityLayerRelayerConfig?: LiquidityLayerRelayerConfig; }; export type SdkEnvironment = keyof typeof coreEnvironments; diff --git a/typescript/infra/src/config/middleware.ts b/typescript/infra/src/config/middleware.ts new file mode 100644 index 000000000..0a0cd364e --- /dev/null +++ b/typescript/infra/src/config/middleware.ts @@ -0,0 +1,8 @@ +import { ConnectionType, DockerConfig } from './agent'; + +export interface LiquidityLayerRelayerConfig { + docker: DockerConfig; + namespace: string; + connectionType: ConnectionType.Http | ConnectionType.HttpQuorum; + prometheusPushGateway: string; +} diff --git a/typescript/infra/src/middleware/liquidity-layer-relayer.ts b/typescript/infra/src/middleware/liquidity-layer-relayer.ts new file mode 100644 index 000000000..bec062e68 --- /dev/null +++ b/typescript/infra/src/middleware/liquidity-layer-relayer.ts @@ -0,0 +1,73 @@ +import { ChainName } from '@hyperlane-xyz/sdk'; + +import { AgentConfig, CoreEnvironmentConfig } from '../config'; +import { LiquidityLayerRelayerConfig } from '../config/middleware'; +import { HelmCommand, helmifyValues } from '../utils/helm'; +import { execCmd } from '../utils/utils'; + +export async function runLiquidityLayerRelayerHelmCommand< + Chain extends ChainName, +>( + helmCommand: HelmCommand, + agentConfig: AgentConfig, + relayerConfig: LiquidityLayerRelayerConfig, +) { + const values = getLiquidityLayerRelayerHelmValues(agentConfig, relayerConfig); + + if (helmCommand === HelmCommand.InstallOrUpgrade) { + // Delete secrets to avoid them being stale + try { + await execCmd( + `kubectl delete secrets --namespace ${agentConfig.namespace} --selector app.kubernetes.io/instance=liquidity-layer-relayers`, + {}, + false, + false, + ); + } catch (e) { + console.error(e); + } + } + + return execCmd( + `helm ${helmCommand} liquidity-layer-relayers ./helm/liquidity-layer-relayers --namespace ${ + relayerConfig.namespace + } ${values.join(' ')}`, + {}, + false, + true, + ); +} + +function getLiquidityLayerRelayerHelmValues( + agentConfig: AgentConfig, + relayerConfig: LiquidityLayerRelayerConfig, +) { + const values = { + hyperlane: { + runEnv: agentConfig.environment, + // Only used for fetching RPC urls as env vars + chains: agentConfig.contextChainNames, + connectionType: relayerConfig.connectionType, + }, + image: { + repository: relayerConfig.docker.repo, + tag: relayerConfig.docker.tag, + }, + infra: { + prometheusPushGateway: relayerConfig.prometheusPushGateway, + }, + }; + return helmifyValues(values); +} + +export function getLiquidityLayerRelayerConfig( + coreConfig: CoreEnvironmentConfig, +): LiquidityLayerRelayerConfig { + const relayerConfig = coreConfig.liquidityLayerRelayerConfig; + if (!relayerConfig) { + throw new Error( + `Environment ${coreConfig.environment} does not have a LiquidityLayerRelayerConfig config`, + ); + } + return relayerConfig; +} diff --git a/typescript/sdk/src/deploy/middleware/LiquidityLayerApp.ts b/typescript/sdk/src/deploy/middleware/LiquidityLayerApp.ts index c2bd0f76f..d5a55e206 100644 --- a/typescript/sdk/src/deploy/middleware/LiquidityLayerApp.ts +++ b/typescript/sdk/src/deploy/middleware/LiquidityLayerApp.ts @@ -3,31 +3,41 @@ import { ethers } from 'ethers'; import { CircleBridgeAdapter__factory, - ICircleBridge__factory, ICircleMessageTransmitter__factory, + ITokenMessenger__factory, + PortalAdapter__factory, } from '@hyperlane-xyz/core'; +import { utils } from '@hyperlane-xyz/utils'; import { HyperlaneApp } from '../../HyperlaneApp'; import { Chains } from '../../consts/chains'; +import { ChainNameToDomainId, DomainIdToChainName } from '../../domains'; import { LiquidityLayerContracts } from '../../middleware'; import { MultiProvider } from '../../providers/MultiProvider'; import { ChainMap, ChainName } from '../../types'; -import { objMap } from '../../utils/objects'; -import { - BridgeAdapterConfig, - BridgeAdapterType, - CircleBridgeAdapterConfig, -} from './LiquidityLayerRouterDeployer'; +import { BridgeAdapterConfig } from './LiquidityLayerRouterDeployer'; + +const PORTAL_VAA_SERVICE_TESTNET_BASE_URL = + 'https://wormhole-v2-testnet-api.certus.one/v1/signed_vaa/'; +const CIRCLE_ATTESTATIONS_BASE_URL = + 'https://iris-api-sandbox.circle.com/attestations/'; -const CircleBridgeInterface = ICircleBridge__factory.createInterface(); +const PORTAL_VAA_SERVICE_SUCCESS_CODE = 5; + +const TokenMessengerInterface = ITokenMessenger__factory.createInterface(); const CircleBridgeAdapterInterface = CircleBridgeAdapter__factory.createInterface(); +const PortalAdapterInterface = PortalAdapter__factory.createInterface(); const BridgedTokenTopic = CircleBridgeAdapterInterface.getEventTopic( CircleBridgeAdapterInterface.getEvent('BridgedToken'), ); +const PortalBridgedTokenTopic = PortalAdapterInterface.getEventTopic( + PortalAdapterInterface.getEvent('BridgedToken'), +); + interface CircleBridgeMessage { chain: Chain; remoteChain: Chain; @@ -37,30 +47,25 @@ interface CircleBridgeMessage { domain: number; nonceHash: string; } + +interface PortalBridgeMessage { + origin: Chain; + nonce: number; + portalSequence: number; + destination: Chain; +} + export class LiquidityLayerApp< Chain extends ChainName = ChainName, > extends HyperlaneApp { constructor( public readonly contractsMap: ChainMap, public readonly multiProvider: MultiProvider, - public readonly bridgeAdapterConfigs: ChainMap< - Chain, - BridgeAdapterConfig[] - >, + public readonly config: ChainMap, ) { super(contractsMap, multiProvider); } - circleBridgeAdapterConfig(): ChainMap { - return objMap( - this.bridgeAdapterConfigs, - (_chain, config) => - config.find( - (_) => _.type === BridgeAdapterType.Circle, - ) as CircleBridgeAdapterConfig, - ); - } - async fetchCircleMessageTransactions(chain: Chain): Promise { const cc = this.multiProvider.getChainConnection(chain); const params = new URLSearchParams({ @@ -75,6 +80,46 @@ export class LiquidityLayerApp< return response.result.map((_: any) => _.transactionHash).flat(); } + async fetchPortalBridgeTransactions(chain: Chain): Promise { + const cc = this.multiProvider.getChainConnection(chain); + const params = new URLSearchParams({ + module: 'logs', + action: 'getLogs', + address: this.getContracts(chain).portalAdapter!.address, + topic0: PortalBridgedTokenTopic, + }); + const req = await fetch(`${cc.getApiUrl()}?${params}`); + const response = await req.json(); + + return response.result.map((_: any) => _.transactionHash).flat(); + } + + async parsePortalMessages( + chain: Chain, + txHash: string, + ): Promise[]> { + const connection = this.multiProvider.getChainConnection(chain); + const receipt = await connection.provider.getTransactionReceipt(txHash); + const matchingLogs = receipt.logs + .map((_) => { + try { + return [PortalAdapterInterface.parseLog(_)]; + } catch { + return []; + } + }) + .flat(); + if (matchingLogs.length == 0) return []; + + const event = matchingLogs.find((_) => _!.name === 'BridgedToken')!; + const portalSequence = event.args.portalSequence.toNumber(); + const nonce = event.args.nonce.toNumber(); + const destination = DomainIdToChainName[event.args.destination]; + + return [ + { origin: chain, nonce, portalSequence, destination }, + ] as PortalBridgeMessage[]; + } async parseCircleMessages( chain: Chain, txHash: string, @@ -84,7 +129,7 @@ export class LiquidityLayerApp< const matchingLogs = receipt.logs .map((_) => { try { - return [CircleBridgeInterface.parseLog(_)]; + return [TokenMessengerInterface.parseLog(_)]; } catch { try { return [CircleBridgeAdapterInterface.parseLog(_)]; @@ -100,8 +145,10 @@ export class LiquidityLayerApp< .message; const nonce = matchingLogs.find((_) => _!.name === 'BridgedToken')!.args .nonce; - const remoteChain = - message.chain === Chains.fuji ? Chains.goerli : Chains.fuji; + const remoteChain = chain === Chains.fuji ? Chains.goerli : Chains.fuji; + const domain = this.config[chain].circle!.circleDomainMapping.find( + (_) => _.hyperlaneDomain === ChainNameToDomainId[chain], + )!.circleDomain; return [ { chain, @@ -110,15 +157,68 @@ export class LiquidityLayerApp< txHash, message, nonce, - domain: 0, + domain, nonceHash: ethers.utils.solidityKeccak256( - ['uint32', 'uint256'], - [0, nonce], + ['uint32', 'uint64'], + [domain, nonce], ), }, ]; } + async attemptPortalTransferCompletion( + message: PortalBridgeMessage, + ): Promise { + const destinationPortalAdapter = this.getContracts(message.destination) + .portalAdapter!; + + const transferId = await destinationPortalAdapter.transferId( + ChainNameToDomainId[message.origin], + message.nonce, + ); + + const transferTokenAddress = + await destinationPortalAdapter.portalTransfersProcessed(transferId); + + if (!utils.eqAddress(transferTokenAddress, ethers.constants.AddressZero)) { + console.log( + `Transfer with nonce ${message.nonce} from ${message.origin} to ${message.destination} already processed`, + ); + return; + } + + const wormholeOriginDomain = this.config[ + message.destination + ].portal!.wormholeDomainMapping.find( + (_) => _.hyperlaneDomain === ChainNameToDomainId[message.origin], + )?.wormholeDomain; + const emitter = utils.strip0x( + utils.addressToBytes32( + this.config[message.origin].portal!.portalBridgeAddress, + ), + ); + + const vaa = await fetch( + `${PORTAL_VAA_SERVICE_TESTNET_BASE_URL}${wormholeOriginDomain}/${emitter}/${message.portalSequence}`, + ).then((_) => _.json()); + + if (vaa.code && vaa.code === PORTAL_VAA_SERVICE_SUCCESS_CODE) { + console.log(`VAA not yet found for nonce ${message.nonce}`); + return; + } + + const connection = this.multiProvider.getChainConnection( + message.destination, + ); + console.debug( + `Complete portal transfer for nonce ${message.nonce} on ${message.destination}`, + ); + await connection.handleTx( + destinationPortalAdapter.completeTransfer( + utils.ensure0x(Buffer.from(vaa.vaaBytes, 'base64').toString('hex')), + ), + ); + } async attemptCircleAttestationSubmission( message: CircleBridgeMessage, ): Promise { @@ -126,8 +226,7 @@ export class LiquidityLayerApp< message.remoteChain, ); const transmitter = ICircleMessageTransmitter__factory.connect( - this.circleBridgeAdapterConfig()[message.remoteChain] - .messageTransmitterAddress, + this.config[message.remoteChain].circle!.messageTransmitterAddress, connection.signer!, ); @@ -140,7 +239,7 @@ export class LiquidityLayerApp< const messageHash = ethers.utils.keccak256(message.message); const attestationsB = await fetch( - `https://iris-api-sandbox.circle.com/attestations/${messageHash}`, + `${CIRCLE_ATTESTATIONS_BASE_URL}${messageHash}`, ); const attestations = await attestationsB.json(); diff --git a/typescript/sdk/src/deploy/middleware/LiquidityLayerRouterDeployer.ts b/typescript/sdk/src/deploy/middleware/LiquidityLayerRouterDeployer.ts index 826d08b66..e70e6e42b 100644 --- a/typescript/sdk/src/deploy/middleware/LiquidityLayerRouterDeployer.ts +++ b/typescript/sdk/src/deploy/middleware/LiquidityLayerRouterDeployer.ts @@ -1,13 +1,15 @@ -import { ethers } from 'ethers'; - import { CircleBridgeAdapter, CircleBridgeAdapter__factory, LiquidityLayerRouter, LiquidityLayerRouter__factory, + PortalAdapter, + PortalAdapter__factory, } from '@hyperlane-xyz/core'; +import { utils } from '@hyperlane-xyz/utils'; import { HyperlaneCore } from '../../core/HyperlaneCore'; +import { ChainNameToDomainId } from '../../domains'; import { LiquidityLayerContracts, LiquidityLayerFactories, @@ -15,18 +17,19 @@ import { } from '../../middleware'; import { MultiProvider } from '../../providers/MultiProvider'; import { ChainMap, ChainName } from '../../types'; -import { objMap } from '../../utils/objects'; +import { objFilter, objMap } from '../../utils/objects'; import { RouterConfig } from '../router/types'; import { MiddlewareRouterDeployer } from './deploy'; export enum BridgeAdapterType { Circle = 'Circle', + Portal = 'Portal', } export interface CircleBridgeAdapterConfig { type: BridgeAdapterType.Circle; - circleBridgeAddress: string; + tokenMessengerAddress: string; messageTransmitterAddress: string; usdcAddress: string; circleDomainMapping: { @@ -35,12 +38,22 @@ export interface CircleBridgeAdapterConfig { }[]; } -export type BridgeAdapterConfig = CircleBridgeAdapterConfig; +export interface PortalAdapterConfig { + type: BridgeAdapterType.Portal; + portalBridgeAddress: string; + wormholeDomainMapping: { + hyperlaneDomain: number; + wormholeDomain: number; + }[]; +} -export type LiquidityLayerConfig = RouterConfig & { - bridgeAdapterConfigs: BridgeAdapterConfig[]; +export type BridgeAdapterConfig = { + circle?: CircleBridgeAdapterConfig; + portal?: PortalAdapterConfig; }; +export type LiquidityLayerConfig = RouterConfig & BridgeAdapterConfig; + export class LiquidityLayerDeployer< Chain extends ChainName, > extends MiddlewareRouterDeployer< @@ -61,14 +74,27 @@ export class LiquidityLayerDeployer< async enrollRemoteRouters( contractsMap: ChainMap, ): Promise { - // Enroll the LiquidityLayerRouter with each other + this.logger(`Enroll LiquidityLayerRouters with each other`); await super.enrollRemoteRouters(contractsMap); - // Enroll the circle adapters with each other + this.logger(`Enroll CircleBridgeAdapters with each other`); await super.enrollRemoteRouters( - objMap(contractsMap, (_chain, contracts) => ({ - router: contracts.circleBridgeAdapter!, - })), + objFilter( + objMap(contractsMap, (_chain, contracts) => ({ + router: contracts.circleBridgeAdapter, + })), + (_): _ is { router: CircleBridgeAdapter } => !!_.router, + ), + ); + + this.logger(`Enroll PortalAdapters with each other`); + await super.enrollRemoteRouters( + objFilter( + objMap(contractsMap, (_chain, contracts) => ({ + router: contracts.portalAdapter, + })), + (_): _ is { router: PortalAdapter } => !!_.router, + ), ); } @@ -89,16 +115,21 @@ export class LiquidityLayerDeployer< const bridgeAdapters: Partial = {}; - for (const adapterConfig of config.bridgeAdapterConfigs) { - if (adapterConfig.type === BridgeAdapterType.Circle) { - bridgeAdapters.circleBridgeAdapter = - await this.deployCircleBridgeAdapter( - chain, - adapterConfig, - config.owner, - router, - ); - } + if (config.circle) { + bridgeAdapters.circleBridgeAdapter = await this.deployCircleBridgeAdapter( + chain, + config.circle, + config.owner, + router, + ); + } + if (config.portal) { + bridgeAdapters.portalAdapter = await this.deployPortalAdapter( + chain, + config.portal, + config.owner, + router, + ); } return { @@ -107,6 +138,68 @@ export class LiquidityLayerDeployer< }; } + async deployPortalAdapter( + chain: Chain, + adapterConfig: PortalAdapterConfig, + owner: string, + router: LiquidityLayerRouter, + ): Promise { + const cc = this.multiProvider.getChainConnection(chain); + + const initCalldata = + PortalAdapter__factory.createInterface().encodeFunctionData( + 'initialize', + [ + ChainNameToDomainId[chain], + owner, + adapterConfig.portalBridgeAddress, + router.address, + ], + ); + const portalAdapter = await this.deployContract( + chain, + 'portalAdapter', + [], + { + create2Salt: this.create2salt, + initCalldata, + }, + ); + + for (const { + wormholeDomain, + hyperlaneDomain, + } of adapterConfig.wormholeDomainMapping) { + const expectedCircleDomain = + await portalAdapter.hyperlaneDomainToWormholeDomain(hyperlaneDomain); + if (expectedCircleDomain === wormholeDomain) continue; + + this.logger( + `Set wormhole domain ${wormholeDomain} for hyperlane domain ${hyperlaneDomain}`, + ); + await cc.handleTx( + portalAdapter.addDomain(hyperlaneDomain, wormholeDomain), + ); + } + + if ( + !utils.eqAddress( + await router.liquidityLayerAdapters('Portal'), + portalAdapter.address, + ) + ) { + this.logger('Set Portal as LiquidityLayerAdapter on Router'); + await cc.handleTx( + router.setLiquidityLayerAdapter( + adapterConfig.type, + portalAdapter.address, + ), + ); + } + + return portalAdapter; + } + async deployCircleBridgeAdapter( chain: Chain, adapterConfig: CircleBridgeAdapterConfig, @@ -119,7 +212,7 @@ export class LiquidityLayerDeployer< 'initialize', [ owner, - adapterConfig.circleBridgeAddress, + adapterConfig.tokenMessengerAddress, adapterConfig.messageTransmitterAddress, router.address, ], @@ -135,8 +228,10 @@ export class LiquidityLayerDeployer< ); if ( - (await circleBridgeAdapter.tokenSymbolToAddress('USDC')) === - ethers.constants.AddressZero + !utils.eqAddress( + await circleBridgeAdapter.tokenSymbolToAddress('USDC'), + adapterConfig.usdcAddress, + ) ) { this.logger(`Set USDC token contract`); await cc.handleTx( @@ -162,13 +257,21 @@ export class LiquidityLayerDeployer< ); } - this.logger('Set CircleLiquidityLayerAdapter on Router'); - await cc.handleTx( - router.setLiquidityLayerAdapter( - adapterConfig.type, + if ( + !utils.eqAddress( + await router.liquidityLayerAdapters('Circle'), circleBridgeAdapter.address, - ), - ); + ) + ) { + this.logger('Set Circle as LiquidityLayerAdapter on Router'); + await cc.handleTx( + router.setLiquidityLayerAdapter( + adapterConfig.type, + circleBridgeAdapter.address, + ), + ); + } + return circleBridgeAdapter; } } diff --git a/typescript/sdk/src/index.ts b/typescript/sdk/src/index.ts index f485fd10d..d051415c6 100644 --- a/typescript/sdk/src/index.ts +++ b/typescript/sdk/src/index.ts @@ -127,6 +127,7 @@ export { BridgeAdapterType, BridgeAdapterConfig, CircleBridgeAdapterConfig, + PortalAdapterConfig, } from './deploy/middleware/LiquidityLayerRouterDeployer'; export { LiquidityLayerApp } from './deploy/middleware/LiquidityLayerApp'; diff --git a/typescript/sdk/src/middleware.ts b/typescript/sdk/src/middleware.ts index 1788da295..a1939a633 100644 --- a/typescript/sdk/src/middleware.ts +++ b/typescript/sdk/src/middleware.ts @@ -7,6 +7,8 @@ import { InterchainQueryRouter__factory, LiquidityLayerRouter, LiquidityLayerRouter__factory, + PortalAdapter, + PortalAdapter__factory, } from '@hyperlane-xyz/core'; import { RouterContracts, RouterFactories } from './router'; @@ -31,13 +33,16 @@ export type InterchainQueryContracts = RouterContracts; export type LiquidityLayerFactories = RouterFactories & { circleBridgeAdapter: CircleBridgeAdapter__factory; + portalAdapter: PortalAdapter__factory; }; export const liquidityLayerFactories: LiquidityLayerFactories = { router: new LiquidityLayerRouter__factory(), circleBridgeAdapter: new CircleBridgeAdapter__factory(), + portalAdapter: new PortalAdapter__factory(), }; export type LiquidityLayerContracts = RouterContracts & { circleBridgeAdapter?: CircleBridgeAdapter; + portalAdapter?: PortalAdapter; }; diff --git a/typescript/sdk/src/middleware/liquidity-layer.hardhat-test.ts b/typescript/sdk/src/middleware/liquidity-layer.hardhat-test.ts index 678bbe265..f8dc72fca 100644 --- a/typescript/sdk/src/middleware/liquidity-layer.hardhat-test.ts +++ b/typescript/sdk/src/middleware/liquidity-layer.hardhat-test.ts @@ -4,10 +4,12 @@ import { ethers } from 'hardhat'; import { LiquidityLayerRouter, - MockCircleBridge, - MockCircleBridge__factory, MockCircleMessageTransmitter, MockCircleMessageTransmitter__factory, + MockCircleTokenMessenger, + MockCircleTokenMessenger__factory, + MockPortalBridge, + MockPortalBridge__factory, MockToken, MockToken__factory, TestLiquidityLayerMessageRecipient__factory, @@ -23,6 +25,7 @@ import { CircleBridgeAdapterConfig, LiquidityLayerConfig, LiquidityLayerDeployer, + PortalAdapterConfig, } from '../deploy/middleware/LiquidityLayerRouterDeployer'; import { getChainToOwnerMap, getTestMultiProvider } from '../deploy/utils'; import { ChainNameToDomainId } from '../domains'; @@ -44,7 +47,8 @@ describe('LiquidityLayerRouter', async () => { let liquidityLayerApp: LiquidityLayerApp; let config: ChainMap; let mockToken: MockToken; - let circleBridge: MockCircleBridge; + let circleTokenMessenger: MockCircleTokenMessenger; + let portalBridge: MockPortalBridge; let messageTransmitter: MockCircleMessageTransmitter; before(async () => { @@ -58,8 +62,12 @@ describe('LiquidityLayerRouter', async () => { const mockTokenF = new MockToken__factory(signer); mockToken = await mockTokenF.deploy(); - const circleBridgeF = new MockCircleBridge__factory(signer); - circleBridge = await circleBridgeF.deploy(mockToken.address); + const portalBridgeF = new MockPortalBridge__factory(signer); + const circleTokenMessengerF = new MockCircleTokenMessenger__factory(signer); + circleTokenMessenger = await circleTokenMessengerF.deploy( + mockToken.address, + ); + portalBridge = await portalBridgeF.deploy(mockToken.address); const messageTransmitterF = new MockCircleMessageTransmitter__factory( signer, ); @@ -70,24 +78,36 @@ describe('LiquidityLayerRouter', async () => { getChainToOwnerMap(testChainConnectionConfigs, signer.address), (_chain, conf) => ({ ...conf, - bridgeAdapterConfigs: [ - { - type: BridgeAdapterType.Circle, - circleBridgeAddress: circleBridge.address, - messageTransmitterAddress: messageTransmitter.address, - usdcAddress: mockToken.address, - circleDomainMapping: [ - { - hyperlaneDomain: localDomain, - circleDomain: localDomain, - }, - { - hyperlaneDomain: remoteDomain, - circleDomain: remoteDomain, - }, - ], - } as CircleBridgeAdapterConfig, - ], + circle: { + type: BridgeAdapterType.Circle, + tokenMessengerAddress: circleTokenMessenger.address, + messageTransmitterAddress: messageTransmitter.address, + usdcAddress: mockToken.address, + circleDomainMapping: [ + { + hyperlaneDomain: localDomain, + circleDomain: localDomain, + }, + { + hyperlaneDomain: remoteDomain, + circleDomain: remoteDomain, + }, + ], + } as CircleBridgeAdapterConfig, + portal: { + type: BridgeAdapterType.Portal, + portalBridgeAddress: portalBridge.address, + wormholeDomainMapping: [ + { + hyperlaneDomain: localDomain, + wormholeDomain: localDomain, + }, + { + hyperlaneDomain: remoteDomain, + wormholeDomain: remoteDomain, + }, + ], + } as PortalAdapterConfig, }), ), ); @@ -101,16 +121,12 @@ describe('LiquidityLayerRouter', async () => { ); const contracts = await LiquidityLayer.deploy(); - liquidityLayerApp = new LiquidityLayerApp( - contracts, - multiProvider, - objMap(config, (_chain, conf) => conf.bridgeAdapterConfigs), - ); + liquidityLayerApp = new LiquidityLayerApp(contracts, multiProvider, config); local = liquidityLayerApp.getContracts(localChain).router; }); - it('can transfer tokens', async () => { + it('can transfer tokens via Circle', async () => { const recipientF = new TestLiquidityLayerMessageRecipient__factory(signer); const recipient = await recipientF.deploy(); @@ -126,7 +142,7 @@ describe('LiquidityLayerRouter', async () => { BridgeAdapterType.Circle, ); - const transferNonce = await circleBridge.nextNonce(); + const transferNonce = await circleTokenMessenger.nextNonce(); const nonceId = await messageTransmitter.hashSourceAndNonce( localDomain, transferNonce, @@ -143,4 +159,38 @@ describe('LiquidityLayerRouter', async () => { amount, ); }); + + it('can transfer tokens via Portal', async () => { + const recipientF = new TestLiquidityLayerMessageRecipient__factory(signer); + const recipient = await recipientF.deploy(); + + const amount = 1000; + await mockToken.mint(signer.address, amount); + await mockToken.approve(local.address, amount); + await local.dispatchWithTokens( + remoteDomain, + utils.addressToBytes32(recipient.address), + '0x00', + mockToken.address, + amount, + BridgeAdapterType.Portal, + ); + + const originAdapter = + liquidityLayerApp.getContracts(localChain).portalAdapter!; + const destinationAdapter = + liquidityLayerApp.getContracts(remoteChain).portalAdapter!; + await destinationAdapter.completeTransfer( + await portalBridge.mockPortalVaa( + localDomain, + await originAdapter.nonce(), + amount, + ), + ); + await coreApp.processMessages(); + + expect((await mockToken.balanceOf(recipient.address)).toNumber()).to.eql( + amount, + ); + }); }); diff --git a/typescript/sdk/src/providers/MultiProvider.ts b/typescript/sdk/src/providers/MultiProvider.ts index 31ffce884..6663b109f 100644 --- a/typescript/sdk/src/providers/MultiProvider.ts +++ b/typescript/sdk/src/providers/MultiProvider.ts @@ -129,7 +129,9 @@ export class MultiProvider< } if (!intersection.length) { - throw new Error(`No chains shared between MultiProvider and list`); + throw new Error( + `No chains shared between MultiProvider and list (${ownChains} and ${chains})`, + ); } const intersectionChainMap = pick(this.chainMap, intersection); diff --git a/typescript/sdk/src/utils/objects.ts b/typescript/sdk/src/utils/objects.ts index 5bc6f908c..a5ffb5a36 100644 --- a/typescript/sdk/src/utils/objects.ts +++ b/typescript/sdk/src/utils/objects.ts @@ -18,6 +18,15 @@ export function objMap( >; } +export function objFilter( + obj: Record, + func: (v: I) => v is O, +): Record { + return Object.fromEntries( + Object.entries(obj).filter(([_, v]) => func(v)), + ) as Record; +} + // promiseObjectAll :: {k: Promise a} -> Promise {k: a} export function promiseObjAll(obj: { [key in K]: Promise; diff --git a/typescript/utils/src/utils.ts b/typescript/utils/src/utils.ts index 2aba2ffff..d139dd127 100644 --- a/typescript/utils/src/utils.ts +++ b/typescript/utils/src/utils.ts @@ -19,6 +19,10 @@ export function deepEquals(v1: any, v2: any) { return JSON.stringify(v1) === JSON.stringify(v2); } +export function eqAddress(a: string, b: string) { + return ethers.utils.getAddress(a) === ethers.utils.getAddress(b); +} + export const ensure0x = (hexstr: string) => hexstr.startsWith('0x') ? hexstr : `0x${hexstr}`;