❮ Writing

Building a Real-Time Ethereum MEV Attack Tracker in Go

Synced my own geth node, built a Go pipeline around the mempool, wired in a forked Python ML detector, and used Dragonfly + ClickHouse to survive reconnects.

I was looking for something technically hard to poke at outside of work. Ethereum kept showing up in threads about distributed systems, adversarial environments, real money on the line. I was not interested in trading. I wanted a problem where the data is public but the game is dirty.

That search landed me on the mempool: transactions waiting to be picked up before a block is sealed. From there I went down a few rabbit holes. Sandwich attacks made sense pretty quickly. I also spent time on VPIN, but that is a separate story for another post.

What stuck was MEV. I wanted to build something that watched the mempool and flagged sandwich-style activity as it happened. There was no free mempool feed I trusted, so I rented a VPS from SSDNodes, installed geth, and waited for the sync to finish.

When the node was finally caught up I subscribed to pending transactions over WebSocket, built the Go pipeline below, and hooked up the detector side separately. For the actual sandwich detection I did not start from zero. I found a public Python ML repo from roughly four years earlier, forked it, and tweaked it enough to run against my stream.

Most serious MEV today moves in large chunks through private builder networks, not the public mempool you and I can see from a standard node. That was already true while I was running this. I did not catch much live action. A few small confirmed sandwiches on low-liquidity pools, a lot of patterns that looked suspicious in pending and then never landed on-chain the way I expected.

Still worth doing. Running my own node and staring at the raw stream taught me more about how the chain actually works than any tutorial did. This post is about that architecture, not about outsmarting bots.

What I was actually trying to catch

MEV bots profit by reordering transactions around yours. The classic sandwich looks like this on a Uniswap-style pool:

  1. Bot buys token X (pushes price up slightly)
  2. Your swap executes at a worse price
  3. Bot sells token X (captures the spread minus gas)

You need pending transactions to spot the setup early, and confirmed blocks to verify the ordering after the fact. geth exposes both over a single WebSocket connection:

  • eth_subscribe with newPendingTransactions for the mempool
  • eth_subscribe with newHeads for new blocks

The subscription gives you transaction hashes for pending txs, not full objects. Every hash turns into a separate RPC call (eth_getTransactionByHash) before you can decode anything useful. That asymmetry shapes the whole pipeline.

Pipeline overview

The service is a straight line with one fork for parallelism:

flowchart LR
  subgraph SRC["Source"]
    direction TB
    G[(geth)]
  end

  subgraph CORE["Processing"]
    direction TB
    I[Ingestor] --> D[Decoder] --> S[Sharder]
  end

  subgraph DET["Detection"]
    direction TB
    PY[Python ML]
  end

  subgraph STORE["Storage"]
    direction TB
    DF[(Dragonfly)]
    CH[(ClickHouse)]
  end

  subgraph OUT["Output"]
    direction TB
    A[Aggregator] --> L[Alerter]
    L --> T[Telegram]
    L --> P[(Prometheus)]
  end

  G -->|WS pending + heads| I
  D --> DF
  I -->|cursor| DF
  S --> PY
  PY --> A
  A --> CH

  classDef proc fill:#0d1410,stroke:#6aab7a,stroke-width:1.5px,color:#c8efd0
  classDef cache fill:#0d1218,stroke:#5b8fd4,stroke-width:1.5px,color:#c8ddf5
  classDef alert fill:#141010,stroke:#c9924a,stroke-width:1.5px,color:#f0dcc0
  classDef node fill:#101014,stroke:#555,color:#ddd
  class I,D,S,PY,A,L proc
  class DF,CH cache
  class T,P alert
  class G node

Pipeline simplified. Channel backpressure, load shedding, and parallel hydrate paths not shown.

Ingestor owns the WebSocket lifecycle. One goroutine reads frames, pushes raw notifications into a channel, and never does anything else. Decoding and HTTP fetches happen downstream so a slow router lookup cannot stall the reader.

Decoder hydrates pending hashes, filters to swaps on routers I cared about (Uniswap V2/V3 routers, a handful of clones), and resolves the pool address from calldata. Everything else is dropped before it hits memory.

Sharder batches enriched events and forwards them to the detector process. I kept pool ordering intact on the Go side so the Python service received events in a sensible sequence.

