Skip to content
Open
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
d63e58d
wip
Amxx Mar 21, 2025
70a4daf
Test wormhole adapter
Amxx Mar 24, 2025
6492d92
minimize changes
Amxx Mar 24, 2025
bf7e7e2
add quote function
Amxx Mar 28, 2025
c628223
cvhange prettier rules
Amxx Apr 3, 2025
69c38a5
Merge branch 'master' into interrop/wormhole-adaptor
Amxx Jun 25, 2025
82027f0
Update remappings.txt
Amxx Jun 25, 2025
fceeb40
update wormhole gateway adaptor
Amxx Jun 25, 2025
4b7e226
Merge branch 'master' into interrop/wormhole-adaptor
Amxx Jun 25, 2025
0135754
Update ERC7786Receiver
Amxx Jul 23, 2025
f15be8e
Merge branch 'erc7786/receiveMessage-without-attributes' into interro…
Amxx Jul 23, 2025
8244d04
update
Amxx Jul 23, 2025
3a05c3a
Merge branch 'master' into interrop/wormhole-adaptor
Amxx Jul 24, 2025
da5762b
Simplify base to align with sending only supporting EVM chains
Amxx Jul 25, 2025
011b755
refactor quote/finalize following ERC7786 pending work
Amxx Jul 25, 2025
a6dd68b
up
Amxx Jul 25, 2025
cbad532
up
Amxx Jul 25, 2025
8a20f74
flatten
Amxx Aug 20, 2025
4696933
support requestRelay attribute
Amxx Aug 20, 2025
83a78be
add requestRelay attribute support
Amxx Aug 20, 2025
a57acc4
fix supportsAttribute
Amxx Aug 20, 2025
c52f4b8
Merge branch 'master' into interrop/wormhole-adaptor
Amxx Aug 20, 2025
3830d3d
missing import
Amxx Aug 20, 2025
65a2ea1
fix tests
Amxx Aug 20, 2025
c6dbbf1
Merge branch 'master' into interrop/wormhole-adaptor
Amxx Aug 20, 2025
0436913
fix separate executed bitmaps between origin chains
Amxx Aug 20, 2025
540ec3c
Address PR comments
Amxx Aug 21, 2025
35b8c2a
Apply suggestions from code review
Amxx Aug 21, 2025
6ed786e
refactor attribute management, and sending to the wormhole relayer
Amxx Aug 21, 2025
47bc84c
update
Amxx Aug 21, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -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
34 changes: 34 additions & 0 deletions contracts/crosschain/utils/ERC7786Attributes.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.27;

import {IERC7786Attributes} from "../../interfaces/IERC7786Attributes.sol";

/// @dev Library of helper to parse/process ERC-7786 attributes
library ERC7786Attributes {
/// @dev Parse the `requestRelay(uint256,uint256,address)` (0x4cbb573a) attribute into its components.
function tryDecodeRequestRelay(
bytes memory attribute
) internal pure returns (bool success, uint256 value, uint256 gasLimit, address refundRecipient) {
success = bytes4(attribute) == IERC7786Attributes.requestRelay.selector && attribute.length >= 0x64;

assembly ("memory-safe") {
value := mul(success, mload(add(attribute, 0x24)))
gasLimit := mul(success, mload(add(attribute, 0x44)))
refundRecipient := mul(success, mload(add(attribute, 0x64)))
}
}

/// @dev Calldata variant of {tryDecodeRequestRelay}.
function tryDecodeRequestRelayCalldata(
bytes calldata attribute
) internal pure returns (bool success, uint256 value, uint256 gasLimit, address refundRecipient) {
success = bytes4(attribute) == IERC7786Attributes.requestRelay.selector && attribute.length >= 0x64;

assembly ("memory-safe") {
value := mul(success, calldataload(add(attribute.offset, 0x04)))
gasLimit := mul(success, calldataload(add(attribute.offset, 0x24)))
refundRecipient := mul(success, calldataload(add(attribute.offset, 0x44)))
}
}
}
296 changes: 296 additions & 0 deletions contracts/crosschain/wormhole/WormholeGatewayAdapter.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
// 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 {VaaKey} from "wormhole-solidity-sdk/interfaces/IWormholeRelayer.sol";
import {fromUniversalAddress, toUniversalAddress} from "wormhole-solidity-sdk/utils/UniversalAddress.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {BitMaps} from "@openzeppelin/contracts/utils/structs/BitMaps.sol";
import {InteroperableAddress} from "@openzeppelin/contracts/utils/draft-InteroperableAddress.sol";
import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";
import {ERC7786Attributes} from "../utils/ERC7786Attributes.sol";
import {IERC7786GatewaySource} from "../../interfaces/IERC7786.sol";
import {IERC7786Receiver} from "../../interfaces/IERC7786.sol";
import {IERC7786Attributes} from "../../interfaces/IERC7786Attributes.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.
*
* Note: only EVM chains are currently supported
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this because of address encoding?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I see we're using sendPayloadToEvm.

