Skip to main content

MEV-Share

Swaps​

I want to swap on an decentralized exchange. However, I've heard that I can be exposed to MEV if I send the signed transaction directly to the Ethereum L1 mempool. A good friend recently told me about the new MEVShare.sol contract on SUAVE that worked well for her. I decide I want to send my transaction to that contract instead.

What do I need to do, and what happens next? We'll describe the steps first in natural language. If you want to jump straight to code, please do so.

  1. I take my signed ETH L1 Uniswap tx, and encrypt it using the public key of a specific SUAVE node.
    1. I decide whether I want all my transaction information in there, or just - for instance - the address of the pool contract I am trading on, but not the direction of the trade.
      1. We call this Ethereum L1 transaction a "hint", because it need not reveal everything an ordinary L1 transaction would.
    2. I wrap my L1 transaction in another transaction to the MEVShare.sol on SUAVE.
  2. I send the transaction in (1.ii) to the SUAVE node I encrypted it for.
  3. The SUAVE node sees this transaction, sees that it contains additional data (the encrypted L1 transaction), and passes it to the MEVM running in that node to process confidentially.

1. The trade​

  1. The MEVM enters into the function being called in the MEVShare.sol contract on SUAVE.
    1. This does not permute state: we use view functions to fetch confidential data, and then have the MEVM compute over that data off-chain.
    2. Once the MEVM has the data, it will use any other combination of precompiles to extract relevant parts of it and simulate the results of any computation done ofer that data.
    3. These "hint" and "simulations" are stored in the Confidential Data Store of that particular SUAVE node, under a specific "keyspace". The Confidential Data Store, and this notion of "keyspace", enables us to have public mechanisms specified in verifiable contracts which nevertheless collect and compute over private data. For instane: 2. When MEVShare.sol tells the MEVM to combine transactions from users looking for protection with transactions from searchers doing backruns, the MEVM can do so without storing any of this data in MEVShare.sol's onchain storage. 3. When EthBlockBid.sol tells the MEVM to access matched bids, it can also do, searching in its specific Confidential Data Store, in the same "mevshare" keyspace.
    4. It's subtle but important: the view function being called here will return a callback to another function which is intended to permute the state. If it returned the result - rather than this callback - it would expose all the confidential data when it is sent in the next step.
  2. The callback to a function which does permute states is placed alongside the original transaction sent in (2) above.
    1. We call this triple combination - the encrypted L1 transaction, the transaction I sent which wraps it, and the result from the MEVM - a "SUAVE transaction".
  3. The SUAVE transaction is propagated to the public mempool via an internal p2p method in the MEVM.
  4. Whoever is a proposer picks it up and includes it in the next block.

Now, say some searcher is monitoring the chain, looking for hints about domains they're interested in. What do they do with all the above?

  1. They see a new block, with a transaction to MEVShare.sol.
  2. They check its logs, see a Hint event, and can extract the information they need to guess profitable backruns on a given domain.
  3. They craft their backrun on ETH L1, and wrap that backrun transaction into another transaction to the same SUAVE node.
  4. What happens above is repeated, except that the searcher would call a different function in MEVShare.sol.
    1. This function operates similarly to what was described above, except that it expects an additional parameter, which is the id of my original transaction that the searcher wants to backrun.
    2. It uses the same precompiles, giving it acess to the searcher's backrun transaction, which it simulates and stores as another "hint".
    3. Using the additional input param, it then merges my original transaction with the searcher's backrun transaction and stores those in the "mevshare keyspace".
  5. As above, the MEVM returns a callback to another function which is intended to permute the state, all of which is propagated as a SUAVE transaction to the public mempool, so that we also don't reveal the searcher's successful backrun.

We still need to find out how the block which contains these matched transactions will be built and propagated, but let's put that to the side for one moment to look at the builder solidity code necessary to perform the above steps.

The code​

FetchAndEmit​

We'll begin with the base FetchAndEmit contract which is called by both the MEVShare.sol and EthBlockBid.sol contracts when they need to access confidential inputs sent along with the creationTx as a part of the eth_sendRawTransaction received by a SUAVE node. It also broadcasts specific information from these confidentialComputeRequests required for searchers to complete backruns etc.