Detector is the forked Python ML repo. I did not write the model logic. I adjusted config, input formatting, and a few thresholds so it could consume my SwapEvent stream instead of whatever dataset the original author had in mind.

Aggregator deduplicates alerts, rolls up counts by attacker address, and applies a minimum profit threshold before anything noisy goes out. Confirmed sandwiches get written to ClickHouse for later querying.

Dragonfly sits in the hot path as a cache layer. Decoded swap payloads, recent pool state, and the stream cursor all live here with short TTLs on mempool data and longer TTLs on block-level checkpoints.

ClickHouse stores confirmed sandwich hits: attacker address, victim tx, pool, token pair, confidence score, block number, timestamps. Append-only, cheap to query later when you want to know if anything actually showed up on a given day.

Each handoff uses a buffered channel (capacity 256 in production, tuned after load testing). When a queue fills up, the ingestor blocks. That is intentional. The alternative is silently buffering ten thousand events in RAM during a mempool spike and pretending you are still real time.

The event shape

After decoding, everything downstream works on a single struct:

type SwapEvent struct {
    Hash      common.Hash
    Pool      common.Address
    TokenIn   common.Address
    TokenOut  common.Address
    AmountIn  *big.Int
    Sender    common.Address
    GasPrice  *big.Int
    SeenAt    time.Time
    Block     uint64 // 0 while pending
}

Block stays zero until newHeads confirms the transaction. I kept pending and confirmed detections as separate alert types because the false positive rate on pending alone was unusable.

WebSocket reconnects

The first version used a simple read loop. It worked until geth restarted during a client upgrade and the process sat there doing nothing until I noticed the Grafana chart flatlined.

The second version wrapped the connection in a supervisor:

stateDiagram-v2
    direction LR
    [*] --> Connect
    Connect --> Live: subscribe ok
    Live --> CatchUp: head gap
    CatchUp --> Live: blocks replayed
    Live --> Retry: read error
    Live --> Retry: no head in 30s
    Retry --> Connect: backoff
    Retry --> Dead: give up
    Dead --> [*]

    classDef ok fill:#0d1410,stroke:#6aab7a,stroke-width:1.5px,color:#c8efd0
    classDef warn fill:#141010,stroke:#c9924a,stroke-width:1.5px,color:#f0dcc0
    classDef bad fill:#181014,stroke:#d96570,stroke-width:1.5px,color:#f2b6bb
    class Live,CatchUp ok
    class Connect,Retry warn
    class Dead bad

Supervisor simplified. Backoff jitter, mempool cursor, and block backfill not shown.

Three rules kept it honest:

Backoff with jitter. Reconnect delay starts at 250ms and doubles to a 30s cap. A random ±20% jitter on top so a node restart does not get hit by five identical retry timestamps.

Liveness check. An idle chain and a dead subscription look the same from the reader: silence. A side goroutine watches newHeads. If nothing arrives within ~30 seconds (~2.5 post-merge mainnet slots at 12s each), it kills the socket and forces a reconnect.

Block gap fill. On reconnect, compare lastProcessedBlock against the node’s current head. Fetch anything in between over HTTP (eth_getBlockByNumber in batches of 32) and replay transactions through the decoder. You cannot recover the mempool gap from the outage window. I logged mempool_gap_seconds as a metric so I at least knew when detections were incomplete.

Mempool cursor in Dragonfly. Block backfill handles confirmed history. Mempool state is harder. On every decoded pending tx the ingestor writes a cursor to Dragonfly: last seen tx hash, timestamp, and the block head at the time. On reconnect the supervisor reads that key first, logs how long the gap was, and resets the Python detector buffer from a clean state. You still miss whatever flew through during the outage, but you are not guessing where you were when the socket died.

The cursor key looked roughly like this:

type MempoolCursor struct {
    LastTxHash    common.Hash
    LastSeenAt    time.Time
    HeadAtLastSeen uint64
    ReconnectCount uint64
}

Dragonfly also cached hydrated swap payloads so a reconnect within the TTL did not trigger duplicate eth_getTransactionByHash calls for txs the decoder had already seen.

Supervisor loop, simplified:

func (s *Stream) run(ctx context.Context) {
    bo := backoff.New(250*time.Millisecond, 30*time.Second)
    for {
        if err := s.connectAndServe(ctx); err != nil {
            if ctx.Err() != nil {
                return
            }
            time.Sleep(bo.Next())
            s.metrics.Reconnects.Inc()
            continue
        }
        bo.Reset()
        return
    }
}