*/
// slither-disable-next-line locked-ether
contract WormholeGatewayAdapter is IERC7786GatewaySource, IWormholeReceiver, Ownable {
using BitMaps for BitMaps.BitMap;
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;

// Message temporary representation, waiting for gas payment in requestRelay
struct PendingMessage {
bool pending;
address sender;
uint256 value;
bytes recipient;
bytes payload;
}

uint256 private _lastSendId;
mapping(bytes32 sendId => PendingMessage) private _pending;
mapping(uint256 chainId => BitMaps.BitMap) private _executed;

/// @dev A message was relayed to Wormhole (part of the post processing of the outbox ids created by {sendMessage})
event MessageRelayed(bytes32 sendId);

/// @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 UnauthorizedCaller(address);
error InvalidOriginGateway(uint16 wormholeSourceChain, bytes32 wormholeSourceAddress);
error ReceiverExecutionFailed();
error UnsupportedChainId(uint256 chainId);
error UnsupportedWormholeChain(uint16 wormholeId);
error ChainEquivalenceAlreadyRegistered(uint256 chainId, uint16 wormhole);
error RemoteGatewayAlreadyRegistered(uint256 chainId);
error InvalidSendId(bytes32 sendId);
error AdditionalMessagesNotSupported();
error MessageAlreadyExecuted(uint256 chainId, bytes32 outboxId);

modifier onlyWormholeRelayer() {
require(msg.sender == address(_wormholeRelayer), UnauthorizedCaller(msg.sender));
_;
}

/// @dev Initializes the contract with the Wormhole gateway and the initial owner.
constructor(IWormholeRelayer wormholeRelayer, uint16 wormholeChainId, address initialOwner) Ownable(initialOwner) {
_wormholeRelayer = wormholeRelayer;
_wormholeChainId = wormholeChainId;
}

/// @dev Returns the local Wormhole relayer
function relayer() public view virtual returns (address) {
return address(_wormholeRelayer);
}

/// @dev Returns whether a binary interoperable chain id is supported.
function supportedChain(bytes memory chain) public view virtual returns (bool) {
Copy link
Contributor

@frangio frangio Aug 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's clear or intuitive that chain has to be a full interop address. I'd consider renaming the argument at least, possibly the function, and making this explicit in the documentation including a @param.

(bool success, uint256 chainId, ) = chain.tryParseEvmV1();
return success && supportedChain(chainId);
}

/// @dev Returns whether an EVM chain id is supported.
function supportedChain(uint256 chainId) public view virtual returns (bool) {
return _chainIdToWormhole[chainId] & MASK == MASK;
}

/// @dev Returns the Wormhole chain id that correspond to a given binary interoperable chain id.
function getWormholeChain(bytes memory chain) public view virtual returns (uint16) {
(uint256 chainId, ) = chain.parseEvmV1();
return getWormholeChain(chainId);
}

/// @dev Returns the Wormhole chain id that correspond to a given EVM chain id.
function getWormholeChain(uint256 chainId) public view virtual returns (uint16) {
uint24 wormholeId = _chainIdToWormhole[chainId];
require(wormholeId & MASK == MASK, UnsupportedChainId(chainId));
Copy link
Contributor

@frangio frangio Aug 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this check needed? Why? Please add a comment.

Makes me ask why the mapping holds uint24 chain ids since uint16 seems to be the only thing that's supported.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok I understand now that this is necessary for | MASK because a wormhole id may be 0. I would add this in a comment on the mapping and/or the MASK variable.

return uint16(wormholeId);
}

/// @dev Returns the EVM chain id for a given Wormhole chain id.
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 binary interoperable chain id.
function getRemoteGateway(bytes memory chain) public view virtual returns (address) {
(uint256 chainId, ) = chain.parseEvmV1();
return getRemoteGateway(chainId);
}

/// @dev Returns the address of the remote gateway for a given EVM chain id.
function getRemoteGateway(uint256 chainId) public view virtual returns (address) {
address addr = _remoteGateways[chainId];
require(addr != address(0), UnsupportedChainId(chainId));
return addr;
}

/// @dev Registers a chain equivalence between a binary interoperable chain id and a Wormhole chain id.
function registerChainEquivalence(
bytes calldata chain,
uint16 wormholeId
) public virtual /*onlyOwner in registerChainEquivalence*/ {
(uint256 chainId, ) = chain.parseEvmV1Calldata();
registerChainEquivalence(chainId, wormholeId);
}

/// @dev Registers a chain equivalence between an EVM chain id and a Wormhole chain id.
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);
}

