← Back

Anatomy of the JaredFromSubway Counter-MEV Honeypot

June 26, 2026 · on-chain post-mortem
Targetjaredfromsubway.eth — Ethereum's most active sandwich bot; funds held in contract 0x1f2f10d1…f387
Drain2026-06-20 18:49:11 UTC · block 25,360,696 · tx 0x2be8704f…3e65
Loss1,474.5825 WETH + 2,870,573.13 USDC + 2,035,760.16 USDT (≈ $7.5M)
Classcounter-MEV honeypot — not a key compromise, not a protocol bug
Root causeERC-20 approvals granted during baited trades and never consumed or revoked
Attackerrecipient 0x3e37…65d0 · executor 0x5af3…36e1 · coordinator 0xb84db016…df52

An attacker turned the jaredfromsubway.eth sandwich bot against itself. There was no stolen key and no protocol exploit; the bot was drained through ERC-20 approvals it granted during what looked, to its own heuristics, like a run of small profitable trades. The approvals were never consumed, accumulated as standing permissions, and were collected in a single sweep. This write-up reconstructs the mechanism from chain state — receipts, storage, historical allowance() reads, traces, and bytecode — and ends with the hardening that would have blocked it. Every figure is anchored to a transaction or address.

Background

A sandwich bot front-runs a pending swap, lets the victim execute at the worse price, and back-runs into the move it created; the profit is the spread, usually in WETH. To swap, it must grant ERC-20 approvals so pools and routers can pull its tokens.

An approval is the whole story here. approve(spender, amount) writes an allowance, keyed by (owner, spender), into the token's storage. Afterwards the spender — and only the spender, since the check is on msg.sender — can call transferFrom(owner, recipient, value), which moves tokens and subtracts value from the allowance. An allowance is a durable, one-way permission that persists until it is spent down or explicitly zeroed. In a normal swap the approval is consumed by the swap that immediately follows it and the allowance returns to zero.

Root cause

The bot approves a spender for the exact amount of a swap and assumes the swap will consume that approval. The honeypot is engineered to break exactly this assumption: it accepts the approval but routes around the transferFrom that would spend it, leaving a non-zero allowance pointed at an attacker contract. The bot, seeing a settled and slightly profitable trade, neither notices nor revokes. Repeated, this accumulates standing allowances that the attacker later drains. Nothing in the chain of events requires a bug in WETH, in Uniswap, or in the bot's signing key — only an approval that outlives its swap.

Attacker infrastructure

Three contract types, all from one controlling account:

The fake tokens are deliberately convincing. Storage of one child, with the runtime read-count per slot (which matters later):

SlotValueMeaningReads
00x3e37…65d0owner / payout6
1"Wrapped Ether"ERC-20 name1
2"fWETH"ERC-20 symbol1
318decimals1
410,000,000e18fabricated supply1
70xc02aaa39…756cc2real WETH — the drain target11
100xb84db016…df52coordinator5
110xae2fc483…ae13bot operator (jaredfromsubway.eth)1

The bytecode is a fork of Fei Protocol's CoreRef (a wrong call returns CoreRef: Caller is not a minter) wired to Uniswap's TransferHelper — audited-looking code, so the bot's "is this a normal token / does the pool balance" checks pass. The fake tokens were paired against the bot's assets in Uniswap-V2-style pools.

Attack flow

  1. Bait (unarmed). The bot routes through a fake pool, "wraps" WETH into the fake token — which, like any deposit, needs an approve first — and the wrapper honestly pulls the WETH via transferFrom. The approval is consumed, the round-trip returns the WETH, and the bot books a few thousandths of an ETH of real profit. This trains its heuristics and leaves nothing standing.
  2. Arm. The attacker flips a coordinator flag for one specific block (see Timing). On that block the same wrapper skips the consuming transferFrom, leaving the bot's approval live.
  3. Drain. Once enough approvals stand, one transaction calls the coordinator, which loops the children; each child, as the approved spender, runs transferFrom up to its allowance and forwards to the attacker.

The mint/consume switch

The wrapper is one function with one branch, reconstructed from bytecode and traces:

function wrapTo(uint amount, address to) returns (uint) {
    _mint(to, amount);                        // fabricate fake-WETH (Transfer: 0x0 → to)
    coordinator.register(address(this));

    if (coordinator.getStatus() == 1) {       // ARMED  (block.number == armedBlock)
        coordinator.setBorrowAmount(amount);  //   just record it — pull NOTHING
    } else {                                  // UNARMED
        realWETH.transferFrom(msg.sender,     //   honest deposit: THIS is the call
                              address(this),  //   that actually spends the approval
                              amount);
    }
    return amount;
}

