← All weekly notes

7 min read

Building a DEX backrunner in Go

How I built a small Ethereum mempool watcher that decoded Uniswap swaps and assembled cross-DEX backruns as Flashbots bundles.

Go · Ethereum · MEV · Flashbots · DeFi

I started Backrunner in April 2023 as a small prototype for understanding the complete path from seeing a pending Ethereum transaction to constructing and submitting a backrun.

The project watches the public mempool for selected Uniswap swaps. When a trade is relevant, it decodes the calldata, locates the corresponding Uniswap V3 pool and SushiSwap pair, constructs an arbitrage transaction and places both transactions in a Flashbots bundle.

This is a record of the design and the lessons I would carry into a production version. The repository is intentionally limited in scope and should not be treated as ready-to-run trading software.

What a backrun does

A backrun is ordered immediately after another transaction. In this prototype, the observed transaction sells a token for WETH on Uniswap. That trade may move the Uniswap price away from the price on SushiSwap.

The backrun attempts to use the temporary difference:

  1. Let the observed Uniswap transaction execute first.
  2. Buy the token from the affected Uniswap pool.
  3. Sell the token into the corresponding SushiSwap pair.
  4. Keep the remaining WETH only if the complete route is profitable.

Ordering is essential. Running the arbitrage before the observed transaction would see different pool state and is not the same strategy.

The main pipeline

The prototype has five main stages:

pending transaction        ↓filter Uniswap router calls        ↓decode the swap parameters        ↓find Uniswap and SushiSwap markets        ↓build, simulate and submit a Flashbots bundle

The Go process owns transaction discovery, decoding, RPC communication and bundle submission. A small Solidity executor holds the atomic on-chain swap sequence.

Watching the mempool

The runner subscribes to pending transaction hashes over WebSocket. A normal HTTP RPC connection is not enough for this subscription, which is why the configuration expects an Ethereum WebSocket endpoint.

The central loop is deliberately small:

func (r *Runner) MonitorMempool(ctx context.Context) error {	pendingTxs := make(chan common.Hash)	subscription, err := r.clients.Subscriber.		SubscribePendingTransactions(ctx, pendingTxs)	if err != nil {		return fmt.Errorf("failed to subscribe pending txs: %v", err)	} 	for {		select {		case txHash := <-pendingTxs:			go func() {				r.trade.ExecuteBackrun(ctx, txHash)			}()		case err := <-subscription.Err():			return fmt.Errorf("pending tx subscription: %v", err)		case <-ctx.Done():			return nil		}	}}

This keeps ingestion responsive because transaction work happens outside the subscription loop. It is useful for a prototype, but an unbounded goroutine per hash is not appropriate for mainnet traffic. A production watcher needs a bounded queue, worker limits, cancellation and metrics for dropped or delayed work.

Filtering before doing expensive work

Fetching and decoding every pending transaction is expensive. The prototype first checks whether the transaction targets Uniswap's Router02 contract:

if tx.To() == nil {	return // Contract creation} if tx.To().Hash() != uniSwapRouter02.Hash() {	return}

It then keeps only the direction needed by the strategy. The selected transaction must output WETH, meaning the user is selling another token into WETH:

trade, err := u.decodeTx(tx)if err != nil {	return} if trade != nil && trade.Params.TokenOut == wethAddress.String() {	_ = u.execute(ctx, trade, tx)}

This narrow filter made the experiment understandable. A broader implementation would need multiple router versions, direct pool calls, aggregators and more than one swap inside a multicall.

Decoding nested calldata

Ethereum calldata begins with a four-byte function selector. The prototype embeds the router ABI, identifies multicall and then inspects the first nested call.

const methodSize = 4 selector := tx.Data()[:methodSize]if hexutil.Encode(selector) != methodMulticall {	return nil, nil} multicall, err := decodeTxDataField(routerABI, selector, tx.Data())if err != nil {	return nil, err} data := multicall["data"].([][]byte)innerSelector := data[0][:methodSize]swap, err := decodeTxDataField(routerABI, innerSelector, data[0])

The supported inner calls are exactInputSingle and exactOutputSingle. Both are converted into one internal UniswapTrade value containing the token pair, fee tier, amount and price limit.

One important lesson is that decoding succeeds only when the outer and inner ABI assumptions are correct. Real router transactions may contain several calls, token approvals, wrapping or unwrapping steps. Inspecting only data[0] is a prototype shortcut rather than a general multicall decoder.

Finding the two markets

After decoding the token pair, the runner asks each factory for its market address:

uniPool, err := u.contracts.UniswapFactory.GetPool(	&bind.CallOpts{},	common.HexToAddress(trade.Params.TokenIn),	common.HexToAddress(trade.Params.TokenOut),	trade.Params.Fee,) sushiPair, err := u.contracts.SushiswapFactory.GetPair(	&bind.CallOpts{},	common.HexToAddress(trade.Params.TokenIn),	common.HexToAddress(trade.Params.TokenOut),)

The Uniswap V3 lookup includes the fee tier because a token pair can have several pools. The SushiSwap lookup returns the V2-style pair for the two tokens. If no SushiSwap pair exists, the candidate is discarded.

Finding both contracts is only market discovery. Before executing, a complete system must read or simulate the resulting state and prove that the price difference covers gas, relay or builder payments, slippage and execution risk.

Keeping the swaps atomic

The Solidity executor expresses the intended on-chain sequence. Only the owner can call it:

function execute(    address sushiPair,    address uniPool,    address tokenAddress,    bool sushiZeroForOne,    bool uniZeroForOne,    int256 buyAmount,    uint160 sqrtPriceLimitX96) public returns (uint256) {    require(msg.sender == owner, "unauthorized");     IUniPool(uniPool).swap(        address(this),        uniZeroForOne,        buyAmount,        sqrtPriceLimitX96,        ""    );     // Sell the acquired token into the SushiSwap pair.    // The complete implementation must handle the required    // token transfers and Uniswap V3 callback correctly.     return WETH.balanceOf(address(this));}

Atomic execution matters because the second trade must not be left behind if the first trade fails. The contract also includes a liquidate function that allows the owner to withdraw accumulated WETH.

The executor shows the intended route, but the prototype contract is incomplete. A working Uniswap V3 swap requires the caller to implement the swap callback and pay the pool. A SushiSwap V2-style swap also requires the input tokens to reach the pair before requesting output. Those protocol-level settlement steps must be implemented and tested before this design can execute safely.

Preserving transaction order with a bundle

The key bundle detail is the transaction order:

bundle := flashbotsrpc.FlashbotsCallBundleParam{	Txs: []string{		hexutil.Encode(rawObservedTx),		hexutil.Encode(rawBackrunTx),	},	BlockNumber:      fmt.Sprintf("0x%x", currentBlock+1),	StateBlockNumber: "latest",} result, err := u.clients.FbClient.FlashbotsCallBundle(	u.bundlerKey,	bundle,)

The observed transaction is first and the backrun is second. The bundle targets the next block and is simulated before submission. This is safer than sending a transaction based only on local calculations because simulation can expose reverts and unexpected state interactions.

The repository contains a clear TODO after simulation: calculate the actual profit before sending. That check is mandatory. A successful simulation only proves that the transactions can execute; it does not prove that the result is profitable.

What I would change for a production system

The prototype helped connect mempool data, ABI decoding, pool discovery, contract execution and private bundles in one small codebase. Turning that path into a reliable system would require several changes.

Control concurrency

Use a bounded worker pool rather than starting one goroutine for every pending hash. Deduplicate hashes, set RPC timeouts and apply backpressure before the node or process becomes overloaded.

Build without broadcasting

Construct the backrun transaction without publishing it to the public transaction pool. When using a generated Go contract binding, set NoSend or build the calldata and transaction explicitly. The only submission path should be the private bundle after simulation and the profit check.

Model post-transaction state

The opportunity exists after the observed swap changes the pool. Pricing must use that expected state rather than only the latest confirmed reserves. The calculation also needs accurate Uniswap V3 tick and liquidity behavior instead of treating the pool like a simple constant-product market.

Enforce profit on-chain

Record the executor's WETH balance before the route and require a minimum increase after it. The minimum should account for gas, builder payment, slippage and a safety margin. If the requirement is not met, revert the entire transaction.

Complete protocol settlement

Implement the Uniswap V3 callback, transfer tokens into the SushiSwap pair correctly and validate token ordering. Add fork tests for both swap directions, each supported fee tier and failure cases.

Protect keys and nonces

Keep bundle and sender keys outside local environment files in production. Coordinate nonces across concurrent opportunities, support replacement bundles and submit to more than one target block when appropriate.

Treat tokens as hostile

The repository README warns about poison and honeypot tokens. Token allowlists, transfer simulation, approval controls and balance-delta checks are essential. Arbitrary token contracts can execute surprising code or make an apparent opportunity impossible to exit.

What I want to remember

The mempool subscription is only the beginning. The difficult part of a backrunner is proving that the state transition, settlement path, ordering and final balance are all correct under real block conditions.

The strongest part of the design is the separation between discovery in Go and atomic execution in Solidity. The most important next step is to make profitability and safety explicit invariants: build privately, simulate the exact ordered bundle, require a minimum on-chain profit and revert everything when any assumption fails.

Source