connectAndServe subscribes, starts the reader, runs until error, then returns. The outer loop handles retry. Clean separation.

Sandwich detection

The Go side ends at a clean JSON payload per swap. The Python service picks it up from a queue, runs the ML classifier, and returns a label plus a confidence score. I kept the interface dead simple because I was adapting someone else’s project, not maintaining a research codebase.

The repo I forked was old and mostly abandoned, which was fine for learning. I changed the feature extraction to match my decoded swap fields, lowered the alert threshold after too many false positives on pending txs, and added a confirmed-only mode that waited for newHeads before firing anything to Telegram.

sequenceDiagram
    autonumber
    participant M as mempool
    participant G as Go pipeline
    participant DF as Dragonfly
    participant P as Python ML
    participant A as aggregator
    participant CH as ClickHouse
    participant O as alerter

    M->>G: pending tx hash
    G->>G: hydrate + decode swap
    G->>DF: cache payload + cursor
    G->>P: SwapEvent JSON
    P->>P: classify pattern
    P->>A: label + confidence
    A->>A: deduplicate + threshold
    A->>CH: insert if confirmed
    A->>O: confirmed alert
    O->>O: telegram if confirmed

Happy path only. Reconnects, shedding, and pending vs confirmed modes not shown.

Most of the tuning was operational, not mathematical. Pending-only predictions were noisy. Confirmed-only was quiet. That gap told me more about public mempool visibility than about the model itself.

Stale state on the Python side was a problem early on. The original repo assumed batch files, not a live stream. I added a small buffer with a TTL so old candidates did not sit around forever.

Load and shedding

The decoder was always the bottleneck. During busy periods (NFT mints, large airdrop claims) pending volume would spike and the hydrate queue would stay full.

When a channel stayed above 90% capacity for more than 5 seconds, the ingestor started dropping events from pools outside my target list before they entered the decoder queue. Target pools covered the top Uniswap V2 pairs by volume. Shedding incremented a Prometheus counter (events_shed_total) so I could correlate missed detections with load spikes instead of guessing.

Metrics I actually looked at:

  • ws_reconnects_total
  • blocks_backfilled_total
  • mempool_gap_seconds (histogram)
  • detection_latency_ms (from pending sell to alert)
  • events_shed_total
  • open_sandwich_candidates (gauge)

That last one helped after I added TTL cleanup on the Python side. The forked repo had no concept of expiring live-stream state.

Numbers from when it ran

End-to-end latency from a pending sell showing up to a Telegram message was typically 200-400ms over the VPS connection. Most of that was eth_getTransactionByHash, not detection logic.

Confirmed sandwich alerts were rare. I saw plenty of suspicious pending patterns that never made it on-chain in the order I expected, and a handful of small confirmed sandwiches on low-liquidity pools where public mempool routing still made sense. Nothing that would justify the infra cost on its own. ClickHouse had the record either way. When Telegram stayed quiet I could still run a query and see what the pipeline had actually stored versus what I thought I missed.

Why I stopped

SSDNodes VPS plus a synced geth instance is a recurring bill for a side project with no product attached. Disk, bandwidth, and the occasional resync after an unclean shutdown add up. Once it was clear that the interesting MEV was mostly happening outside the public mempool anyway, keeping the node live full time was harder to justify.

The Go pipeline and my fork still live in private repos. The original ML project is public if you dig around. I might spin the node up again for something else, but not just for mempool watching.

Takeaways

If you are building something similar:

  • Treat the WebSocket as unreliable from day one. Supervise it, measure gaps, backfill blocks, and persist a mempool cursor somewhere durable enough to survive restarts.
  • Never put decode work in the reader goroutine.
  • Cache hydrated payloads (Dragonfly worked well here). Reconnects get expensive fast if you re-fetch every tx hash from scratch.
  • If you borrow a detector, spend time on the glue: input format, TTL, confirmed vs pending modes. That is where old repos usually break on live data.
  • Store confirmed hits in something queryable (ClickHouse was overkill for the volume I had, but nice when alerts were quiet and I wanted proof the pipeline ran).

The Ethereum specifics age quickly. The shape of the problem does not: lossy stream, ordering-sensitive state, and no easy way to know what you missed.