The only line that consumes the approval is the transferFrom in the unarmed branch. On an armed block it is replaced by a no-token bookkeeping call, and the fabricated _mint covers the bot's incoming side so the trade still looks balanced. One leg of the armed transaction 0xa2c9d0a1…, traced; the two decisive frames are flagged:

[1] operator EOA → bot router                          (the bot's own transaction)
 ├─[2] bot → WETH.approve(child, 92.1614)               ← bot approves, BEFORE the swap
 ├─[3] bot → child.wrapTo(92.16, pool)
 │      · Transfer  0x0 → pool   92.1614                ← fake-WETH minted from nothing
 │      ├─[4] child → coordinator.getStatus()  → 1      ← armed block?  yes
 │      └─[6] child → coordinator.setBorrowAmount(92.16) ← ARMED: no transferFrom, nothing pulled
 ├─[7] bot → pool.swap(…)                               ← ordinary Uniswap-V2 swap
 │      …
 └─[15] bot → fakeWETH.unwrap(92.16)
        · Transfer  bot → 0x0   92.16                   ← fake-WETH burned
        ├─[18] fakeWETH → WETH.transfer(bot, 0.001119)  ← ARMED: bot gets only dust back
        └─[22] fakeWETH → WETH.transferFrom(child, …, 0) ← amount = 0, a no-op
FrameUnarmed roundArmed round
getStatus()01
inside wrapTotransferFrom(bot→child, 92.16) — approval spentsetBorrowAmount(92.16) — nothing pulled
inside unwraptransfer(bot, 92.16) — full WETH backtransfer(bot, 0.001119) — dust
closing calltransferFrom(child→…, 92.16)transferFrom(child→…, 0)
net resultallowance → 0, nothing to takeallowance stays 92.16, bot +0.001119

Balance-wise, the armed round is counter-intuitive: the bot surrenders no real WETH. Its WETH balance even ticks up by the 0.001119 of dust; the 92.16 it appears to move is fabricated fake-WETH (mint then burn); and there is no third-party victim, because the counterparty is the attacker's own fake pool. The only thing the bot parts with is the standing approval — free at the time. The real, one-way loss occurs once, in the drain.

The bait approvals were left untouched, not briefly created and spent. Tracking the spender at 0x052cb08c… block by block:

At blockallowance(bot → spender)state
2536051992.1601set, left standing
2536068892.1601~33 min later, untouched
2536069592.1614topped up, one block before sweep
253606960consumed by the drain

Timing: the block-armed switch

getStatus() is not a stored boolean — it is block.number == armedBlock, true for exactly one block. The arming call records that block and tips the builder to land the bot's bait in it:

function armRound() payable {                  // selector 0xb6e808af
    armedBlock = block.number;                 //  arm the trap for THIS block only
    block.coinbase.call{value: msg.value}("");  //  tip the builder of this block
}

function getStatus() view returns (uint) {
    return block.number == armedBlock ? 1 : 0; //  true for one block, then lapses
}

The arming ran eleven times, each call arming one block and paying that block's builder a fresh tip — small at first, 0.025 ETH for the decisive rounds. To be precise about what the tip buys: priority for inclusion and ordering inside a block, not exclusivity, and not builder complicity — from the builder's side this is an ordinary fee-paying transaction. What it bought was reliable co-location of the arm and the bait, block by block.

Reproducibility. The balances, allowances, and storage below resolve with a public archive RPC and an explorer. The armedBlock flag and the block.coinbase tip live in the internal call traces; confirming them requires a node exposing debug_traceTransaction, not ordinary logs.

The drain

One transaction, 0x2be8704f…3e65, from a throwaway wallet to the coordinator, calling 0xc269a509(address victim, address[] children). Shallow and wide — one trigger fanning into 50 independent transferFrom calls (66 children, 16 inert):

EOA 0x5af3…   (no allowance of its own — just pays gas)
  └─ coordinator.c269a509(victim = bot, children[66])
       ├─ child.withdraw(bot) → WETH.transferFrom(bot → 0x3e37, 92.1614)   × 16
       ├─ child.withdraw(bot) → USDC.transferFrom(bot → 0x3e37, …)         × 20
       ├─ child.withdraw(bot) → USDT.transferFrom(bot → 0x3e37, …)         × 14
       └─ 16 children with a zero allowance → withdraw() does nothing

There is no exploit primitive in the code — only pre-authorised transfers:

// coordinator — no access control; payout is fixed in storage
function drain(address victim, address[] children) payable {   // 0xc269a509
    for (uint i; i < children.length; i++)
        IChild(children[i]).withdraw(victim);                  // 0x51cff8d9
    block.coinbase.call{value: msg.value}("");                 // this tx tips the builder too
}

// child — only the coordinator (or owner) may call it
function withdraw(address victim) {                            // 0x51cff8d9
    uint amt = realToken.allowance(victim, address(this));
    if (amt > 0)
        realToken.transferFrom(victim, owner, amt);            // owner = 0x3e37, the payout
}
AddressWETHUSDCUSDT
Bot 0x1f2f…f387−1,474.5825−2,870,573.13−2,035,760.16
Attacker 0x3e37…65d0+1,474.5825+2,870,573.13+2,035,760.16

The WETH leg is 16 × 92.16140769. This answers a common question — why 66 contracts for 16 allowances: 16 is WETH only; the sweep collected 50 live allowances across three assets (16 WETH, 20 USDC, 14 USDT), with the remaining 16 children inert.

Why a throwaway wallet could trigger it

The sender, EOA 0x5af38735…36e1, has no allowance from the bot; a direct transferFrom from it would revert. The permission belongs to the child contracts — the approved spenders — and only they can spend it. Two properties make the wallet disposable: the coordinator's drain has no access control (replaying it at the pre-drain block from a random address, the attacker, and the executor all succeed), and the payout is fixed in storage rather than taken from the caller. Routing all 66 through one call is about atomicity: sixteen separate withdrawals across separate blocks would give the bot's risk logic a window to revoke; one transaction removes it.

EIP-7702: tool, not vector

The controlling account is EIP-7702-delegated — a 2025 Ethereum change letting a key-controlled EOA temporarily run contract code, so one signature can batch calls atomically; here it points at a stock MetaMask smart-account. Its role was entirely attacker-side and mundane: batching the deploy-and-configure calls that built the trap. It was not the attack vector. The bot's funds sit in a contract and EIP-7702 applies only to EOAs, so the bot could not be a 7702 victim; the sweep's atomicity comes from the coordinator loop, not from 7702. The vulnerability was the standing approvals.

Infrastructure and timeline

The live coordinator and 66 tokens were a one-evening build (19 Jun, 15:32–22:08 UTC), but the campaign is wider: the root account was funded 19.898 ETH on 7 Jun, the factory deployed the same day, and a rehearsal coordinator was armed on 8 Jun without ever draining. Root funding to final sweep is ~13 days.

0x4e5b2e1d…  ──19.898 ETH──►  relay 0xe8b7…  ──19.898 ETH──►  0x3e37   (root account)
                                                                 │   EIP-7702 → standard MetaMask smart-account
                                                                 ├─ factory 0x81F248Ff…   (07 Jun) ─► 66 fake tokens + V2 pairs
                                                                 ├─ rehearsal coordinator 0xa708…  (armed 08 Jun, never drained)
                                                                 ├─ live coordinator 0xb84db016…   (19 Jun)
                                                                 └─ executor 0x5af3…  ◄── 1 ETH (08 Jun)
                                                                       └─ sends the 11 arm rounds + the final drain
When (UTC)FromCallsPurpose
19 Jun 15:32root 0x3e37creationdeploy coordinator
19–20 Junroot 0x3e378 config callsregister children, set parameters
20 Jun 17:55–18:47executor 0x5af311 × armRound()arm one block + tip its builder
20 Jun 18:49executor 0x5af31 × drain()the sweep

Across these 66 contracts the bot did not profit. The bait was tens of dollars of real WETH, lent and then reclaimed many times over in the sweep.

The inert operator slot

Slot 11 of each child stores the bot's operator address (0xae2fc483), uniform across children from creation — evidence of targeting, but functionally dead. It is read once, only by its own setter, and never by the drain/arm/wrap paths; the drained address is a call parameter, not this slot. The kit is reusable against any victim.

Mitigations

The pool checks were fine and the trades were genuinely profitable. The defect was treating an approval as a transient detail of a swap rather than a standing liability. For anyone running automated approvals:

Indicators

Sum the WETH Transfer legs of the drain receipt and you land on 1,474.5825 to the eighth decimal; read slot 7 of any child for the real WETH it targets, slot 11 for the operator address the code never uses. The whole event is in plain state — a permission system working exactly as specified, against an operator that treated its permissions as disposable.