EIP-5164: Cross-Chain Execution

:joy: I like to of it as a one way street; when you combine two it becomes a two-way street. Although a catapult would be more fun to watch!

I agree with you in that I’d like the user space “receiver” to be as simple as possible. The trick is that we want the receiver to recognize both the bridge contract (to authorize the transport layer) and the caller on the other side (to authorize the action). Passing the caller explicitly seems like the simplest 80 IQ approach…but what else are you thinking? Perhaps the GSN-style extra calldata bytes for the sender?

You know what is interesting- the CrossChainRelayer (receiver) in your diagram is basically an implementation of the CrossChainReceiver. I quite like this as it specifically defines how the Call[] batch is handled, instead of leaving it up to a user-space implementation.

Quick update for everyone else: Pierrick from PoolTogether is starting work on ERC-5164 implementations for the native Optimism, Arbitrum, and Polygon message bridges. We’re going to collaborate with @Amxx and use his prototype work. The implementations will help us refine the spec, then when we’re ready we’ll finalize the ERC and audit the bridges. Then we’ll have standardized bridges that everyone can use!

You know what the meme says, sometimes the most stupid things are also the smartest ones.

I would love to see contracts do something like this:

interface ICrossChainReceiver {
    function foreignChainId() returns (uint256); // returns the chainId of the foreign chain
    function foreignSender() returns (address); // returns the address of the cross chain sender if during a crosschain call
}

