🤖 AI 资讯

· ·
← 返回列表

I went looking for sandwiches and found a DEX pool trading with itself

DEV.to2026-09-23 04:37:43AI应用,开源,代码生成,强化学习,招聘HR原文 ↗

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.

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"