Blind spots of a blind arb bot

The faint sound of a money printer - Part 5 of 5

For anyone starting at the end, this series is about reverse-engineering an unverified arbitrage bot on Arbitrum, one of eight contracts a single operator shipped over roughly two months, working from its bytecode and its on-chain behaviour. If you’re interested in the process behind discovering how it works under the hood (and want to avoid spoilers!) feel free to start the series from the beginning.

The short version is the bot’s trades are loops, borrowing a token, pushing it through two or three trading pools of different designs, then repaying, all inside one transaction that either completes profitably or is erased as though it never happened. The earlier parts work through how the bot behaves, how its instructions are hidden and how it decides what to trade, but this one covers what happens when it finally commits.

The previous part ended with the golden-section search finding a single number, the optimal amount to trade, and up to that point the contract has touched nothing. No tokens moved, no state changed. The moment this number is found, it gets handed to Uniswap V4’s flash-accounting machinery, the whole loop fires atomically and the surplus gets swept out. This part is about that handoff, from a number in memory to real money in the operator’s wallet.

A single flash window for three different dialects

The contract does not hold the tokens it trades with. That’s the whole point of a blind-arb bot with no capital at risk between opportunities. So the first thing execution does is borrow the entire trade, and Uniswap V4 gives it a way to do that without a traditional flash loan at all.

Older flash loans work by lending you tokens and trusting you to give them back before the transaction ends, but V4 does something stranger and cheaper. It opens a window, lets you move value around inside it however you like, and keeps a running tab of what you owe and what you are owed, one signed number per token. Nothing has to be repaid in the usual sense. The only rule is that every number on that tab must be zero when the window closes, and if any of them is not, the whole transaction is erased. It’s basically running a bar tab that has to settle before last call.

The first move of the bot is getting into that window. It packs the winning route into a fresh payload and calls unlock() on the PoolManager, which immediately calls back into the contract through unlockCallback, the 0x91dd7346 selector we found sitting unexplained in the dispatch table back in Part 2. Everything that follows happens inside that one callback frame.

Inside it, the contract walks the hops in order, and we also find the three dialects from Part 3, only the code in the handlers is slightly different. A V4 hop is traded natively against the PoolManager, and no tokens move at all: the swap just adjusts two numbers on the tab. A V2 or V3 hop is a different matter, because those pools don’t know anything about the tab. They are separate contracts on the far side of the fence, and trading with them means real transfer calls moving real balances.

It’s important to keep in mind the tension here. The PoolManager’s ledger only knows about pools the PoolManager owns. Everything the bot does with a V2 or a V3 pool happens outside that ledger entirely, and yet it happens inside the settlement window that the ledger governs. So the contract is keeping two sets of books at once: V4’s automatic delta tracking for the native hops, and its own manual accounting for the foreign ones, with the manual half executed inside the accounting period of the automatic half. It is doing bookkeeping in someone else’s ledger, in a currency that ledger cannot see, and it has to come out even anyway.

Here is the whole thing as a call tree. This is a three-hop route that touches one pool of each kind.

bot                                              the probe transaction lands
│
├─ PoolManager.unlock(payload) ──────────────────────── the ledger opens ────
│  │
│  └─ bot.unlockCallback(payload)          our own payload, handed straight back
│     │
│     ├─ hop 1   V3 pool.swap(...)                              ○ moves tokens
│     │  │
│     │  └─ bot.uniswapV3SwapCallback(...)      no selector for this. The bot
|     |     |                                   recognizes it purely by the
│     │     │                                   shape of its arguments
│     │     │
│     │     ├─ hop 2   PoolManager.swap(...)                    ● ledger only
│     │     │  ├─ PoolManager.sync(tokenIn)
│     │     │  ├─ PoolManager.take(tokenOut, bot, amount)
│     │     │  │
│     │     │  ├─ hop 3   V2 pool.getReserves()                 ○ moves tokens
│     │     │  ├─ hop 3   V2 pool.swap(...)
│     │     │  │
│     │     │  ├─ PoolManager.sync(tokenIn)
│     │     │  └─ PoolManager.settle()             ← hop 2 finally pays its debts
│     │     │
│     │     └─ tokenIn.transfer(V3 pool, owed)     ← and now hop 1 pays its own
│     │
│     └─ PoolManager.take(token, bot, surplus)     ← the profit, collected as a
│                                                    balancing entry, not a step
│
└─ every delta reads zero, so the window is allowed to close

   ●  a V4 hop. Nothing moves. Two numbers on the tab change.
   ○  a V2 or V3 hop. Real ERC-20 transfers, invisible to the tab.

