EIP-5164: Cross-Chain Execution

This is a very interesting question!

I looked at the native Polygon, Optimism, Arbitrum, and Nomad bridges to see what the lowest common denominator was, and found that the 1-to-1 (or really a kind of a multicast in the case of Nomad) was the best fit.

The current spec requires contracts on the sending chain to know the addresses of the contracts on the receiving chain. Why not also the receiving chain id?

That being said, perhaps that’s the answer: it’s optional. Some Relayers might ignore the chainId while others, like Hop, will use it.

This does mean, however, that Executors will need to include the sending chain id on the other side. For some that might be kind of redundant, and opens up for human error. But, if the contract is audited and re-used it’s far less likely.

I see you posted a response above

Yes, this is exactly the change that would be needed. You know- I think what we could do is add this to the spec, but also add something along the lines of:

Relayers MAY ignore the passed chainId if they are bound to a particular chain

But- we could require the Relayer to include the chainId in the Event (as you initially wrote). Likewise for the Executor.

Really, the critical thing about this spec is:

  1. A consistent call interface
  2. A consistent way to check the original sender / chain

We can allow Relayer and Executor implementations to take shortcuts in certain ways, as long as they adhere to the fundamentals.

Ok let’s review:

  1. Add a relayCall fxn
  2. Add a chainId param to the relay, but allow the relay to ignore it
  3. The relay should emit the target chain as part of the relayed event
  4. The executor should include the receiving chainId in the event and append it to the calldata

I like these changes! @Pierrick curious to hear your thoughts too.

Chris- do you want to make a PR with the changes and tack your name on to the authors? It’d be great for you to be part of this.

I think these changes are straightforward, and we can update our code to match.

Would love to! And totally agree with everything above. We’ll get going on the implementation too and should have something soon :saluting_face:

Thank you!

2 Likes

Thank you for the draft. This is going to be an impactful EIP.

QQ: Have you considered adding an extension field to this function

turning

interface CrossChainRelayer {
  function relayCalls(Call[] calldata calls, uint256 gasLimit) external payable returns (uint256 nonce);
}

into

interface CrossChainRelayer {
  function relayCalls(Call[] calldata calls, uint256 gasLimit, bytes calldata extraData /*EIP-5750 field*/) external payable returns (uint256 nonce);
}

and so it can be “future expandable” for things like this:

  • someone wants to send raw eth or wrapped eth along with the call
  • achieve something like a commit-reveal or commit-timelock with EIP-5732 Commit Interface
  • achieve additional authorization with something like EIP-5453 Endorsement Interface to query EIP-1271 in one or both chains of the cross-chain

See EIP-5750 for more details: EIP-5750: General Extensibility for Method Behaviors

1 Like

Hi @xinbenlv!

@cwhinfrey is currently working on a big update to the EIP. The PR is awaiting more changes, but you can keep an eye on the EIP-5164 Update PR on Github for updates. The PR should be updated within a few days.

Where we landed was to have the events and the Executor semantics as part of the core EIP, and the sending function as an optional extension within the EIP (like 721 metadata). Just as you say, there could be many additional parameters or returns values depending on the bridge.

The critical functionality is the receiver semantics with the encoded sender appended to the calldata, and it’s really nice to have standardized events so that calls can be traced with common infrastructure.

So all that being said, what do you think about allowing diff send functions rather than having a data blob? curious to hear if you have any more thoughts around that

Returning to this thread after a long while, since I’m working on a bridge aggregator implementation.

Wondering about the types used here:

interface MessageDispatcher {
  event MessageDispatched(
    bytes32 indexed messageId,
    address indexed from,
    uint256 indexed toChainId,
    address to,
    bytes data,
  );
}
interface SingleMessageDispatcher is MessageDispatcher {
  function dispatchMessage(uint256 toChainId, address to, bytes calldata data) external payable returns (bytes32 messageId);
}

Specifically, I’m wondering if to and from (and perhaps even chainId) should all be bytes32?
The rationale being that this might allow for:

  1. some extra room that could be used for things like chain-specific addresses (not strictly necessary given chainId is explicitly provided).
  2. greater flexibility for interoperability with networks that don’t conform to the EVM’s 20-byte account namespace.

I also noticed that dispatchMessageBatch() requires one toChainID for all batched messages. I see no reason why a batch of messages should not be able to include messages to multiple chains.

interface BatchedMessageDispatcher is MessageDispatcher {
  function dispatchMessageBatch(uint256 toChainId, Message[] calldata messages) external payable returns (bytes32 messageId);
}

