diff --git a/.gitmodules b/.gitmodules index fc3a658b..9300a380 100644 --- a/.gitmodules +++ b/.gitmodules @@ -20,3 +20,6 @@ path = lib/zk-email-verify branch = v6.3.2 url = https://github.com/zkemail/zk-email-verify +[submodule "lib/wormhole-solidity-sdk"] + path = lib/wormhole-solidity-sdk + url = https://github.com/wormhole-foundation/wormhole-solidity-sdk diff --git a/contracts/crosschain/wormhole/WormholeGatewayBase.sol b/contracts/crosschain/wormhole/WormholeGatewayBase.sol new file mode 100644 index 00000000..c0881676 --- /dev/null +++ b/contracts/crosschain/wormhole/WormholeGatewayBase.sol @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {IWormholeRelayer} from "wormhole-solidity-sdk/interfaces/IWormholeRelayer.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {InteroperableAddress} from "@openzeppelin/contracts/utils/draft-InteroperableAddress.sol"; + +/// Note: only EVM chains are currently supported +abstract contract WormholeGatewayBase is Ownable { + using InteroperableAddress for bytes; + + IWormholeRelayer internal immutable _wormholeRelayer; + uint16 internal immutable _wormholeChainId; + uint24 private constant MASK = 1 << 16; + + // Remote gateway. + mapping(uint256 chainId => address) private _remoteGateways; + + // chain equivalence ChainId <> Wormhole + mapping(uint256 chainId => uint24 wormholeId) private _chainIdToWormhole; + mapping(uint16 wormholeId => uint256 chainId) private _wormholeToChainId; + + /// @dev A remote gateway has been registered for a chain. + event RegisteredRemoteGateway(uint256 chainId, address remote); + + /// @dev A chain equivalence has been registered. + event RegisteredChainEquivalence(uint256 chainId, uint16 wormholeId); + + error UnsupportedChainId(uint256 chainId); + error UnsupportedWormholeChain(uint16 wormholeId); + error ChainEquivalenceAlreadyRegistered(uint256 chainId, uint16 wormhole); + error RemoteGatewayAlreadyRegistered(uint256 chainId); + error UnauthorizedCaller(address); + + modifier onlyWormholeRelayer() { + require(msg.sender == address(_wormholeRelayer), UnauthorizedCaller(msg.sender)); + _; + } + + constructor(IWormholeRelayer wormholeRelayer, uint16 wormholeChainId) { + _wormholeRelayer = wormholeRelayer; + _wormholeChainId = wormholeChainId; + } + + function relayer() public view virtual returns (address) { + return address(_wormholeRelayer); + } + + function supportedChain(bytes memory chain) public view virtual returns (bool) { + (bool success, uint256 chainId, ) = chain.tryParseEvmV1(); + return success && supportedChain(chainId); + } + + function supportedChain(uint256 chainId) public view virtual returns (bool) { + return _chainIdToWormhole[chainId] & MASK == MASK; + } + + function getWormholeChain(bytes memory chain) public view virtual returns (uint16) { + (uint256 chainId, ) = chain.parseEvmV1(); + return getWormholeChain(chainId); + } + + function getWormholeChain(uint256 chainId) public view virtual returns (uint16) { + uint24 wormholeId = _chainIdToWormhole[chainId]; + require(wormholeId & MASK == MASK, UnsupportedChainId(chainId)); + return uint16(wormholeId); + } + + function getChainId(uint16 wormholeId) public view virtual returns (uint256) { + uint256 chainId = _wormholeToChainId[wormholeId]; + require(chainId != 0, UnsupportedWormholeChain(wormholeId)); + return chainId; + } + + /// @dev Returns the address of the remote gateway for a given chainType and chainReference. + function getRemoteGateway(bytes memory chain) public view virtual returns (address) { + (uint256 chainId, ) = chain.parseEvmV1(); + return getRemoteGateway(chainId); + } + + function getRemoteGateway(uint256 chainId) public view virtual returns (address) { + address addr = _remoteGateways[chainId]; + require(addr != address(0), UnsupportedChainId(chainId)); + return addr; + } + + function registerChainEquivalence( + bytes calldata chain, + uint16 wormholeId + ) public virtual /*onlyOwner in registerChainEquivalence*/ { + (uint256 chainId, ) = chain.parseEvmV1Calldata(); + registerChainEquivalence(chainId, wormholeId); + } + + function registerChainEquivalence(uint256 chainId, uint16 wormholeId) public virtual onlyOwner { + require( + _chainIdToWormhole[chainId] == 0 && _wormholeToChainId[wormholeId] == 0, + ChainEquivalenceAlreadyRegistered(chainId, wormholeId) + ); + + _chainIdToWormhole[chainId] = wormholeId | MASK; + _wormholeToChainId[wormholeId] = chainId; + emit RegisteredChainEquivalence(chainId, wormholeId); + } + + function registerRemoteGateway(bytes calldata remote) public virtual /*onlyOwner in registerRemoteGateway*/ { + (uint256 chainId, address addr) = remote.parseEvmV1Calldata(); + registerRemoteGateway(chainId, addr); + } + + function registerRemoteGateway(uint256 chainId, address addr) public virtual onlyOwner { + require(supportedChain(chainId), UnsupportedChainId(chainId)); + require(_remoteGateways[chainId] == address(0), RemoteGatewayAlreadyRegistered(chainId)); + _remoteGateways[chainId] = addr; + emit RegisteredRemoteGateway(chainId, addr); + } +} diff --git a/contracts/crosschain/wormhole/WormholeGatewayDestination.sol b/contracts/crosschain/wormhole/WormholeGatewayDestination.sol new file mode 100644 index 00000000..9309aa35 --- /dev/null +++ b/contracts/crosschain/wormhole/WormholeGatewayDestination.sol @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {IWormholeReceiver} from "wormhole-solidity-sdk/interfaces/IWormholeReceiver.sol"; +import {fromUniversalAddress} from "wormhole-solidity-sdk/utils/UniversalAddress.sol"; +import {BitMaps} from "@openzeppelin/contracts/utils/structs/BitMaps.sol"; +import {InteroperableAddress} from "@openzeppelin/contracts/utils/draft-InteroperableAddress.sol"; +import {IERC7786Receiver} from "../../interfaces/IERC7786.sol"; +import {WormholeGatewayBase} from "./WormholeGatewayBase.sol"; + +abstract contract WormholeGatewayDestination is WormholeGatewayBase, IWormholeReceiver { + using BitMaps for BitMaps.BitMap; + using InteroperableAddress for bytes; + + BitMaps.BitMap private _executed; + + error InvalidOriginGateway(uint16 wormholeSourceChain, bytes32 wormholeSourceAddress); + error MessageAlreadyExecuted(bytes32 outboxId); + error ReceiverExecutionFailed(); + error AdditionalMessagesNotSupported(); + + function receiveWormholeMessages( + bytes memory adapterPayload, + bytes[] memory additionalMessages, + bytes32 wormholeSourceAddress, + uint16 wormholeSourceChain, + bytes32 deliveryHash + ) public payable virtual onlyWormholeRelayer { + require(additionalMessages.length == 0, AdditionalMessagesNotSupported()); + + (bytes32 outboxId, bytes memory sender, bytes memory recipient, bytes memory payload) = abi.decode( + adapterPayload, + (bytes32, bytes, bytes, bytes) + ); + + // Wormhole to ERC-7930 translation + address addr = getRemoteGateway(getChainId(wormholeSourceChain)); + + // check message validity + // - `wormholeSourceAddress` is the remote gateway on the origin chain. + require( + addr == fromUniversalAddress(wormholeSourceAddress), + InvalidOriginGateway(wormholeSourceChain, wormholeSourceAddress) + ); + + // prevent replay - deliveryHash might not be unique if a message is relayed multiple time + require(!_executed.get(uint256(outboxId)), MessageAlreadyExecuted(outboxId)); + _executed.set(uint256(outboxId)); + + (, address target) = recipient.parseEvmV1(); + bytes4 result = IERC7786Receiver(target).receiveMessage(deliveryHash, sender, payload); + require(result == IERC7786Receiver.receiveMessage.selector, ReceiverExecutionFailed()); + } +} diff --git a/contracts/crosschain/wormhole/WormholeGatewayDuplex.sol b/contracts/crosschain/wormhole/WormholeGatewayDuplex.sol new file mode 100644 index 00000000..a3ee4242 --- /dev/null +++ b/contracts/crosschain/wormhole/WormholeGatewayDuplex.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {WormholeGatewayBase, IWormholeRelayer} from "./WormholeGatewayBase.sol"; +import {WormholeGatewayDestination} from "./WormholeGatewayDestination.sol"; +import {WormholeGatewaySource} from "./WormholeGatewaySource.sol"; + +/** + * @dev A contract that combines the functionality of both the source and destination gateway + * adapters for the Wormhole Network. Allowing to either send or receive messages across chains. + */ +// slither-disable-next-line locked-ether +contract WormholeGatewayDuplex is WormholeGatewaySource, WormholeGatewayDestination { + /// @dev Initializes the contract with the Wormhole gateway and the initial owner. + constructor( + IWormholeRelayer wormholeRelayer, + uint16 wormholeChainId, + address initialOwner + ) Ownable(initialOwner) WormholeGatewayBase(wormholeRelayer, wormholeChainId) {} +} diff --git a/contracts/crosschain/wormhole/WormholeGatewaySource.sol b/contracts/crosschain/wormhole/WormholeGatewaySource.sol new file mode 100644 index 00000000..0ad445fe --- /dev/null +++ b/contracts/crosschain/wormhole/WormholeGatewaySource.sol @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {VaaKey} from "wormhole-solidity-sdk/interfaces/IWormholeRelayer.sol"; +import {toUniversalAddress} from "wormhole-solidity-sdk/utils/UniversalAddress.sol"; +import {InteroperableAddress} from "@openzeppelin/contracts/utils/draft-InteroperableAddress.sol"; +import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; +import {WormholeGatewayBase} from "./WormholeGatewayBase.sol"; +import {IERC7786GatewaySource} from "../../interfaces/IERC7786.sol"; + +// TODO: allow non-evm destination chains via non-evm-specific finalize/retry variants +abstract contract WormholeGatewaySource is IERC7786GatewaySource, WormholeGatewayBase { + using InteroperableAddress for bytes; + // using Strings for *; + + struct PendingMessage { + bool pending; + address sender; + uint256 value; + bytes recipient; + bytes payload; + } + + uint256 private _sendId; + mapping(bytes32 => PendingMessage) private _pending; + + event MessageRelayed(bytes32 sendId); + error InvalidSendId(bytes32 sendId); + + /// @inheritdoc IERC7786GatewaySource + function supportsAttribute(bytes4 /*selector*/) public pure returns (bool) { + return false; + } + + /// @inheritdoc IERC7786GatewaySource + function sendMessage( + bytes calldata recipient, // Binary Interoperable Address + bytes calldata payload, + bytes[] calldata attributes + ) external payable returns (bytes32 sendId) { + // Use of `if () revert` syntax to avoid accessing attributes[0] if it's empty + if (attributes.length > 0) + revert UnsupportedAttribute(attributes[0].length < 0x04 ? bytes4(0) : bytes4(attributes[0][0:4])); + + // Note: this reverts with UnsupportedChainId if the recipient is not on a supported chain. + // No real need to check the return value. + getRemoteGateway(recipient); + + sendId = bytes32(++_sendId); + _pending[sendId] = PendingMessage(true, msg.sender, msg.value, recipient, payload); + + emit MessageSent( + sendId, + InteroperableAddress.formatEvmV1(block.chainid, msg.sender), + recipient, + payload, + 0, + attributes + ); + } + + function quoteRelay( + bytes calldata recipient, // Binary Interoperable Address + bytes calldata /*payload*/, + bytes[] calldata /*attributes*/, + uint256 value, + uint256 gasLimit, + address /*refundRecipient*/ + ) external view returns (uint256) { + (uint256 cost, ) = _wormholeRelayer.quoteEVMDeliveryPrice(getWormholeChain(recipient), value, gasLimit); + return cost - value; + } + + function requestRelay(bytes32 sendId, uint256 gasLimit, address /*refundRecipient*/) external payable { + // TODO: revert if refundRecipient is not address(0)? + + PendingMessage memory pmsg = _pending[sendId]; + require(pmsg.pending, InvalidSendId(sendId)); + + // Do we want to do that to get a gas refund? Would it be valuable to keep that information stored? + delete _pending[sendId]; + + // TODO: Do we care about the returned "sequence"? + _wormholeRelayer.sendPayloadToEvm{value: pmsg.value + msg.value}( + getWormholeChain(pmsg.recipient), + getRemoteGateway(pmsg.recipient), + abi.encode( + sendId, + InteroperableAddress.formatEvmV1(block.chainid, pmsg.sender), + pmsg.recipient, + pmsg.payload + ), + pmsg.value, + gasLimit + ); + + emit MessageRelayed(sendId); + } +} diff --git a/contracts/mocks/crosschain/wormhole/WormholeRelayerMock.sol b/contracts/mocks/crosschain/wormhole/WormholeRelayerMock.sol new file mode 100644 index 00000000..4ef946bb --- /dev/null +++ b/contracts/mocks/crosschain/wormhole/WormholeRelayerMock.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {IWormholeRelayer} from "wormhole-solidity-sdk/interfaces/IWormholeRelayer.sol"; +import {IWormholeReceiver} from "wormhole-solidity-sdk/interfaces/IWormholeReceiver.sol"; +import {toUniversalAddress} from "wormhole-solidity-sdk/utils/UniversalAddress.sol"; + +contract WormholeRelayerMock { + uint64 private _seq; + + function sendPayloadToEvm( + uint16 targetChain, + address targetAddress, + bytes memory payload, + uint256 receiverValue, + uint256 gasLimit + ) external payable returns (uint64) { + // TODO: check that destination chain is local + + uint64 seq = _seq++; + IWormholeReceiver(targetAddress).receiveWormholeMessages{value: receiverValue, gas: gasLimit}( + payload, + new bytes[](0), + toUniversalAddress(msg.sender), + targetChain, + keccak256(abi.encode(seq)) + ); + + return seq; + } +} diff --git a/lib/@openzeppelin-contracts b/lib/@openzeppelin-contracts index 32e7a6ff..6079eb3f 160000 --- a/lib/@openzeppelin-contracts +++ b/lib/@openzeppelin-contracts @@ -1 +1 @@ -Subproject commit 32e7a6ffbc5af9ab0e6dfdbc58508511d0f0b4a2 +Subproject commit 6079eb3f01d5a37ae23e7e72d6909852566bc2e3 diff --git a/lib/@openzeppelin-contracts-upgradeable b/lib/@openzeppelin-contracts-upgradeable index 7bb7e907..da12828d 160000 --- a/lib/@openzeppelin-contracts-upgradeable +++ b/lib/@openzeppelin-contracts-upgradeable @@ -1 +1 @@ -Subproject commit 7bb7e9077dd93d116657bb181e32c84165b8dbba +Subproject commit da12828d5d0fbd8a556e118756eedef9fb042ffc diff --git a/lib/wormhole-solidity-sdk b/lib/wormhole-solidity-sdk new file mode 160000 index 00000000..575181b5 --- /dev/null +++ b/lib/wormhole-solidity-sdk @@ -0,0 +1 @@ +Subproject commit 575181b586a315d8f9813eab82e4cb98b45bc381 diff --git a/remappings.txt b/remappings.txt index 18badd3f..0ffaddd8 100644 --- a/remappings.txt +++ b/remappings.txt @@ -4,3 +4,4 @@ @axelar-network/axelar-gmp-sdk-solidity/=lib/axelar-gmp-sdk-solidity/ @zk-email/email-tx-builder/=lib/email-tx-builder/packages/contracts/ @zk-email/contracts/=lib/zk-email-verify/packages/contracts/ +wormhole-solidity-sdk/=lib/wormhole-solidity-sdk/src/ diff --git a/test/crosschain/wormhole/WormholeGateway.test.js b/test/crosschain/wormhole/WormholeGateway.test.js new file mode 100644 index 00000000..f9999ef9 --- /dev/null +++ b/test/crosschain/wormhole/WormholeGateway.test.js @@ -0,0 +1,114 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const { anyValue } = require('@nomicfoundation/hardhat-chai-matchers/withArgs'); + +const WormholeHelper = require('./WormholeHelper'); + +async function fixture() { + const [owner, sender, ...accounts] = await ethers.getSigners(); + + const { chain, wormholeChainId, wormhole, gatewayA, gatewayB } = await WormholeHelper.deploy(owner); + + const receiver = await ethers.deployContract('$ERC7786ReceiverMock', [gatewayB]); + const invalidReceiver = await ethers.deployContract('$ERC7786ReceiverInvalidMock'); + + return { + owner, + sender, + accounts, + chain, + wormholeChainId, + wormhole, + gatewayA, + gatewayB, + receiver, + invalidReceiver, + }; +} + +describe('WormholeGateway', function () { + const sendId = '0x0000000000000000000000000000000000000000000000000000000000000001'; + + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + it('initial setup', async function () { + await expect(this.gatewayA.relayer()).to.eventually.equal(this.wormhole); + await expect(this.gatewayA.getChainId(this.wormholeChainId)).to.eventually.equal(this.chain.reference); + await expect(this.gatewayA.getWormholeChain(ethers.Typed.bytes(this.chain.erc7930))).to.eventually.equal( + this.wormholeChainId, + ); + await expect(this.gatewayA.getWormholeChain(ethers.Typed.uint256(this.chain.reference))).to.eventually.equal( + this.wormholeChainId, + ); + await expect(this.gatewayA.getRemoteGateway(ethers.Typed.bytes(this.chain.erc7930))).to.eventually.equal( + this.gatewayB, + ); + await expect(this.gatewayA.getRemoteGateway(ethers.Typed.uint256(this.chain.reference))).to.eventually.equal( + this.gatewayB, + ); + + await expect(this.gatewayB.relayer()).to.eventually.equal(this.wormhole); + await expect(this.gatewayB.getChainId(this.wormholeChainId)).to.eventually.equal(this.chain.reference); + await expect(this.gatewayB.getWormholeChain(ethers.Typed.bytes(this.chain.erc7930))).to.eventually.equal( + this.wormholeChainId, + ); + await expect(this.gatewayB.getWormholeChain(ethers.Typed.uint256(this.chain.reference))).to.eventually.equal( + this.wormholeChainId, + ); + await expect(this.gatewayB.getRemoteGateway(ethers.Typed.bytes(this.chain.erc7930))).to.eventually.equal( + this.gatewayA, + ); + await expect(this.gatewayB.getRemoteGateway(ethers.Typed.uint256(this.chain.reference))).to.eventually.equal( + this.gatewayA, + ); + }); + + it('workflow', async function () { + const erc7930Sender = this.chain.toErc7930(this.sender); + const erc7930Recipient = this.chain.toErc7930(this.receiver); + const payload = ethers.randomBytes(128); + const attributes = []; + // const encoded = ethers.AbiCoder.defaultAbiCoder().encode( + // ['bytes32', 'string', 'string', 'bytes', 'bytes[]'], + // [sendId, getAddress(this.sender), getAddress(this.receiver), payload, attributes], + // ); + + await expect(this.gatewayA.connect(this.sender).sendMessage(erc7930Recipient, payload, attributes)) + .to.emit(this.gatewayA, 'MessageSent') + .withArgs(sendId, erc7930Sender, erc7930Recipient, payload, 0n, attributes); + + await expect(this.gatewayA.requestRelay(sendId, 100_000n, ethers.ZeroAddress)) + .to.emit(this.gatewayA, 'MessageRelayed') + .withArgs(sendId) + .to.emit(this.receiver, 'MessageReceived') + .withArgs(this.gatewayB, anyValue, erc7930Sender, payload); + }); + + it('invalid receiver - bad return value', async function () { + await this.gatewayA + .connect(this.sender) + .sendMessage(this.chain.toErc7930(this.invalidReceiver), ethers.randomBytes(128), []); + + await expect(this.gatewayA.requestRelay(sendId, 100_000n, ethers.ZeroAddress)).to.be.revertedWithCustomError( + this.gatewayB, + 'ReceiverExecutionFailed', + ); + }); + + it('invalid receiver - EOA', async function () { + await this.gatewayA + .connect(this.sender) + .sendMessage(this.chain.toErc7930(this.accounts[0]), ethers.randomBytes(128), []); + + await expect(this.gatewayA.requestRelay(sendId, 100_000n, ethers.ZeroAddress)).to.be.revertedWithoutReason(); + }); + + it('invalid sendId', async function () { + await expect(this.gatewayA.requestRelay(sendId, 100_000n, ethers.ZeroAddress)) + .to.be.revertedWithCustomError(this.gatewayA, 'InvalidSendId') + .withArgs(sendId); + }); +}); diff --git a/test/crosschain/wormhole/WormholeHelper.js b/test/crosschain/wormhole/WormholeHelper.js new file mode 100644 index 00000000..89b8c10c --- /dev/null +++ b/test/crosschain/wormhole/WormholeHelper.js @@ -0,0 +1,28 @@ +const { ethers } = require('hardhat'); +const { getLocalChain } = require('@openzeppelin/contracts/test/helpers/chains'); + +const toUniversalAddress = addr => ethers.zeroPadValue(addr.target ?? addr.address ?? addr, 32); +const fromUniversalAddress = addr => ethers.getAddress(ethers.hexlify(ethers.getBytes(addr).slice(-20))); + +async function deploy(owner, wormholeChainId = 23600) { + const chain = await getLocalChain(); + + const wormhole = await ethers.deployContract('WormholeRelayerMock'); + const gatewayA = await ethers.deployContract('WormholeGatewayDuplex', [wormhole, wormholeChainId, owner]); + const gatewayB = await ethers.deployContract('WormholeGatewayDuplex', [wormhole, wormholeChainId, owner]); + + await Promise.all([ + gatewayA.connect(owner).registerChainEquivalence(ethers.Typed.bytes(chain.erc7930), wormholeChainId), + gatewayB.connect(owner).registerChainEquivalence(ethers.Typed.bytes(chain.erc7930), wormholeChainId), + gatewayA.connect(owner).registerRemoteGateway(ethers.Typed.bytes(chain.toErc7930(gatewayB))), + gatewayB.connect(owner).registerRemoteGateway(ethers.Typed.bytes(chain.toErc7930(gatewayA))), + ]); + + return { chain, wormholeChainId, wormhole, gatewayA, gatewayB }; +} + +module.exports = { + deploy, + toUniversalAddress, + fromUniversalAddress, +};