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:
- Bot buys token X (pushes price up slightly)
- Your swap executes at a worse price
- 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_subscribewithnewPendingTransactionsfor the mempooleth_subscribewithnewHeadsfor 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_totalblocks_backfilled_totalmempool_gap_seconds(histogram)detection_latency_ms(from pending sell to alert)events_shed_totalopen_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.