This is what the sandwich looks like. unlock() and the closing take bracket everything, and
in between, each pool’s demand for payment is answered by performing the next
hop rather than by paying. So the debts nest inward and are discharged
innermost-first, which is why hop 1 is the last to settle. Note also that the
filled markers never move a token: three hops of trading, and only two of them
touch a balance anywhere.

The settling itself uses three PoolManager functions. For each V4 hop the contract calls sync to snapshot the pool’s balance, then take to pull what it is owed, and only later settle to pay what it owes. In between those last two, it recurses into the remaining hops, which is what produces the ordering the diagram shows.

And when the last hop returns and the callback ends, the tab has to be zero. The bot’s profit is the one thing that would otherwise leave it unbalanced: a surplus of the token it started with, sitting on the credit side of the ledger. It zeroes that surplus the only way the ledger permits, by take-ing it out to its own balance. This is the thing Part 1 promised and could not yet explain. Balancing the books and collecting the profit are not two steps. They are the same step.

Two sequels to the selector trick

Part 2 built one big idea: this contract only owns two real selectors, and everything else that looks like a selector is actually data. That idea has a sequel here. Let’s look at it from two different points of view.

A V3 callback that arrives with no selector

A Uniswap V3 pool does not simply hand you tokens when you swap. Halfway through, before it has given you anything, it calls back into whoever asked for the swap and demands payment, through a function called uniswapV3SwapCallback(int256,int256,bytes). If you do not implement it, you cannot trade with V3 at all. So this contract has to answer that call.

Except we established in Part 2 that its dispatcher recognises exactly two selectors, the owner sweep and V4’s unlockCallback, and this is neither. A V3 pool calling in would fall straight through both checks into the fallback, where the first byte of calldata gets read as a hop count and the whole thing is interpreted as an arbitrage route. That should be like throwing a wrench into a working engine but it’s not, and the reason is the nicest piece of engineering in this part.

The contract identifies the callback by its shape. An ABI-encoded call to uniswapV3SwapCallback(int256,int256,bytes) always lays its arguments out the same way, and two words in that layout are predictable enough to fingerprint it. Here is the layout, with the two the contract cares about marked:

uniswapV3SwapCallback(int256,int256,bytes), as the ABI always encodes it

  byte    0   selector              <- never read by this contract
  byte    4   amount0Delta        int256
  byte   36   amount1Delta        int256
  byte   68   0x0000…0060  =  96    <- where the bytes argument starts
  byte  100   0x0000…0080  =  128   <- how long the bytes argument is
  byte  132   the bytes themselves

The two do different jobs. The word at byte 68 is the pointer telling a decoder where the trailing bytes argument begins, and for this signature it is always 0x60, forced by the argument types alone. Anyone calling this function produces it. The word at byte 100 is the length of that bytes argument, and the bot knows what it should be because the bot is the one who put it there on the way out: 0x80, 128 bytes.

So the dispatcher reads two words and never touches the selector:

01FD  PUSH1  0x84
01FF  CALLDATASIZE
0200  GT                   ; is the calldata longer than 132 bytes?
0201  PUSH2  0x0236
0204  JUMPI                ;   yes -> go and look at its shape

0235  PUSH1  0x64
0237  CALLDATALOAD         ; the word at byte 100
0238  PUSH1  0x80
023A  EQ                   ;   is it 128?
023B  PUSH1  0x44
023D  CALLDATALOAD         ; the word at byte 68
023E  PUSH1  0x60
0240  EQ                   ;   is it 96?

0219  PUSH2  0x0732        ; both matched -> service the swap callback
0224  PUSH2  0x067E        ; otherwise    -> read the bytes as an arb route

Read together, the two tests ask two different questions. The first says this is shaped like a V3 swap callback. The second says and it is carrying the payload I sent. One is a check on the counterparty’s ABI, the other is very nearly a signature on the bot’s own outgoing message, and it costs one extra CALLDATALOAD to have both.

The ABI’s job is to let contracts name functions unambiguously, and the four-byte selector is the whole mechanism for doing it. This contract ignores that mechanism in both directions. In Part 2 we watched it write its own instructions into the selector bytes, refusing to be named. Here we watch it recognise a counterparty by the silhouette of the arguments, refusing to read a name. The fallback-as-interpreter from Part 2 turns out to be a fallback-as-switchboard too, sorting callers by shape.