Perhaps it would be better for this information to be in the Message struct?

struct Message {
    address to;
    bytes data;
    uint256 toChainId;
}

I also wonder if dispatchMessage() is even necessary?
The interface and implementation could be simplified by only exposing the batch versions of the functions and events. Users can still choose to send a single message via these functions if they wish.

Hey Auryn! Welcome back :slight_smile:

That’s an interesting idea. It would definitely offer more flexibility in terms of dispatching messages to non-EVM chains. It does make me think:

  • Is bytes32 sufficient for the majority of non-EVM chains? I’m not familiar enough with other chain encodings to speak to this.
  • Is bytes32 too abstract? For EVM chains the casting is obvious, but would simply have a 256-bit “bucket” leave the encoding too ambiguous for other blockchains?
  • Supporting non-EVM chains would only cover the dispatch of messages; the execution of messages would be open to interpretation. It seems like we’d need additional ERCs or extensions to codify other blockchains.

So, I guess tldr; is the question of whether bytes32 is enough for all chains, and if we’re okay that it only covers that dispatch side for non-EVM chains. Would be curious to hear your thoughts on this.

I think that’s a good observation. IIRC our assumption was that batching would mostly be done for one chain, so we’d save calldata by defining the toChainId only once. But the flexibility might be worth it!

That would simplify the signature to:

  function dispatchMessages(Message[] calldata messages) external payable returns (bytes32 messageId);

Which I do like.

IIRC we added that because it would likely be the most common use case. However, that function could easily be provided by wrapper function in a library.

This would also mean that there would be a single way to call an ERC5164 dispatcher, so it would ensure that implementations would be swappable (vs having multiple ways of dispatching).

@cwhinfrey What are your thoughts on this? We went back and forth quite a bit over the two function signatures! I’d be happy consolidating them into the batch fxn and adding chainId to a Message struct.

Right, so the entire interface could look like this:

struct Message {
    address to;
    uint256 toChainId;
    bytes data;
}

interface MessageDispatcher {
    event MessageDispatched(
        bytes32 indexed messageId,
        address indexed from,
        uint256 indexed toChainId,
        address to,
        bytes data
    );

    function dispatchMessages(Message[] memory messages) external payable returns (bytes32[] memory messageIds);
}
1 Like

Hey @auryn, great to see you (back) in here!

I think this is more of a subjective question about the scope of the EIP. My vote would be to keep this scoped to EVM chains to avoid getting too generalized. I think that would maximize adoption on Ethereum but definitely open to other options and opinions on this.

I wanted to add some color behind some of the discussions and design decisions here and it might be worth adding some of this to the EIP itself if we stay on this course. This is my understanding of the design decisions of the EIP after a lot of discussion with @Brendan @Pierrick and others:

The taxonomy of cross-chain messages and how they’re initiated is very broad. We can approach this standard by just supporting a subset or keep it extensible like it currently is. I attempted to break down some of the different messaging paradigms here but please push back, poke holes, or let me know if something should be added: An Incomplete Taxonomy of Cross-Chain Messages · GitHub

The top level interfaces MessageDispatcher and MessageExecutor standardize two things:

  1. The events emitted when initiating and executing messages or message batches. This is useful for offchain indexers.
  2. How the contract receiving a message can parse out the messageId, fromChainId, from needed for validation and event logging. This has sometimes been referred to as the “message receiver interface” in discussions.

Both of these standards are useful for all the messaging paradigms described in the link above. However, standardizing the send/dispatch interface is very hard or impossible without cutting out many use cases that would benefit from the standardized events and receiver interface. A messenger that supports one message paradigm can’t necessarily be swapped out for a messenger that supports another even if we can get all the necessary data into a single interface anyway.

It was this reason that drove the idea to allow for extensions of MessageDispatcher that each can define an interface for a given messaging paradigm. Contracts sending messages can use these interfaces knowing they can swap out a different messenger if it supports the same type of messaging which is the primary advantage of standardizing the send/dispatch interface in the first place. The EIP defines two extensions but you can see in the link above that there are many unique extensions that could be standardized in the future for the many different messaging paradigms that aren’t necessarily compatible with each other under a unified interface.

Would love to hear any thoughts on all of this.

1 Like

Regarding the batched messages, if nothing changes, we should probably clarify in the EIP that the batches are atomic so that message senders can rely on consistent functionality across BatchedMessageDispatcher implementations. Non-atomic batches could be sent and handled as individual single messages and therefore have different chainIds. Atomic batches obviously need to get executed all on one chain though.

