Anatomy of the JaredFromSubway Counter-MEV Honeypot
0x1f2f10d1…f3870x2be8704f…3e650x3e37…65d0 · executor 0x5af3…36e1 · coordinator 0xb84db016…df52An 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:
- 66 fake tokens impersonating WETH, each a complete ERC-20 + wrapper, not a stub.
- A coordinator (
0xb84db016…df52) holding the shared state the tokens consult during a trade, plus the arm and sweep entrypoints. - A factory (
0x81F248Ff…0091) that mass-produced the tokens from one template — hence identical storage across the set.
The fake tokens are deliberately convincing. Storage of one child, with the runtime read-count per slot (which matters later):
| Slot | Value | Meaning | Reads |
|---|---|---|---|
| 0 | 0x3e37…65d0 | owner / payout | 6 |
| 1 | "Wrapped Ether" | ERC-20 name | 1 |
| 2 | "fWETH" | ERC-20 symbol | 1 |
| 3 | 18 | decimals | 1 |
| 4 | 10,000,000e18 | fabricated supply | 1 |
| 7 | 0xc02aaa39…756cc2 | real WETH — the drain target | 11 |
| 10 | 0xb84db016…df52 | coordinator | 5 |
| 11 | 0xae2fc483…ae13 | bot 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
- Bait (unarmed). The bot routes through a fake pool, "wraps" WETH into the fake token — which, like any deposit, needs an
approvefirst — and the wrapper honestly pulls the WETH viatransferFrom. 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. - 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. - Drain. Once enough approvals stand, one transaction calls the coordinator, which loops the children; each child, as the approved spender, runs
transferFromup 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
| Frame | Unarmed round | Armed round |
|---|---|---|
getStatus() | 0 | 1 |
inside wrapTo | transferFrom(bot→child, 92.16) — approval spent | setBorrowAmount(92.16) — nothing pulled |
inside unwrap | transfer(bot, 92.16) — full WETH back | transfer(bot, 0.001119) — dust |
| closing call | transferFrom(child→…, 92.16) | transferFrom(child→…, 0) |
| net result | allowance → 0, nothing to take | allowance 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 block | allowance(bot → spender) | state |
|---|---|---|
| 25360519 | 92.1601 | set, left standing |
| 25360688 | 92.1601 | ~33 min later, untouched |
| 25360695 | 92.1614 | topped up, one block before sweep |
| 25360696 | 0 | consumed 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.
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
}
| Address | WETH | USDC | USDT |
|---|---|---|---|
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) | From | Calls | Purpose |
|---|---|---|---|
| 19 Jun 15:32 | root 0x3e37 | creation | deploy coordinator |
| 19–20 Jun | root 0x3e37 | 8 config calls | register children, set parameters |
| 20 Jun 17:55–18:47 | executor 0x5af3 | 11 × armRound() | arm one block + tip its builder |
| 20 Jun 18:49 | executor 0x5af3 | 1 × 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:
- Zero after use. Reconcile every approval against the amount actually spent in the same transaction; if an allowance survives a settled trade, that is the anomaly, not the profit.
- Prefer single-use authorisation. Use EIP-2612
permitor approve-exact-then-zero patterns over standing allowances; never default touint256.maxfor newly encountered spenders. - Vet the spender, not just the price. A balanced pool and a positive simulation say nothing about whether the spender you approved is a contract that will quietly retain the allowance.
- Account for unconsumed approvals as exposure. Outstanding allowances to unknown addresses are open positions against your balance; monitor and cap them.
Indicators
- Drain / sweep:
0x2be8704f5a59b69e0b71f64aefdb99eb0e8ae9fb3926147c581910d71bcf3e65 - Decisive arming tx:
0xa2c9d0a13cc985e3fe445ad8d8bc2d156eec2580b8b6700ab057f5f1f881de3f - Coordinator:
0xb84db016324e8f2bfdd8dd9c260338aee0a8df52· drain selector0xc269a509· arm selector0xb6e808af - Factory:
0x81F248Ff583d3f8592ea0354a7b8DBe66de40091· representative child:0x052cb08c527c46a65647982d668d8084c980a784 - Victim bot:
0x1f2f10d1c40777ae1da742455c65828ff36df387· operator:0xae2fc483527b8ef99eb5d9b44875f005ba1fae13 - Recipient:
0x3e37f4a10d771ba9de44b6d301410b1bedea65d0· executor:0x5af38735b215b00aa7c9f93fed7ee415cecb36e1
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.