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")) == _int(b.get("h"))
and side(a) in ("buy", "sell")
and side(b) in ("buy", "sell")
and side(a) != side(b)
and size_match(a, b, tol)
)
- Sandwich (A-B-A): every print between the legs is another maker trading in leg 1's direction, there are 1 to 4 of them, and A came out ahead.
- Round-trip (A-A): anything else. Usually nothing between, in one transaction: the same wallet buying back what it just sold.
- Organic: every print that isn't a leg. Victims stay organic. They're real fills, and what they paid is the question.
What the join actually found
The day-1 spike ran this on 1,200 real prints from the busiest pair and found zero same-block sandwiches. A ten-token census across Ethereum, BSC and Solana found 2 in 8,000 prints: the same wallet, front-running a seller on UNI and on LINK, for $0.95 and $2.19.
That's a sandwich rate of 0.025 %. It's not a headline.
The round-trips were a headline. On MOTO/WETH, CoinMarketCap's #1 Uniswap v2 pair on Ethereum by 24-hour transactions at capture time, the last 800 prints (3.6 hours) held 29 round-trips by 3 wallets, every one inside a single transaction. They made up 66.2 % of the pool's volume: $127,254 of $192,275. The first one, straight from the receipt:
leg 1 lgid 209 0xc9160fdab187f2e55567b760d88a87ae7fe56d95 sell a1/a0 = 1.0430246602456774 / 842991.8537715519 = 1.237289133434893e-06
leg 2 lgid 218 0xc9160fdab187f2e55567b760d88a87ae7fe56d95 buy a1/a0 = 1.0325944136432206 / 829640.6520318444 = 1.2446285161103536e-06
same wallet · same block 26006339 · same tx · |Δa0| / a0 = 0.0158 ≤ 0.05 → round-trip
So I changed the headline before building the page, not in a caveat after. The engine didn't change: same feed, same ordering, same maker join. The sandwich became one named case of a middleman, and the page leads with the number the join actually found.
Why it matters to the person placing the order
Round-trip legs aren't trades anyone else can fill against, yet they sit on the tape. If you measure what a fill pays against the print before it across the raw tape, MOTO/WETH says 55.3 bps at the median. Take out the legs and measure only organic prints against the organic print before them, and the median is 15.5 bps (p90 60.9). The legs roughly triple the apparent cost of trading there.
The measurement is deliberately plain. It's the adverse-signed move of each print against the previous one:
if tp == "buy":
bps = (q / prev - 1.0) * BPS
elif tp == "sell":
bps = (1.0 - q / prev) * BPS
The price is always a1 / a0. The feed's own q field is rounded to two significant figures, and on Uniswap v4 rows it's sometimes zero. From the organic p90 the tool picks a route and a slippage cap. For MOTO: Uniswap v2 / WETH, cap 0.65 %.
The number moved, and that's the point
I ran the same bare command 24 minutes later and it measured 27.5 %: one wallet, eight round-trips. The next morning the window was clean, zero round-trips, and the tool printed zero on its own headline.
Two days after that, the hero rule (it's a rule, not a hard-coded token) picked a different pair, wildebeest/WETH. This time the join found the other shape: 17 sandwiches, 18 victim prints, 4 wallets in a 4.5-hour window. The first wallet listed was the one the census had caught on UNI and LINK.
So the census number stands as a census number: sandwiches are rare on average, not rare everywhere. All four transcripts are in DEMO.md with their receipts, and python3 scripts/verify_tape.py re-derives every published figure from the committed tapes offline.
I also got the classification wrong once. The first cut of the join called four prints on a BSC pair sandwiches. They were two wallets washing around each other, each buying back for exactly what it received. A sandwich now requires the attacker to come out ahead, and a same-wallet return that extracted nothing is a round-trip. That's the one thing it's safe to call.
Honest limitations
- A run measures the last 800 prints, not 24 hours. That was 3.6 h on MOTO and 20 h on SHIB. The span is on every row.
- Uniswap v3 fee tiers of one pair merge. The feed carries no pool address, so the number is per pair, not per tier.
- Quote-to-fill includes fees and is realised. On a busy 0.30 % pool, consecutive opposite-side prints straddle the fee twice, so the p90 sits near 60 bps. Use it comparatively.
- A round-trip is a shape, not a verdict. The tool prints the rows and the definition and never labels a person.
-
The anonymous tier throttles per IP, with no
Retry-After. The CLI backs off and says so.
Run it
git clone https://github.com/edycutjong/middleman.git && cd middleman
python3 scripts/middleman.py
No pip install, no .env, no key. It asks CoinMarketCap which Uniswap v2 pair is busiest right now, pulls 800 prints and prints the table, the route and the raw rows of the first middleman it finds. It takes about ten seconds and uses 0 credits.
The live page is at middleman.edycu.dev, the code is at github.com/edycutjong/middleman, and there's a 3-minute demo at youtu.be/BwikjIf19hQ. If you've ever wondered what your fill actually paid for, the rows are one keyless call away.
Top comments (0)