It also costs almost nothing. A selector comparison is a load and an EQ. This is two loads and two EQs, and it saves the contract from carrying a dispatch entry it would otherwise need. Cheaper than doing it properly, and it works because the operator controls both ends of every conversation this contract has.

The contract that mails a letter to itself

The second reveal is what the contract does to get into the flash window in the first place.

It cannot simply start swapping. The V4 ledger only exists inside a callback from the PoolManager, and the only way to be called by the PoolManager is to call unlock() first and wait to be called back. So the contract takes the winning route it has just spent a golden-section search computing, re-encodes it into a fresh payload, hands that payload to unlock(), and receives its own instructions back one frame later through unlockCallback. It mails itself a letter and waits for the postman to walk it back up the path.

What makes this more than a curiosity is that the letter is not written in the same language as the original. The interpreter has a second, inner grammar.

The calldata grammar from Part 2 is built for the wire: 39 bytes per hop, every field packed tight because every byte is billed, and the pool address XOR-masked so a competitor reading the mempool learns nothing. The inner payload has none of those pressures. It never leaves memory. So it is laid out differently: 37 bytes per hop, pool_type promoted to the front, tick_spacing dropped entirely because execution does not need it, the direction byte replaced by a computed one, and the pool address carried in the clear, already unmasked.

Put the two hop formats next to each other and every one of those decisions is visible:

field outward hop, on the wire inner hop, in memory
pool_type byte 1 byte 0
direction byte 0 byte 1, recomputed
fee, in millionths bytes 2–4 bytes 2–4
tick_spacing bytes 5–6 not carried
the pool itself bytes 7–38, XOR-masked bytes 5–36, in the clear
39 bytes 37 bytes

pool_type and direction trade places. On the wire the direction byte leads, which is what Part 2 found. In memory the pool type leads, because the execution loop’s very first act on each hop is to read byte 0 and branch depending on the pool type behind, sending V2 hops to one routine, V3 and Algebra to another, V4 to a third. The field that decides where to jump is put where the jump can reach it soonest.

tick_spacing disappears entirely. It was never needed for trading, it existed so the simulator in Part 4 could walk the price from one liquidity boundary to the next while it was still deciding. By the time we reach execution the deciding is done, the number has been chosen, and the pool itself will work out which ticks get crossed. So the field is dropped and the hop shrinks by two bytes.

And the pool address arrives unmasked. The XOR mask in Part 2 existed to defeat somebody reading the mempool, and the two bytes it cost were worth paying because calldata is public and billed by the byte. This payload never leaves the contract’s own memory, where there is no competitor to blind and no per-byte charge to dodge, so the masking is simply gone. The obfuscation was never about hiding from the machine. It was about hiding from us, and the moment we are out of the room, the contract just stops bothering.

Bytes 1 through 32 of the inner payload carry the optimal trade size, the single figure the search in Part 4 spent up to twenty-one simulations to find. Everything before this moment was deciding, but this is the place where the contract commits.

And then the callbacks nest. Each hop passes the remaining hops along as the bytes payload of its own swap, so the pool calls back demanding payment, and the contract answers that demand by performing the next hop, which triggers the next callback, and so on down. The innermost hop is the one that finally produces tokens, and its output pays the debt one level up, which pays the debt above that. Nobody puts capital in at the top. The loop funds itself from the inside out, and the PoolManager’s net-to-zero rule is the only thing standing at the end to confirm nobody was cheated.

The blind spots: what this design cannot touch

This section is going to be an accounting of what this clever little machine just can’t see. And the interesting thing is that its blind spots are not accidents. They are the direct, predictable shadow cast by the design choices that make it fast.

The constraint at the read end

Recall the three-way fork from Part 3, the pool_type switch that reads a pool’s price. It knows exactly four dialects: constant-product V2, concentrated-liquidity V3, Algebra, and V4. That switch is the entire universe of pools this bot can price.

A pool speaking any other model is not just unprofitable to it. It is unreadable. Point the bot at a Curve-style stableswap, a Balancer weighted pool with something other than a fifty-fifty split, an order book, or an AMM design that ships next month, and the snapshot function would not know which storage slot holds the price or which formula turns it into a number. There is no branch to fall through to. The route could not even be encoded in the calldata, because there is no pool_type byte that means “one of those.”

The bot’s world is exactly as large as a three-way switch, and not one pool larger.

The constraint at the execution end

Even for a pool it can read, there is a second gate: the trade has to survive the settlement dance we described in the sections above. That rules out a whole category of tokens for a reason that follows directly from how the ledger works.