contract BridgeAware {
    mapping(address => bool) isBridge;
    mapping(uint256 => mapping(address => bool) authorized;

    modifier onlyAuthorized() {
        bool isCrossChain = isBridge[msg.sender];
        address chain = isCrossChain ? IBridge(msg.sender).foreignChainId() : block.chainid;
        address sender = isCrossChain ? IBridge(msg.sender).foreignSender() : msg.sender;
        require(authorized[chain][sender], "Not authorized");
        _;
    }
}

Hmmm… the trouble with the foreign chain id is that it can’t be enforced or verified; it’s just a value set by the creator of the CCReceiver contract. I think it makes more sense to use the address of the CCReceiver itself to authenticate the transport layer, because then the user contract can verify the transport layer using the basic msg.sender == receiver. This also aligns better with Nomad- because Replicas don’t actually track the sending chain id.

For the foreignSender; it feels like that would be gas-intensive wouldn’t it? It would need to do a 5k SSTORE to track the sender across calls, then each call would need do to an external call to the bridge to check (1k+).

I swear you were going to suggest the hidden call argument approach! What do you think about that?

It would look something like:

interface ICrossChainReceiver {
}

contract BridgeAware {
    address crossChainReceiver;
    address foreignCaller;

    modifier onlyAuthorized() {
        require(msg.sender == crossChainReceiver, "not transport layer");
        require(_foreignCaller() == foreignCaller, "not authorized");
    }

    function _foreignCaller() internal view returns (address caller) {
        assembly {
            caller := shr(96, calldataload(sub(calldatasize(), 20)))
        } 
    }
}

It does mean that the CrossChainReceiver would need to pack in the extra calldata when it calls the user contracts:

interface ICrossChainReceiver {
    function receiveCalls(...) {
        ...
        (bool success, bytes memory returndata) = req.to.call{gas: req.gas, value: req.value}(
            abi.encodePacked(req.data, foreignCaller)
        );
        ...
    }
}

I think this would be the most gas efficient. Users would need to use a tiny lib to interpret the foreignCaller, but it would be easy enough.

Thoughts?

I agree that the transport layer identification should be done using the CCReceiver address. The foreignChain() would be metadata, that helps identifying, without being trustworthy by default (like name or symbol for ERC20).

In my example above the bridges are whitelisted, so I assume checking the foreignChainId they return would be part of the whitelisting process.

That is EIP-2771. I’m actually listed as one of the author.
Its really not a bad idea:

  • For many bridges, I assumed foreignCaller() would just be a proxy, and not need storage. But in some cases storage will be needed.
  • EIP-1153 would make that cheaper if implemented, but that is not something we should rely on.
  • EIP-2771 is a meta-tx relay mechanism, and for sure the bridge matches this logic. It is probably the smartest move here.
1 Like

Hello magicians,

It’s been a while since we gave you an update.

The EIP entered the Draft stage on September 22.

And we have been working on several implementations using native bridges for Arbitrum, Optimism and Polygon. This work is available in this repository:

All this work helped us test our interfaces with various bridges, which outlined the short comings of the EIP. Bridges process messages in various ways and we have realized that our EIP should only define how messages are being relayed to other chains and traced. Execution and authentication of these messages is left to the developers to implement.

Transaction ordering is not specified but developers can use the nonce if they wish to enforce it in their application.

The updated EIP is available in the following PR: Update EIP-5164: Move to review by PierrickGT · Pull Request #5808 · ethereum/EIPs · GitHub

We are awaiting your feedback before this EIP enters the review stage.

1 Like

EIP-5164 entered the Review stage on October 31.

The ERC-5164 repository has been updated with the latest changes made to the interface.
A how to section has also been added to the README: GitHub - pooltogether/ERC5164

It should make it easier for people to use the various bridges and also contribute.

We are awaiting your feedback before the EIP enters the Last Call stage.

1 Like

Tomorrow there will be a C4 auditing contest for the above ERC-5164 reference implementation.

It will be a great time to find any faults with the implementation, because you’ll get paid! The prize pool is ~$30k USDC.

The contest starts tomorrow, on Dec 1st at 12:00 PST. The contest will run for three days.

The audit will cover the current implementation, which includes:

  • Bridge from Ethereum to Optimism
  • Bridge from Ethereum to Arbitrum
  • Bridge from Ethereum to Polygon

Hopefully in the future we’ll see more implementations for L2s such as zkSync and for bridges such as Nomad, Hyperlane and others!

1 Like

First of all, really excited to see this all coming along! I know I’m jumping in late to the conversation here but Hop is going to release a cross-chain messenger soon so this is top of mind for us.

I generally agree that a standard as @Amxx suggested is very much needed even if this EIP doesn’t go that direction. Both Optimism and Arbitrum built their bridges with this pattern from the beginning and we’re using this pattern for our messenger as well. I also really like your idea @Brendan to use the hidden call argument approach instead of the callback approach currently used. Either way, a standard way to validate crossChainSender and crossChainChainId after receiving a call would be very useful.

I’m also admittedly confused about who is intended to implement ICrossChainReceiver. If every project is deploying their own ICrossChainReceiver and they all still need to do authentication against non-standard message bridge contracts, (e.g. https://github.com/hop-protocol/contracts-v2/blob/master/contracts/connectors/L1ArbitrumConnector.sol#L33-L34) then who benefits from this being standardized?

But if many projects all intended to use the same CrossChainReceiver, why should receiveCalls be standardized? I would think projects consuming this standard only care about how to validate the crossChainSender and crossChainChainId when a call is received from the CrossChainReceiver and not implementation details of how CrossChainReceiver receives the calls.

Hey Chris! Nice to see you here.

I had a look at what you were referring to and agreed, but then I realized that the link at the top was outdated!

Here is the latest EIP-5164

It’s exactly as you said- receiveCalls is not part of the spec.

Here is the reference implementation on Github. (the code is easy to grok)

Are we going to see a Hop implementation? I’d love to see it

One last thing: yes the CrossChainRelayers and Executors are intended to be reused!

1 Like

There are two goals we’d really like to see a cross-chain messaging standard achieve:

  1. Contracts could really use a standard way to validate crossChainSender and crossChainChainId after receiving a call from a messenger. Currently, Optimism and Arbitrum have slightly varied interfaces and don’t account for crossChainChainId since they’re only dealing with a single chain-to-chain connection. This could be done the way @Amxx suggested or with the hidden call argument approach.

  2. Off-chain messaging explorers could use a standard way to track cross-chain messages being sent and completed. This just requires standard events for message sending, relaying, and reverts.

Something like this seems relatively unopinionated and achieves both goals:

interface ICrossChainSource {
    event MessageSent(
        bytes32 indexed messageId,
        address indexed from,
        uint256 indexed toChainId,
        address to,
        bytes data
    );
}


interface ICrossChainDestination {
    event MessageRelayed(
        bytes32 messageId,
        uint256 fromChainId,
        address indexed from,
        address indexed to
    );

    event MessageReverted(
        bytes32 messageId,
        uint256 fromChainId,
        address indexed from,
        address indexed to
    );

    function getCrossChainSender() external view returns (address);
    function getCrossChainChainId() external view returns (uint256);
}

Could this live alongside the current suggested standard or would it be a competing standard?

Yes! Hop implementation coming soon! :slight_smile:

I looked through the 5164 and definitely easy to grok too

This is basically what’s the in spec. With a few notable differences:

  • The 5164 includes a gasLimit, but early reports from auditors suggests that it’s not needed. This may be taken out.
  • 5164 batches messages instead of sending them individually
  • 5164 does not catch reverts; they simply revert as normal. This was in case they needed to be retried, or what have you

Someone that you’ve added, which is interesting, is the targetChainId on the Relayer. We avoided adding this because it’s kind of out-of-band information, in the sense that it would be just arbitrarily set by the Relayer contract and not really guarantee anything. Additionally, indexers or whoever is watching the relayer would have to know what the corresponding Executor is anyway on the receiving chain. It already needs to know information that isn’t available on-chain.

PS: I did wonder whether batching was absolutely necessary for the EIP.

Ok after catching up the latest, here are some thoughts:

  • It’s great that validation using the hidden call parameter approach is defined!

    interface CrossChainExecutor {
      bytes calldata = abi.encode(Call.data, nonce, sender); // Can also use abi.encodePacked
    }
    

    What’s the reason for including nonce here if replay protection is handled by the ICrossChainReceiver though?

  • Would it make sense to rename nonce to messageId or something like that and use bytes32? I think there’s some expectation for nonces to be sequential like they are on Ethereum even though it’s not part of the definition of a nonce. We use a hash as the message unique identifier so it would just be a giant random uint256 which works but isn’t ideal.

  • I also like that a gaslimit is not included for individual messages in the batch in . Is it needed at all though?

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

I really like the batching! Any thoughts on including a relayCall option as well? I can get some more data on it but I think the gas savings could be non-trivial and it’s two less words of calldata.

interface CrossChainRelayer {
  function relayCall(Call calldata call, uint256 gasLimit) external payable returns (uint256 nonce);
}

I think everything else could stay as is too.

1 Like

On the Relayer side, there is a definite order to the calls. However, we don’t require strict ordering on the Executor side because that would open the ability to grief for some bridges (Arbitrum, in particular).

Instead, the Executor just uses the nonce to prevent replay attacks. However, we make the ordering available to the Receiver so that it at least has the ability to detect out-of-order messages.

Because of the above rationale, no.

Yes! It might get pulled because the auditors didn’t like it either :slightly_smiling_face:

Yeah; I like the single call option.

What changes to the events would you make to support single + batching?

i.e in terms of harmonizing them

And then lastly, the standard is fairly opinionated that CrossChainRelayer CrossChainExecutor pairs should be between just two chains.

We’ll support 5 chains so I think we’d need to deploy 5 + 4 + 3 + 2 + 1 = 15 different CrossChainRelayer CrossChainExecutor pairs and it could get out of hand as we support more chains. How should multi-chain message bridges think about supporting this? Would it make sense to have two separate standards, one that’s one-to-one and one that’s many-to-many?

No strong opinion here. I think I’d loosely lean toward no change since it’s simpler and you could just do something like for single calls

  emit RelayedCalls(
    nonce,
    sender,
    [call],
    gasLimit
  );

That makes a lot of sense. Our issue is more related to being a many-to-many chain message bridge. For one-to-one message bridges a sequential nonce is enough to have a unique id for the message. But for many-to-many, you’d have collisions across the different sources. That’s why we use a hash instead.