Skip to content

feat: hashed cross domain submarine sends #226

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
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
167 changes: 167 additions & 0 deletions ecosystem/submarine-sends.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# Purpose

Superchain interop enables new low-latency DeFi actions across chains. The protocol ensures integrity of the messages passed between chains, but additional app-level measures must also be in place to ensure users get the same single-chain DeFi experience.

## Goals

- Cross chain DeFi actions do not expose the user to additional 3rdparty risk

## Non Goals

- Masquerade submarine sends from regular transfer of funds.

# Problem Statement + Context

When using the `L2ToL2CrossDomainMessenger`, the target address and calldata is immediately made public when the message is sent. The outbox on the source chain becomes a visible mempool of transactions for a given destination.

For many use cases, this can be fine but in DeFi can undermine fairness, due to front running. [Submarine Sends](https://libsubmarine.org/) is a way to mitigate these kinds of problems, where the transaction is made private until executed in a commit / reveal structure.

# Proposed Solution

We can take inspiration by replicating a commit / reveal scheme for data sent between chains. Enabling cross chain transactions between contracts where the intent remains hidden until the reveal on the destination chain.

Unlike [Submarine Sends](https://libsubmarine.org/) which also provides the ability to make the commit transaction indistinguishable from a normal value transfer, the funds and commitment have to be accounted for and propogated through the `L2ToL2CrossDomainMessenger` which can allow for an external entinty to differentiate between a hashed cross domain submarine send and not. However this is not a problem since DeFi attacks rely on understanding the intent of the transaction which we show remains masked.

Choose a reason for hiding this comment

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

nit: entinty -> entity

Choose a reason for hiding this comment

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

nit: propogated => propagated


- Maybe there's some additional design where the destination escrow address is fresh on every call, achieving the same property but that is left out of scope for now.

## Commitment

The commitment for a submarine send is the sensitive information in the outbound `SentMessage` event, `target` and `message`. For submarine sends where the same target & message can be used more than once, a salt can be provided to ensure unique commitments per transaction.

```solidity
bytes memory payload = abi.encodePacked(salt, target, message)
bytes32 commitment = keccak256(payload)
```

By posting just the commitment, **only the the submitting user with knowledge of the preimage would be able to reveal the transaction** when relaying. If this user encounters issues between submission and relay, the preimage for that data is also lost.

Choose a reason for hiding this comment

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

nit: only the the submitting -> only the submitting


To solve for this, the payload is encrypted and posted as calldata to the submitting transaction. By using ECDH, Elliptic Curve Diffie-Hellman, the data can be encrypted against any public key, allowing for the user to designate a 3rdparty to conduct the relay on their behalf.
Copy link

Choose a reason for hiding this comment

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

I'd suggest leaving this preimage posting unspecified. If a user wants to post it encrypted onchain they can, but many implementations may prefer an out of band dissemination

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yea that's a good point! This all can be encoded as opaque bytes represented with 1 argument. Everything else should be good


1. Generate A Shared Secret Using ECDH. The user locally generates an empheral keypair to create a shared secret with the private key and the relaying public key
```ts
const emphemeralPrivateKey = generatePrivateKey();
const sharedSecret = secp.ecdh(relayerPublicKey, emphemeralPrivateKey);
```
2. AES encrypt the data using the shared secret and the first 12 bytes of the commitment as the iv tag
```ts
const iv = commitment.slice(0, 12);
const cipher = crypto.createCipheriv("aes-256-gcm", sharedSecret, iv);
const data = cipher.update(payload);
```
3. Publish Commitment, Encrypted Data, Ephemeral Public Key, Cipher Auth Tag onchain. Only the commitment needs to be propogated. All else can be referenced in the calldata by the relaying entity from the sending transaction to decrypt the data.
```solidity
function commit(bytes32 commitment, address relayer, bytes calldata data, bytes calldata ephemeralPubKey, bytes calldata cipherAuthTag) external
```

## Reveal

A designated relayer can hook into events emitted by the submarine contract to detect a new commitment that they can decrypt. The reveal takes the following steps

1. Parse necessary data from calldata for decryption -- commitment, data, empheral public key, cipher auth tag
2. Decrypt the payload with the recomputed shared secret using the relayer's private key and emphemeral public key.
```ts
const iv = commitment.slice(0, 12);
const sharedSecret = secp.ecdh(emphemeralPublicKey, relayerPrivateKey);
const decipher = crypto
.createDecipheriv("aes-256-gcm", sharedSecret, iv)
.setAuthTag(cipherAuthTag);
const payload = decipher.update(encryptedData);
```
3. Reveal the payload with the submarine contract. The commitment hash can be recomputed and checked.
```solidity
function reveal(bytes32 commitment, uint256 salt, address target, bytes calldata message) external
```

## Cross Domain Submarine

By sending the commitment hash between L2s via the L2ToL2CrossDomainMessenger, we gain the ability to privately send transactions. However, submarine sends are typically accompanied with funds with the transaction. i.e Bridge and Swap.

The SuperchainTokenBridge & ETH bridge are designed as single cross chain messages. Thus the cross domain submarine contract needs to be able to receive and escrow sent funds prior to excuting the reveal transaction. Thus, a cross domain submarine transaction requires 2 relays on the destination

Choose a reason for hiding this comment

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

Nit: prior to excuting -> prior to executing


1. Send and escrowed funds in the submarine contract
2. Relay message the propogated commitment and details about funds owed to the target.

Sample snippet on how this can work for ETH. Generalizable to SuperchainERC20s

Choose a reason for hiding this comment

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

I am not entirely sure, but it sounds like apps will basically have one submarine contract per chain per app, and they will do submarine sends via this one submarine contract. If this is true (pls confirm), then what happens when there is a race condition on the token amount / swap? e.g. if two users send ETH (from same or diff chain) to the same submarine contract on the one chain, users can have their onward transaction processed out of order. Or is there a baseline assumption that interop txns will be ordered?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

good q!

Yep just like how apps integrate with the SuperchainTokenBridge or L2ToL2CrossDomainMessenger, they can integrate with this. Probably would have a handler contracts on both sides that integrates with the DEX

This design retains the same ordering properties of the token bridge and messenger. There is no enforced ordering. Relayers can relay messages in whatever order they would like.

Since the submarine sender specifies the relayer ahead of time (labs likely would provide a public goods one). I would imagine here we would prolly relay in order as much as possible. However this would not be enshrined in the sumbarine contract.

If you wanted to enforce ordering though, you could do that in the handler contract. what are your thoughts?

Copy link

@simplyoptimistic simplyoptimistic Apr 6, 2025

Choose a reason for hiding this comment

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

Ideally ordering should be enforced on the protocol level because any sort of cross chain action that involves an initial token transfer followed by some defi action would have this race condition, meaning each protocol would have to implement this logic. It could be as simple as having certain messages be contingent on a transfer (so until your token transfer passes, we cannot progress). I think this is something hyperlane is going to implement.

Re: the submarine send contract, it would make sense if this could be wrapped together with an interchain account router type feature, as it would mean that crosschain features that require no new contracts could just involve a crosschain transfer followed by multicalls to execute the remainder of the defi logic.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

because any sort of cross chain action that involves an initial token transfer followed by some defi action would have this race condition

Maybe I misunderstood. The ordering for this part is enforced. The hashed defi action cannot occur before the funds are available in the destination. And this can be batched together so that it does not require separate txs.

Ordering for that is enforced here:

   // Ensure delivery before marking the commitment as pending
   require(messenger.successfulMessages(sentETHMsg));

The ordering that is not enforced is between two unrelated submarine actions.

Can you elaborate on the interchain account feature here? The sender is agnostic of any particular target. So it would be compatible with any crosschain action

Choose a reason for hiding this comment

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

Maybe I misunderstood. The ordering for this part is enforced. The hashed defi action cannot occur before the funds are available in the destination. And this can be batched together so that it does not require separate txs.

Got it, then that's fine. If the transactions are always batched or at least dependent on a prior txn, then race conditions are not an issue.

Can you elaborate on the interchain account feature here? The sender is agnostic of any particular target. So it would be compatible with any crosschain action

The interchain account router allows you to chain arbitrary calls on the destination, e.g. on receiving funds, the contract can swap them for a different asset, and then deposit them into a contract (or something like that). My expectation is that most users that use interop will want the ability to do something like this at least initially, and it would not make sense for each protocol to deploy their own multicall as the recipient of the instructions (i.e. it should be folded into the interop contracts). Does that kind of make sense?

This means that when interop ships, it would be easy for an app developer to create a webapp/contract that allows users to bridge cross chain then do things on other chains, without having to deploy any contracts on other chains at all. They could craft payloads that they can submit on any origin chain, which could be executed on any destination chain against any contracts that already exist on that chain.

Of course, as protocols more heavily integrate interop, this will become less relevant. But at least it lessens the barrier to doing things crosschain on day 1.

```solidity
IL2ToL2CrossDomainMessenger messenger;
ISuperchainETHBridge ethBridge;
mapping(bytes32 => uint256) ethEscrow;
mapping(bytes32 => bool) pendingCommitments;

/// @notice include the calldata parameters required for the encrypted payload
function submarineSendETH(uint256 _chainId, bytes32 commitment, ...) external payable {
uint256 ethAmount = msg.value;
bytes32 sentETHMsg = bridge.sendETH{value: ethAmount}(address(this), _chainId);
bridge.sendMessage(_chainId, address(this), abi.encodeCall(this.handleSubmarineSendETH, sentETHMsg, commitment, ethAmount));
}

/// @notice keep accounting the commitment and funds received
function handleSubmarineSendETH(bytes32 sentETHMsg, bytes32 commitment, uint256 value) onlyMessenger external payable {
// Ensure delivery before marking the commitment as pending
require(messenger.successfulMessages(sentETHMsg));

// If the same commitment has been used, ensure delivery of the first.
// Ideally we can ensure every commitment is unique to avoid this edge case
require(!pendingCommitments[commitment]);

ethEscrow[commitment] = value;
pendingCommitments[commitment] = true;
}
```

By asserting that the bridged funds are delivered first we can be sure that during the reveal, those funds can be delivered to the target.

```solidity
/// @notice reveal the pre-image to the commitment which then invokes the submarine call
function submarineReveal(bytes32 commitment, uint256 salt, address target, bytes calldata message) external {
require(pendingCommitment[commitment]);
require(validCommitmentReveal(...));

uint256 value = ethEscrow[commitment];
delete pendingCommitment[commitment];
delete ethEscrow[commitment];

(bool success, ) = target.call{ value: value }(message);
require(success);
}
```

### Cross Domain Submarine Entrypoint

With the current structure of the `L2ToL2CrossDomainMessenger`, the reveal transaction, `submarineReveal()` is a followup transaction to the cross domain message that propogates the commitment, `handleSubmarineSendETH()`. However a relayer can batch all three calls to conduct a reveal in 1 transaction. `[relayETH(), handleSubmarineSendETH(), submarineReveal()]`.

With [Entrypoints](https://github.com/ethereum-optimism/design-docs/pull/163), it is possible to provide an entrypoint contract where all three calls must happen in 1 invocation. For demonstrative purposes, we'll keep `relayETH` seperate and show `handleSubmarineSendETH()` and `submarineReveal()` can be consolidated into a single entrypoint contract.

```solidity
contract CrossDomainSubmarineEntrypoint {
function submarineReveal(Identifier _id, bytes calldata payload, uint256 salt, address target, bytes calldata message) external {
// (1) Delivers the message which invokes handleSubmarineSendETH()
bytes32 commitment = messenger.relayMessage(_id, payload);

// (2) Reveal with the provided preimage
submarineReveal(commitment, salt, target, message);
}

function handleSubmarineSendETH(...) onlyMessenger external returns (bytes32);
function submarineReveal(...) internal;
}
```

This entrypoint ensures the commitment hash never has to be stored onchain since the reveal takes place in the same transaction, thus never "pending". The funds tied to the submarine send could similarly be attached to this entrypoint.

# Risks & Uncertainties

## Various `msg.sender ` assertions

It is typical for applications that integrate the `L2ToL2CrossDomainMessenger` to include an `onlyMessenger` modifier to ensure integrity of their cross domain calls. However with submarine sends, the developer must make sure to include a similar `onlySubmarineMessenger` modifier that asserts a different `msg.sender`, the submarine contract.

Making the right assertions is already an error-prone process and we're adding some complexity for developers. However this complexity is likely worth the cost since a developer must be intentionally aware how submarine functionality works when integrating their dapp & contracts.

## Commitment Uniqueness

The salt can ensure commitments are always unique. However it is the responsibility of the dapp to ensure this. It's already unlikely in DeFi that calldata might be the exact same as they depend on token inputs and minimum expected outputs. And even if they are the same, they must be pending during the same time window.

However an attacker that realizes that a dapp is using the same salt could try to fontrun some expected (target,message) combos with submarine sends that where the target always fails. All this would achieve in the current sample code snippet is halting delivery of submarine sends with the same commitment (already unlikely).

Choose a reason for hiding this comment

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

nit: fontrun -> frontrun


- In productionizing this implementation, this is an edge case we can explicitly handle.