/// @dev Registers the address of a remote gateway (binary interoperable address version).
function registerRemoteGateway(bytes calldata remote) public virtual /*onlyOwner in registerRemoteGateway*/ {
(uint256 chainId, address addr) = remote.parseEvmV1Calldata();
registerRemoteGateway(chainId, addr);
}

/// @dev Registers the address of a remote gateway (EVM version).
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);
}

/// @inheritdoc IERC7786GatewaySource
function supportsAttribute(bytes4 selector) public pure returns (bool) {
return selector == IERC7786Attributes.requestRelay.selector;
}

/// @inheritdoc IERC7786GatewaySource
function sendMessage(
bytes calldata recipient, // Binary Interoperable Address
bytes calldata payload,
bytes[] calldata attributes
) external payable returns (bytes32 sendId) {
bool relay = false;
uint256 value = msg.value;
uint256 gasLimit = 0;

for (uint256 i = 0; i < attributes.length; ++i) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This supports duplicate requestRelay attributes which we probably shouldn't...

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

(relay, value, gasLimit, ) = ERC7786Attributes.tryDecodeRequestRelayCalldata(attributes[i]);
require(relay, UnsupportedAttribute(attributes[i].length < 0x04 ? bytes4(0) : bytes4(attributes[i])));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There should probably be a utility function to get the selector from a bytes calldata attribute.

}

if (relay) {
// TODO: Do we care about the returned "sequence"?
bytes memory encoded = abi.encode(
sendId,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ This will always be 0!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right, we need another way to deduplicate ...

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

InteroperableAddress.formatEvmV1(block.chainid, msg.sender),
recipient,
payload
);
_wormholeRelayer.sendPayloadToEvm{value: msg.value}(
getWormholeChain(recipient),
getRemoteGateway(recipient),
encoded,
value,
gasLimit
);
} else {
sendId = bytes32(++_lastSendId);

// Note: this reverts with UnsupportedChainId if the recipient is not on a supported chain.
// No real need to check the return value.
getRemoteGateway(recipient);

_pending[sendId] = PendingMessage(true, msg.sender, value, recipient, payload);
}

