EIP-5164: Cross-Chain Execution

Hi @Brendan, a question from the ChainBridge team:
What’s the rationale behind making CrossChainRelayer nonpayable?
How would it be possible for the bridge to charge fees on the source chain?

Hey everybody, excuse the delay I was busy with some events.

Public vs Private Bridge

@Amxx articulated a very important design consideration: whether the ERC supports public or private bridges.

Public bridge: a bridge that anyone can use to send messages. It’s generalized, such that the receiver knows the caller on the origin chain. Many bridges are like this; Optimism, Polygon, and others.

Private bridge: a bridge is specific to a dapp. This is like Nomad’s home and replicas (i.e. relayer and receiver). Replicas need to be censorable by a dapp, so Nomad considers them dapp-specific.

The above EIP is essentially a private bridge; which means a user would need to deploy wrappers for some existing bridges. It’s compatible with Nomad, but precludes public bridges. Not ideal.

By having the EIP support public bridges it will be compatible with both approaches. A public design would still allow the user to deploy their own for a private bridge, but would support public bridges as well.

This is why I was thinking the spec should support bridge swaps @nambrot, because in its current design the receiver executes the call, making it a privileged part of the protocol. To swap the bridge we’d need to update the receiver. Not ideal.

Btw @nambrot, you asked why we would swap: for chains such as Optimism I’d prefer to use the native bridge for slow-moving pieces like governance. However, when tech like Nomad is more robust and has an incentivized censorship layer we’d be able to switch to it for faster bridging.

Additional Fields

Gas

That’s smart- I like it. Should we have a special value? I.e. if gas is zero then it’s considered no-limit?

Caller

100%. Going with a public approach would necessitate this.

Payable

That’s a cool idea! The send message function should be payable.

Summary

  • Update the EIP to be public, not private
  • Make relayCalls payable
  • Add gas limit and caller

Note: by making the bridge public the receiver contract will be verifying the caller as being their desired bridge. We won’t have to worry about authentication @drinkcoffee, as it’s dapp-specific (imagine a dapp that has a public function that can be called across a bridge).

I’ve updated the EIP with the above changes
view the new version here.

Please review and comment so that we can continue iterating!

Open Questions

Should we have a relayData call that relays a simple bytes data param? Or perhaps the Calls struct could be an extension? Would be curious to hear anyone’s thoughts.

I’m not sure how detailed the ERC should be. I personally see an approach that would be similar to what GnosisSafe does for their multisig

  • If the gas value is non zero, then the call must be performed with AT LEAST this value (we check that 1/64 of this value is remaining after the call).
    • If the call is a success, then all good (and it should not be replayed)
    • If the call is a failure despite the gas requirement being met, then the call is non-retriable (we gave it a fair chance and it failed).
  • If the gas value is zero, then any amount provided is forwarded.
    • If the call is a success, then all good (and it should not be replayed)
    • If the call is a failure, then we create a “retriable ticket” that anyone can try to run. with any amount of gas. If the retry call fails, the ticket remains available. If the retry successed, we burn the ticket to prevent double execution.

Congrats on the EIP !

Please take a look at SKALE IMA bridge since it impements a generic messaging framework that resonates well with this EIP

https://docs.skale.network/ima/1.3.x/getting-started

It is currently running on SKL main net and allows to send a message from any SKL chain to any other SKL chain and to ETH main net

If there is an industry wide EIP we at SKALE are happy to make our messaging compliant.

The hardest feature is actually assigning and reimbursing gas costs correctly

1 Like

Hey @kladkogex thanks for chiming in! I poked through the docs but the setup didn’t jump out at me. Can you include a snippet? Or perhaps have a look at the EIP and see if we’re missing anything?

@Amxx Regarding the retry logic: it seems like that it implementation-specific, no? I hesitate to include that in the EIP. As you said earlier, different bridges have different retry logic.

I do like your additional specification around the gas field. However, it feels like there are two elements to this: how much gas the CrossChainReceiver#receiveCalls fxn uses, and how much each of the calls in the batch should use.

Here’s a diagram of the EIP in its current form:

There are a couple of issues here:

  • The CrossChainReceiver#receiveCalls() fxn is called by the bridge layer. The bridge will only know the gas usage after the receiveCalls function is executed. This makes it hard to know costs up-front.
  • Each Call struct currently has a gas value, but really it’s up to the user-implemented CrossChainReceiver to respect that value. It’s not used directly by the ERC.

I noticed that in your bridge code it defines the call along the lines:

Bridge.crossChainCall(address target, bytes memory message, uint32 gasLimit)

This ERC has essentially encoded a batch of calls in the message. In your bridge, however, the gas limit essentially applies to the CrossChainReceiver#receiveCalls function.

This makes a lot more sense to me, as it is deeply functional for the bridge: the bridge now knows the expected gas limit for the CrossChainReceiver#receiveCalls function. This is made available on the sending chain as well, so the bridge could even take a payment on the sending side based on the required gas (a la the payable relayCalls fxn)

This makes me think we should follow the same logic as your bridge by adding a gas limit:

interface CrossChainRelayer {
    function relayCalls(CrossChainReceiver receiver, Call[] calldata calls, uint gasLimit);
}

We would then remove the gas field from the Call struct.

I don’t think the CrossChainReceiver needs to know the gas limit, because the limit is simply applied to the call by the bridge.

Thoughts?

1 Like

The thing is to have a retry logic that is NOT implementation specific, and that is standard to the bridge. It could technically be achieved in an extension ERC.

I honestly don’t like this dependency on “receiveCalls”. It messes the ABI, and would be difficult to implement if the calls are arbitrary.

IMO the ERC-5164 CrossChainRelayer (on the sending chain) should call one dedicated contract on the receiving chain (we’ll need a name for that), which will in turn relay the call to the user contracts, just like the AMB and Optimism bridges do today.

I believe this will be cleaner. It would also put most of the logic on the receiving end in a contract that is reused, instead of requiering every user contract to ship it (in some cases this code can be really big)

As mentionned above, the cross chain signal should not go directly to the user contract. It should go to a bridge contract, which forwards the calls one by one (which a minimum amount of gas specified in the Call structure).

This bridge contract would be able to see if execution fails, and could (in the futur?) include retry mechanisms

My vision was more something like this:

5164

Again, i believe it would be way more versatile for the user contract on the receiving side not having to implement the calls going through a dedicated receiveCalls. This receiveCalls would be part of the red “implementation specific” interface between the two sides of the bridge.

To give some image, I’d like the bridge to be a bridge, with two sides, and that you can take in both direction. Not a catapult that sends you somewhere where a mattress is needs to catch you fall. Each side of the bridge would have an address, in a city, and would ideally know the address (and the city) of the other side.

Note: the user contract would still have to be “bridge aware” so that they recover the correct sender when msg.sender == bridge … but that is a very small piece of logic that could easily support multiple bridges (as long as they implement the same “user facing” interface).

1 Like

: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