The use of the term "bid" here is an historical artifact. Transactions in SUAVE are general, though they were initially designed specifically for orderflow and block space auctions, hence the word "bid" is used often in the suave-geth codebase.

pragma solidity ^0.8.8;

import "../libraries/Suave.sol";

contract FetchAndEmit {

event BidEvent(
Suave.BidId bidId,
uint64 decryptionCondition,
address[] allowedPeekers
);

function fetchConfidentialData() public view returns (bytes memory) {
require(Suave.isConfidential());

bytes memory confidentialInputs = Suave.confidentialInputs();
return abi.decode(confidentialInputs, (bytes));
}

// Bids to this contract should not be trusted!
function emitBid(Suave.Bid calldata bid) public {
emit BidEvent(bid.id, bid.decryptionCondition, bid.allowedPeekers);
}
}

MEVShare​

Next, let's take a look at the MEVShare.sol contract:

contract MevShare is FetchAndEmit {

event HintEvent(
Suave.BidId bidId,
bytes hint
);

event MatchEvent(
Suave.BidId matchBidId,
bytes bidhint,
bytes matchHint
);

function newBid(
uint64 decryptionCondition,
address[] memory bidAllowedPeekers
) external payable returns (bytes memory) {
// 0. check confidential execution
require(Suave.isConfidential());

// 1. fetch bundle data
bytes memory bundleData = this.fetchBidConfidentialBundleData();

// 2. sim bundle
(bool simOk, uint64 egp) = Suave.simulateBundle(bundleData);
if (!simOk) {
revert Suave.PeekerReverted(address(this), "bundle does not simulate correctly");
}

// 3. extract hint
bytes memory hint = Suave.extractHint(bundleData);

// // 4. store bundle and sim results
Suave.Bid memory bid = Suave.newBid(
decryptionCondition,
bidAllowedPeekers,
"mevshare:v0:unmatchedBundles"
);
Suave.confidentialStoreStore(
bid.id,
"mevshare:v0:ethBundles",
bundleData
);
Suave.confidentialStoreStore(
bid.id,
"mevshare:v0:ethBundleSimResults",
abi.encode(egp)
);
emit BidEvent(bid.id, bid.decryptionCondition, bid.allowedPeekers);
emit HintEvent(bid.id, hint);

// // 5. return "callback" to emit hint onchain
return bytes.concat(
this.emitBidAndHint.selector,
abi.encode(bid, hint)
);
}

function emitBidAndHint(Suave.Bid calldata bid, bytes memory hint) public {
emit BidEvent(bid.id, bid.decryptionCondition, bid.allowedPeekers);
emit HintEvent(bid.id, hint);
}

function newMatch(
uint64 decryptionCondition,
address[] memory bidAllowedPeekers,
Suave.BidId shareBidId
) external payable returns (bytes memory) {
// WARNING : this function will copy the original mev share bid
// into a new key with potentially different permsissions

require(Suave.isConfidential());
// 1. fetch confidential data
bytes memory matchBundleData = this.fetchBidConfidentialBundleData();

// 2. sim match alone for validity
(bool simOk, uint64 egp) = Suave.simulateBundle(matchBundleData);
require(simOk, "bundle does not simulate correctly");

// 3. extract hint
bytes memory matchHint = Suave.extractHint(matchBundleData);

Suave.Bid memory bid = Suave.newBid(
decryptionCondition,
bidAllowedPeekers,
"mevshare:v0:matchBids"
);
Suave.confidentialStoreStore(
bid.id,
"mevshare:v0:ethBundles",
matchBundleData
);
Suave.confidentialStoreStore(
bid.id,
"mevshare:v0:ethBundleSimResults",
abi.encode(0)
);

//4. merge bids
Suave.BidId[] memory bids = new Suave.BidId[](2);
bids[0] = shareBidId;
bids[1] = bid.id;
Suave.confidentialStoreStore(
bid.id,
"mevshare:v0:mergedBids",
abi.encode(bids)
);

return bytes.concat(this.emitBid.selector, abi.encode(bid));
}

function emitMatchBidAndHint(
Suave.Bid calldata bid,
bytes memory bidHint,
bytes memory matchHint
) public {
emit BidEvent(bid.id, bid.decryptionCondition, bid.allowedPeekers);
emit MatchEvent(bid.id, bidHint, matchHint);
}
}