emit MessageSent(
sendId,
InteroperableAddress.formatEvmV1(block.chainid, msg.sender),
recipient,
payload,
value,
attributes
);
}

/// @dev Returns a quote for the value that must be passed to {requestRelay}
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;
}

/// @dev Relay a message that was initiated by {sendMessage}.
function requestRelay(bytes32 sendId, uint256 gasLimit, address /*refundRecipient*/) external payable {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changed

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"?
bytes memory encoded = abi.encode(
sendId,
InteroperableAddress.formatEvmV1(block.chainid, pmsg.sender),
pmsg.recipient,
pmsg.payload
);
_wormholeRelayer.sendPayloadToEvm{value: pmsg.value + msg.value}(
getWormholeChain(pmsg.recipient),
getRemoteGateway(pmsg.recipient),
encoded,
pmsg.value,
gasLimit
);

emit MessageRelayed(sendId);
}

/// @inheritdoc IWormholeReceiver
function receiveWormholeMessages(
bytes memory adapterPayload,
bytes[] memory additionalMessages,
bytes32 wormholeSourceAddress,
uint16 wormholeSourceChain,
bytes32 deliveryHash
) public payable virtual onlyWormholeRelayer {
require(additionalMessages.length == 0, AdditionalMessagesNotSupported());

(bytes32 sendId, bytes memory sender, bytes memory recipient, bytes memory payload) = abi.decode(
adapterPayload,
(bytes32, bytes, bytes, bytes)
);

// Wormhole to ERC-7930 translation
uint256 chainId = getChainId(wormholeSourceChain);
address addr = getRemoteGateway(chainId);

// 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[chainId].get(uint256(sendId)), MessageAlreadyExecuted(chainId, sendId));
_executed[chainId].set(uint256(sendId));

(, address target) = recipient.parseEvmV1();
bytes4 result = IERC7786Receiver(target).receiveMessage{value: msg.value}(deliveryHash, sender, payload);
require(result == IERC7786Receiver.receiveMessage.selector, ReceiverExecutionFailed());
}
}
10 changes: 10 additions & 0 deletions contracts/interfaces/IERC7786Attributes.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.4;

/**
* @dev Standard attributes for ERC-7786. These attributes may be standardized in different ERCs.
*/
interface IERC7786Attributes {
function requestRelay(uint256 value, uint256 gasLimit, address refundRecipient) external;
}
4 changes: 2 additions & 2 deletions contracts/mocks/crosschain/ERC7786ReceiverMock.sol
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {ERC7786Receiver} from "../../crosschain/utils/ERC7786Receiver.sol";
contract ERC7786ReceiverMock is ERC7786Receiver {
address private immutable _gateway;

event MessageReceived(address gateway, bytes32 receiveId, bytes sender, bytes payload);
event MessageReceived(address gateway, bytes32 receiveId, bytes sender, bytes payload, uint256 value);

constructor(address gateway_) {
_gateway = gateway_;
Expand All @@ -23,6 +23,6 @@ contract ERC7786ReceiverMock is ERC7786Receiver {
bytes calldata sender,
bytes calldata payload
) internal virtual override {
emit MessageReceived(gateway, receiveId, sender, payload);
emit MessageReceived(gateway, receiveId, sender, payload, msg.value);
}
}
32 changes: 32 additions & 0 deletions contracts/mocks/crosschain/wormhole/WormholeRelayerMock.sol
Original file line number Diff line number Diff line change
@@ -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,
Copy link
Contributor

@frangio frangio Aug 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be the source chain.

https://github.com/wormhole-foundation/wormhole-solidity-sdk/blob/575181b586a315d8f9813eab82e4cb98b45bc381/src/interfaces/IWormholeReceiver.sol#L48

Any reasonable way to do that in this mock? I think it should at least not be the target chain.

keccak256(abi.encode(seq))
);

return seq;
}
}
Loading