The simulator computes an exact expected output. The settlement then demands that exact amount arrive. A fee-on-transfer token breaks that contract immediately, because sending 100 delivers 98, and the missing 2 shows up as an unbalanced tab that erases the transaction. A rebasing token does the same thing more subtly, shifting balances underfoot between the snapshot and the settle. Anything that quietly edits the numbers in transit is poison to this design, whose entire safety property is that the numbers add up.

There’s no defensive handling for either in this contract. It’s part of the same trade-off we’ve been seeing again and again: handling those tokens means extra reads, extra branches, and a slower hot path on every probe, including the 999 out of 1000 that go nowhere. The whole contract is a monument to refusing exactly that sort of complexity.

A concrete opportunity it skipped

In a single burst I took apart tx by tx, a large holder unloaded a mid-cap position through a routing aggregator. The aggregator did what aggregators do and split the sell across six different pools to keep its own slippage down, which meant it left a residual price dislocation in all six at once. A generous opportunity, fanned out and served on a plate.

The bot woke up, fired, and made money. But its route list only reached into three of those six pools. The other three, a pool pairing the token against a second stablecoin, another against a different stablecoin again, and a constant-product pool on an entirely separate venue, were not in the menu. No probe was fired at them, so no profitability check ever ran on them, so no decision was ever made about them. That value was invisible rather than rejected.

Look at how the aggregator actually split the sell:

where the sell went share of the sell in the bot’s menu?
main pool, major pair 87.6% yes
second pool, same major pair 4.0% yes
stablecoin pool 6.1% yes
second stablecoin pool 1.1% no
third stablecoin pool 0.3% no
constant-product pool, other venue 1.0% no
watched 97.7%
missed 2.3%

Half the pools were invisible to it and they carried two point three percent of the flow. An aggregator splitting an order to minimise its own slippage sends the overwhelming bulk of it to the deepest pools, and the deepest pools are exactly the ones anybody building a route list would pick first. The blind spot is real, permanent, and structural, and in this instance it cost the operator the tail rather than the body.

Which is kinda the good shape of the tradeoff, but it cuts both ways. The edge is a small, curated route list, cheap enough to walk end to end thousands of times a burst. The cost of that edge is everything outside it, and everything outside it is unreachable, period. But the list was chosen by somebody who knew where the volume lives, and on this occasion that judgement covered ninety-eight percent of what was on offer.

The bot is not trying to catch all the arbitrage on Arbitrum. It is trying to catch one well-understood slice of it more cheaply than anyone else, and given the margins from Part 1, where a probe has to cost a fraction of a cent for the whole model to work, breadth was never affordable. Every pool you add to the menu is another price read on every probe, including all the ones that go nowhere. Coverage is paid for in the hot path, so every little bit of extra gas counts.

Where the money goes, and what we still don’t know

Two threads from the very first article are still loose, so let’s try to get them tied here.

The exit door is obfuscated too

There is one more selector we named in Part 2 and then set aside: 0x8e16402f, the owner-only sweep, the function that moves accumulated profit out of the contract. It is caller-gated and ordinary in purpose, but not ordinary in construction.

The recipient addresses are not stored anywhere in the contract. They are assembled at runtime, out of arithmetic, every time the sweep runs.

The routine takes a constant from the bytecode and puts it through a small gauntlet. First a loop that runs exactly eighty times, peeling one bit off the bottom of the value and pushing it onto the top of an accumulator, which reverses the low eighty bits end for end. Then a rotation, the 160-bit kind where the bits that fall off one end reappear at the other. Then an offset addition. And finally an XOR against a constant with 0xdeadbeefcafebabe sitting in the middle of it, pushed as a literal eight-byte value right there in the open. Only after all of that does a usable address fall out the far end.

Here is the shape of it, cleaned up from the decompiled output. The seeds and the offsets are redacted, the same way the pool-masking key was in Part 2. The operations are verbatim:

// The owner sweep, building a recipient address through multiple operations.

uint256 acc = 0;
uint256 src = uint80(SEED);                  // the low 80 bits of a baked-in constant

while (i < 80) {                             // walk them one at a time,
    acc = (acc << 1) | (src & 1);            //   pushing each bit onto the far end
    src = src >> 1;                          //   of a value being built backwards
    i   = i + 1;
}

addr = (SEED >> 80 << 80) | acc;             // reversed bits slot back under the top
addr = address(addr + OFFSET);               // an offset addition, wrapped to 160 bits
addr = address(addr << 137 | addr >> 23);    // and a rotation. 137 + 23 = 160