1 Like

Hey all! After @auryn’s comments @cwhinfrey and I had a lot of back and forth privately, and I want to share highlights from our discussions here.

These two points raised by Auryn in particular:

  1. Why not have a chainId attached to each Message so that you can batch messages across multiple chains?
  2. Why not get rid of dispatchMessage?
  3. Why not use bytes32 for addresses, to provide more space for non-EVM addresses?

Regarding point 1:

We can’t batch messages across multiple chains because we’d have no atomicity guarantees. Atomic transaction execution across chains is impossible to guarantee.

What is interesting, however, is that this isn’t clear in the EIP. It’s evident that atomicity of batches might lack clarity…

Regarding point 2:

Should we eliminate dispatchMessage in favour of solely using dispatchMessageBatch? That question begs another question: what is the base messaging primitive here?

The lowest common denominator among bridges is the ability to bridge a single message. Batching is a nice to have, but nowhere to be seen. Given the lack of clarity around atomicity, it seems conceptually the simplest to just go with a single message function. Messages can easily be bundled over a single message dispatch as an abstraction.

By eliminating batching, the atomicity of a message becomes much simpler: either it executed or it didn’t. Simplicity is king. We can always implement batching as a layer on top.

The base primitive here is the single message, and adding complexity on top of that may be enticing but complicates the specification.

Regarding point 3:

This is the hardest point to try to predict: whether to treat addresses as bytes32. It leaves the door open for chains whose addresses are greater than 20 bytes, but not does define how those addresses will be formatted.

For EVM chains, it wouldn’t be hard to cast an address to bytes32 and back, the friction isn’t too bad. But for non-EVM chains, what should the address format be? If the spec is too ambiguous it may render the standard useless if people implement different approaches. If there is ambiguity, how are indexers supposed to interpret which chains the messages are going to?

Instead, it feels simpler to keep it address and let non-EVM specs handle non-EVM messaging.

Summary

So, to summarize our proposed changes:

  • Eliminate BatchedMessageDispatcher in favour of a single MessageDispatcher

We believe this would simplify the spec where it could be implemented by anyone, and we can build more primitives on top of it (message batching, remote controlled accounts, etc)

@cwhinfrey did I miss anything? Anything you want to add?

@auryn Any thoughts on this? How does keeping things simple sound?

1 Like

Hhhmm, is it possible at all to have atomic cross-chain transactions? Sounds like an oxymoron to me. Cross-chain transactions seem as if they are necessarily asynchronous.

Nevertheless, making the default entrypoint batch compatible doesn’t imply anything about the messages themselves being dispatched in bulk. In Hashi’s message dispatcher, I made the default entrypoint receive batches of messages, however they are each dispatched individually and emit an individual event.

This is what IMessageDispatcher currently looks like in Hashi.

// SPDX-License-Identifier: LGPL-3.0-only
pragma solidity ^0.8.17;

import "./IMessage.sol";

interface MessageDispatcher {
    event MessageDispatched(
        bytes32 indexed messageId,
        address indexed from,
        uint256 indexed toChainId,
        address to,
        bytes data
    );

    function dispatchMessages(Message[] memory messages) external payable returns (bytes32[] memory messageIds);
}

I still favour including chainID in the message struct, here is how I’ve implemented it in Hashi.

I think there’s some understandable confusion here. He’s saying is that the batch is atomic, not the cross-chain transaction as a whole. So either the whole batch executes or the whole batch reverts which makes it atomic but it’s not atomic with the dispatch transaction on the source chain.

That makes sense here because this interface is for sending an array of individual messages and not an atomic batch that needs to be executed on a single destination chain. So I think the only decision point remaining is whether the standard exposes an interface for sending a single message or an array of independent single messages like you have with Hashi.

function dispatchMessage(uint256 toChainId, address to, bytes calldata data) external payable returns (bytes32 messageId);

vs

struct Message {
    address to;
    uint256 toChainId;
    bytes data;
}

function dispatchMessages(Message[] memory messages) external payable returns (bytes32[] memory messageIds);

One point I had raised is that the native rollup bridges almost exclusively expose interfaces for sending just one message and could benefit from this standard. Most applications only send individual messages too. So maybe the standard could stick with the interface for a single messages and applications could just call it once per message or messengers could choose to expose an interface accepting an array of messages as well for convenience if they choose.

Sums it up nicely! :raised_hands:

1 Like