What's different​

The above example is almost the same as what currently exists in MEV-share except that, instead of calling the MEV-share API and getting a response immediately, searchers now need to listen to blocks produced on SUAVE.

This does imply a slightly longer delay, as searchers have to wait on consensus about the next block. Blocks are finalised more quickly (~2s) than on Ethereum L1, but this is a notable difference. We feel that the innovations in mechanisms that an open, contestable network like SUAVE will encourage are worth the trade-off.

3. The build​

Now that we have seen how builder solidity can be used to substitute for hints and backruns, it's time to look at what happens when we actually want to build blocks and submit them to our chosen target domain (in this case, we'll only consider Ethereum L1).

It is in scenarios (and contracts) like the below that we expect to see an open marketplace for mechanisms forming, based largely around the efficiency different algorithms and implementations offer, in combination with the way they allocate any value they are able to extract. That is, we expect that the contracts the majority of people will use will be those which offer the greatest efficiency/performance while simultaneously offering the greatest rewards back to their users.

Let's pick up where we were. I've submitted my MEVShare.sol transaction as a confidentialComputeRequest, specifying the SUAVE node I want to send it to. The searcher has seen the Hint event emitted on chain and submitted their backrun transaction as another confidentialComputeRequest to the same node. That node, using the logic in MEVShare.sol, has combined these transactions (or "bids"), and stored them in the confidentialDataStore under the mevshare:v0:mergedBids keyspace.

What happens next?

  1. Someone (most likely the searcher themselves, who wants to see the combined bundle included in the next block on their target domain) crafts one more confidentialComputeRequest, which calls buildMevShare on EthBlockBid.sol, which does the same as in (1) above.
  2. The MEVM enters into the buildMevShare function. It then:
    1. Uses the confidentialStoreRetrieve precompile to fetch all the bundles in confidentialDataStore at its key.
    2. Simulates all those transactions, sorts them by most profitable, takes the top N bundles, and constructs a block.
    3. Calls one more precompile - submitEthBlockBidToRelay - to send it to some relay offchain.
      1. This will eventually be done onchain, hence repeating process one more time.

Builder code​

EthBlockBid​

Here is the reference code for EthBlockBid.sol and EthBlockBidSender.sol.

EGP stands for Effective Gas Price.

You'll notice lots of TODOs left in this contract. We'd love you to write better versions of our example implementations!