Note what the last line is. Shifting left by 137 and right by 23 and OR-ing the halves together is a rotation: the bits that fall off the top reappear at the bottom, nothing is lost, and the two shift amounts sum to exactly the 160 bits of an address. The second recipient gets a rotation too, by a different amount, << 7 against >> 153, and those also sum to 160.

None of this is inferred from the decompiler, which folds several of these constants together and hides the best part. In the deployed bytecode the pieces are all sitting in the open:

0442  JUMPDEST                        ; top of the reversal loop
0443  PUSH1    0x50                   ; 80
0445  DUP2
0446  LT                              ; have we done eighty bits yet?
0447  PUSH2    0x0465
044A  JUMPI
044B  POP
044C  POP
044D  PUSH10   0xffffffffffffffffffff ; the low-80-bit mask
0458  PUSH1    0x50
045A  SHL                             ; ...moved up to clear room underneath

03D9  DUP1                            ; the second recipient's rotation
03DA  PUSH1    0x99                   ;   153
03DC  SHR
03DD  SWAP1
03DE  PUSH1    0x07                   ;   7
03E0  SHL
03E1  OR                              ;   153 + 7 = 160. a rotation.

03AE  PUSH8    0xdeadbeefcafebabe     ; and here it is, in the clear
03B7  DUP2
03B8  XOR

0xdeadbeefcafebabe is a literal PUSH8 at offset 0x03AE with an XOR two instructions later. There are two recipients, assembled by two different recipes, and they are not interchangeable. One receives native ETH, and the sweep sends it the contract’s whole balance. The other receives ERC-20 tokens by transfer. Whether those are two wallets or one wallet reached two ways I have not tried to establish, for reasons I will come to.

This is the same philosophy from Part 2, arriving one last time and applied to the most sensitive value in the contract. Nothing here is hidden in any cryptographic sense. Every constant is public, every operation is visible, and anyone willing to disassemble and single-step can recover both addresses in an afternoon. It’s just friction though, the cost of finding out where the money goes rises from “read the storage” to “understand the code,” and for almost everyone scrolling a block explorer that gap is the whole defense.

And I am going to leave it at that. Showing the mechanism is the interesting part and costs the operator nothing, because the mechanism is in the bytecode for anyone to read. Printing the resolved address is a different act entirely, and it is precisely the pointing-of-the-way the introduction promised not to do. So the reversal, the rotation, the XOR, are all shown. The output withheld, the same way the pool keys were in Part 2.

What I just couldn’t crack

This reverse-engineering series won’t end by claiming total victory. Here is what beat me.

The wake-up signal. Part 1’s central mystery, and still open. The bot sleeps for hours, then wakes seconds before the money lands, on a chain with no public mempool to watch. Part 2 ruled out one candidate by reading every byte of the calldata format and finding no clock in it, no timestamp, no block window, nothing that tells the contract when. So the trigger lives entirely off-chain, in infrastructure the chain cannot show us. The burst autopsy hints that it may be watching one specific upstream actor rather than polling pool state, since the bot’s bursts line up suspiciously well with a single trader’s activity, but a hint is all it is.

The 0x4200...0006 mystery. Part 2’s unexplained detail. The contract goes out of its way to synthesize the OP Stack WETH predeploy address, an address that is a dead EOA on Arbitrum, purely to feed it into an XOR mask. Deliberate misdirection aimed at people doing what I was doing, or a fossil left over from a sibling contract this operator once ran on Base or Optimism? I still cannot tell, but it definitely doesn’t sound like a coincidence.

The exact semantics of profit_scale. Part 3 proved its direction from the shape of the gate, and that argument is airtight: a bare multiplier on one side of a single less-than can only make the test harder to pass. What I could not establish is its units. I can tell you a higher value demands more edge. I cannot tell you how much more, because the other side of that inequality is built from factors I never fully pinned down.


And that is the case file, closed as far as I can close it. Everything recoverable from public bytecode and public transactions is here across five parts: the sleep-and-burst rhythm, the calldata that was an instruction wearing a selector’s clothes, the hand-rolled logarithm and the seventy-year-old search that sizes each trade in a breath of gas, and finally the flash accounting, the letter the contract mails to itself, and the blind spots it accepts in order to stay fast.

What is left over lives off-chain and inside somebody’s head. If that somebody is you, I would genuinely like to know how close I got.

1 Like