I think there’s two issues with this:

  1. native rollup bridges probably don’t need the chainId parameter, since chainId is implied by the bridge contract. Not aware of any roll-ups that share a bridge contract.
  2. just because the currently expose an interface for sending just one message doesn’t mean that they could not (and probably should) expose an endpoint for batched transactions.

Hey folks! Was on vacation excuse the delay

What I meant was that the batch is atomic on the receiving chain; i.e. the batch is either fully executed or not. That kind of atomicity can be guaranteed on a single receiving chain executing a batch of messages at once.

That was my original thinking too; but there are bridges such as Hop that have a single dispatch contract for multiple receiving chains.

Without the chainId you can’t have an aggregated dispatcher, but with a chainId you can have both. The chainId provides optionality; a dispatcher that serves a single chain just needs to validate that the passed chainId matches the target chainId. A dispatcher for multiple chains can recognize more than one chainId.

Batching

I’ve gone back and forth on batching, but at the end of the day my desire is to keep the spec as simple as possible and optimize for the single message use-case, as that’s the use case I’ve seen most commonly.

I don’t think batching will be heavily used, because it means there needs to be more bridge-specific logic: contracts receiving messages have to be 5164-aware to decode who the cross-chain sender is. Rather than deploy multiple 5164-aware contracts, it makes more sense to deploy a single 5164 contract that acts as a kind of “remote account” that is controlled by a sender from another chain. Developers will likely batch transactions themselves to this “remote account” that is the “owner” of contracts on the receiving chain. I know both Hyperlane and Hop are using this approach (perhaps that can be another EIP!).

Having a simpler spec will make adoption and consistency across implementations much easier to achieve. Batching can be a layer on top!

More Thoughts on Traceability

A couple of implementors have reached out to me to ask how they should format the messageIds. They weren’t sure what they should hash, or how unique it needs to be. This got me thinking about situations where the messageId is not terribly unique; i.e. a basic integer. The spec should be strong enough to eliminate ambiguity.

Here are the current events:

interface Dispatcher {
    event MessageDispatched(
      bytes32 indexed messageId,
      address indexed from,
      uint256 indexed toChainId,
      address to,
      bytes data,
    );
}
interface Executor {
    event MessageIdExecuted(
      uint256 indexed fromChainId,
      bytes32 indexed messageId
    );
}

The call flow goes:

caller -> Dispatcher -> (transport layer) -> Executor -> receiver

Let’s pretend we’re an indexer and trace from the caller to the receiver:

  1. The indexer watches the caller for events.
  2. The indexer sees the MessageDispatched event triggered by the caller, and looks at the chain with the toChainId.
  3. The to chain should have a MessageIdExecuted event, but what if the messageId is too generic? It would be difficult to determine exactly where the message came from, because it might match multiple dispatchers.

I think we need to add the Dispatcher address to the MessageExecuted event, so that the trace can be fully deterministic. The combination (fromChainId, dispatcherAddress, messageId) is guaranteed to be unique as long as message ids are unique within the scope of the dispatcher.

Finalizing

I think we’re close to finalizing this thing! I’m going to submit a PR for the EIP you guys to review. Let’s close this out.

What if the spec just specified that the messageId should be unique across any deployment of connected dispatchers and executors? This is likely to be true already if implementers are hashing the source chainId as apart of the messageId. Are there any situations you imagine where dispatcher/executor deployments from different implementers would overlap?

Indexers will have a list of addresses they’re watching anyway so the burden of keeping those address in groups representing a full messenger deployment seems low. For example a message bridge might have a messenger address on 5 different networks with each address being both an executor and dispatcher. It seems reasonable for indexers to index and pair up events from any given deployment independently from other deployments. Most indexers will only be concerned with a single messenger deployment anyway.

I benchmarked adding the dispatcher address to the execute event in our implementation and it’s roughly 2,400 gas overhead. It would require the executor keeping a mapping of the dispatchers by chainId since it doesn’t already have this context. So at 20 gwei that comes out to 0.00004816 ETH or ~$0.09 overhead per message executed on L1 if my calculations are correct.

It’s not the end of the world but the use case of having a message indexer/explorer spanning all messenger deployments doesn’t seem obviously valuable. Being able to index events for specific deployments in a generic way does seem valuable though and is achieved by recommending uniqueness of messageId on a deployment basis.

Right there with you though. Will keep an eye out for this!

I’ve added some additional recommendations to the EIP on how to generate the message id. This should help.

EIP-5164 Pull Request is here

Let’s get this finalized!