I went looking for sandwiches and found a DEX pool trading with itself
A DEX trader sees a quote and a fill and cannot tell what stood between them. I set out to measure the obvious suspect, the sandwich: a bot that buys just before you and sells straight after, in the same block.
I found almost none. What I found instead was a pool where two thirds of the volume was three wallets trading with themselves. The pool was ranked #1 on the same activity those trades were inflating.
- Live: middleman.edycu.dev (the judge page is /judge, every request behind it on /evidence)
- Repo: github.com/edycutjong/middleman. MIT, stdlib-only Python, no key.
The row that makes it possible
CoinMarketCap's /v1/dex/tokens/transactions returns a token's recent swaps, keyless, with a cursor. Each row carries the fields a sandwich detector needs: the maker's wallet (ma), the block (h), the log index inside the block (lgid), the side (tp) and both amounts (a0, a1). Here's one real row, trimmed:
{ "h": "26006339", "lgid": "209", "tp": "sell",
"ma": "0xc9160fdab187f2e55567b760d88a87ae7fe56d95",
"a0": 842991.8537715519, "a1": 1.0430246602456774,
"en": "Uniswap v2", "t0s": "MOTO", "t1s": "WETH",
"tx": "0x1345bed7cd96a35ae5543fdbfde710c1f7b41a3a365fc31b4c3c52e47512028b" }
The maker address lets you count "the same wallet on both sides" instead of guessing. The log index makes "between" exact inside a block. You get no mempool and no MEV labels, and you don't need them: a middleman has to print.
First trap: the block number is a string
Look at h and lgid again. They're quoted. The amounts on the same row are numbers, but the two fields that place a swap in the chain arrive as strings. Sort them as text and "99" comes after "1000". Every "between" in the detector is then wrong, and nothing raises an error.
So the first function in the engine does exactly one thing:
def sort_key(row):
"""(block, log index) as integers, or None when the row cannot be placed."""
h, lgid = _int(row.get("h")), _int(row.get("lgid"))
if h is None or lgid is None:
return None
return (h, lgid)
Rows that can't be placed are dropped and counted, and every receipt states the count. A regression test pins the trap by name: test_block_and_log_index_are_sorted_as_integers_not_as_the_strings_they_arrive_as.
The join
With prints in chain order and grouped per pool (venue plus the two token contracts, because the rows carry no pool address), the rule is short. For each print by wallet A, find A's next print in the same block. If it's the other side and the size matches within 5 %, A stood on both sides of the block. The prints between the two legs decide the shape:
def _legs_match(a, b, tol):
"""Two rows that could be the two legs of one middleman: same wallet, same block,
opposite sides, matched size."""
return (
a.get("ma") is not None
and a.get("ma") == b.get("ma")
and _int(a.get("h")) is not None
and _int(a.get("h"