contract EthBlockBid is FetchAndEmit {

event BuilderBoostBidEvent(
Suave.BidId bidId,
bytes builderBid
);

function buildMevShare(
Suave.BuildBlockArgs memory blockArgs,
uint64 blockHeight
) public returns (bytes memory) {
require(Suave.isConfidential());

Suave.Bid[] memory allShareMatchBids = Suave.fetchBids(
blockHeight,
"mevshare:v0:matchBids"
);
Suave.Bid[] memory allShareUserBids = Suave.fetchBids(
blockHeight,
"mevshare:v0:unmatchedBundles"
);

if (allShareUserBids.length == 0) {
revert Suave.PeekerReverted(address(this), "no bids");
}

Suave.Bid[] memory allBids = new Suave.Bid[](allShareUserBids.length);
for (uint i = 0; i < allShareUserBids.length; i++) {
// TODO: sort matches by egp first!
Suave.Bid memory bidToInsert = allShareUserBids[i]; // will be updated with the best match if any
for (uint j = 0; j < allShareMatchBids.length; j++) {
// TODO: should be done once at the start and sorted
Suave.BidId[] memory mergedBidIds = abi.decode(
Suave.confidentialStoreRetrieve(
allShareMatchBids[j].id,
"mevshare:v0:mergedBids"
),
(Suave.BidId[])
);
if (idsEqual(mergedBidIds[0], allShareUserBids[i].id)) {
bidToInsert = allShareMatchBids[j];
break;
}
}
allBids[i] = bidToInsert;
}

EgpBidPair[] memory bidsByEGP = new EgpBidPair[](allBids.length);
for (uint i = 0; i < allBids.length; i++) {
bytes memory simResults = Suave.confidentialStoreRetrieve(
allBids[i].id,
"mevshare:v0:ethBundleSimResults"
);
uint64 egp = abi.decode(simResults, (uint64));
bidsByEGP[i] = EgpBidPair(egp, allBids[i].id);
}

// Bubble sort
uint n = bidsByEGP.length;
for (uint i = 0; i < n - 1; i++) {
for (uint j = i + 1; j < n; j++) {
if (bidsByEGP[i].egp < bidsByEGP[j].egp) {
EgpBidPair memory temp = bidsByEGP[i];
bidsByEGP[i] = bidsByEGP[j];
bidsByEGP[j] = temp;
}
}
}

Suave.BidId[] memory allBidIds = new Suave.BidId[](allBids.length);
for (uint i = 0; i < bidsByEGP.length; i++) {
allBidIds[i] = bidsByEGP[i].bidId;
}

return buildAndEmit(blockArgs, blockHeight, allBidIds, "mevshare:v0");
}

function buildAndEmit(
Suave.BuildBlockArgs memory blockArgs,
uint64 blockHeight,
Suave.BidId[] memory bids,
string memory namespace
) public virtual returns (bytes memory) {
require(Suave.isConfidential());

(Suave.Bid memory blockBid, bytes memory builderBid) = this.doBuild(blockArgs, blockHeight, bids, namespace);

emit BuilderBoostBidEvent(blockBid.id, builderBid);
emit BidEvent(blockBid.id, blockBid.decryptionCondition, blockBid.allowedPeekers);
return bytes.concat(
this.emitBuilderBidAndBid.selector,
abi.encode(blockBid, builderBid)
);
}

function doBuild(
Suave.BuildBlockArgs memory blockArgs,
uint64 blockHeight,
Suave.BidId[] memory bids,
string memory namespace
) public view returns (Suave.Bid memory, bytes memory) {
address[] memory allowedPeekers = new address[](2);
allowedPeekers[0] = address(this);
allowedPeekers[1] = Suave.BUILD_ETH_BLOCK_PEEKER;

Suave.Bid memory blockBid = Suave.newBid(blockHeight, allowedPeekers, "default:v0:mergedBids");
Suave.confidentialStoreStore(
blockBid.id,
"default:v0:mergedBids",
abi.encode(bids)
);

(bytes memory builderBid, bytes memory payload) = Suave.buildEthBlock(blockArgs, blockBid.id, namespace);
Suave.confidentialStoreStore(blockBid.id, "default:v0:builderPayload", payload); // only through this.unlock

return (blockBid, builderBid);
}

function emitBuilderBidAndBid(
Suave.Bid memory bid,
bytes memory builderBid
) public returns (Suave.Bid memory, bytes memory) {
emit BuilderBoostBidEvent(bid.id, builderBid);
emit BidEvent(bid.id, bid.decryptionCondition, bid.allowedPeekers);
return (bid, builderBid);
}

function unlock(
Suave.BidId bidId,
bytes memory signedBlindedHeader
) public view returns (bytes memory) {
require(Suave.isConfidential());

// TODO: verify the header is correct
// TODO: incorporate protocol name
bytes memory payload = Suave.confidentialStoreRetrieve(bidId, "default:v0:builderPayload");
return payload;
}
}

EthBlockBidSender​

contract EthBlockBidSender is EthBlockBid {
string boostRelayUrl;

constructor(string memory boostRelayUrl_) {
boostRelayUrl = boostRelayUrl_;
}

function buildAndEmit(
Suave.BuildBlockArgs memory blockArgs,
uint64 blockHeight,
Suave.BidId[] memory bids,
string memory namespace
) public virtual override returns (bytes memory) {
require(Suave.isConfidential());

(Suave.Bid memory blockBid, bytes memory builderBid) = this.doBuild(blockArgs, blockHeight, bids, namespace);
(bool ok, bytes memory err) = Suave.submitEthBlockBidToRelay(boostRelayUrl, builderBid);
if (!ok) {
revert Suave.PeekerReverted(address(this), err);
}

emit BidEvent(blockBid.id, blockBid.decryptionCondition, blockBid.allowedPeekers);
return bytes.concat(this.emitBid.selector, abi.encode(blockBid));
}
}

If you'd like to poke around the example implementation further, you can find it in the suave/sol/standard_peekers/bid.sol file of the suave-geth repo.