Hidden in plain calldata

Part 2 of a 5-part series on an unverified arbitrage bot operating on Arbitrum.

At the end of the previous part, I mentioned this bot’s instructions lived in the calldata of every transaction, packed into a format that takes some work to read, and that when I finally cracked it I found something worth hiding.

A quick recap of the puzzle, so that we’re holding all three pieces in our head at once.

  1. The four leading bytes of every transaction, the place where a normal contract puts its function selector, were different on almost every transaction, but not really that different (this will make sense in a bit).
  2. The calldata was relatively short but obviously structured to the naked eye.
  3. The byte sequences that looked like addresses resolved, on Arbiscan, to empty wallets that had never transacted.

Remember this is an operator who counts gas in single digits and has really thin margins on their successful arbs. Whatever they’re hiding must be very valuable.

Let’s see what was actually in there.

Suspicious selectors

In a normal contract the first four bytes belong to the function selector, the hash of the function’s signature. When you call transfer(address,uint256) on a token, your wallet sends 0xa9059cbb followed by the arguments, and the contract uses those four bytes as a lookup key: it compares them against the selectors it knows, finds the match, and jumps to that function. That lookup is why a block explorer can show you a friendly “Method: Transfer” next to a transaction.


Block explorers (Arbiscan, in the image above) show the method name if the selector matches a known signature, or just the plain selector if it doesn’t.

Where do real selectors come from?

You take the function’s signature, the name and argument types with no spaces, and run it through keccak256, Ethereum’s hash function. The selector is the first four bytes of that hash. transfer(address,uint256) hashes to a 32-byte digest that begins 0xa9059cbb..., so the selector is 0xa9059cbb.

The point of hashing is to turn a human-readable name of any length into a fixed four-byte fingerprint that is cheap to compare and identical for everyone who follows the ABI convention. The price you pay is that a hash is one-way and high-entropy (easy to go from function signature to selector, virtually impossible to go the other way). The output looks like noise, because it is noise. Line a few of them up and you’ll see:

Function signature Selector
transfer(address,uint256) 0xa9059cbb
approve(address,uint256) 0x095ea7b3
balanceOf(address) 0x70a08231
getReserves() 0x0902f1ac

There’s no pattern or structure. That is what real selectors look like, and it is the texture your eye learns to expect at the front of a transaction. When you decompile a normal contract, the dispatcher that consumes those four bytes comes out looking like a chain of comparisons against exactly these kinds of high-entropy constants:

// a normal contract's entry, as a decompiler reconstructs it
fallback() external {
    bytes4 sel = bytes4(msg.data[0:4]);
    if (sel == 0xa9059cbb) return transfer(/* ... */);
    if (sel == 0x095ea7b3) return approve(/* ... */);
    if (sel == 0x70a08231) return balanceOf(/* ... */);
    revert();                       // nothing matched
}

Looking at the opcodes, you’d see a similar view.

PUSH0
CALLDATALOAD
PUSH1 0xE0
SHR                 ; sel = the first 4 bytes of calldata
DUP1
PUSH4 0xa9059cbb
EQ
PUSH2 0x004a
JUMPI               ; sel == 0xa9059cbb  ->  transfer
DUP1
PUSH4 0x095ea7b3
EQ
PUSH2 0x0068
JUMPI               ; sel == 0x095ea7b3  ->  approve
DUP1
PUSH4 0x70a08231
EQ
PUSH2 0x009c
JUMPI               ; sel == 0x70a08231  ->  balanceOf
PUSH0
DUP1
REVERT              ; nothing matched, revert

It is always the same silhouette: peel the four selector bytes off the front, then a ladder of compare-and-jump tests, one rung per function the contract knows, and a REVERT at the bottom for anything that matches nothing.

What about the selectors on this bot?

Now hold that texture about how normal selectors look like in your head and then check out these examples of four-byte selector values the operator submitted to this bot:

  • 022a1f00
  • 042a1f00
  • 022a1f01
  • 032a1f01

At first glance they could pass for hash noise, which is exactly the point. A real selector is four bytes of keccak output, high-entropy from the first bit to the last, slight variations and similarities are way out of place. Something here is only wearing a selector’s clothes.

I then looked at how this contract actually dispatches. The raw opcodes behind it are just two selector checks and a fall-through, one instruction per line:

PUSH0
CALLDATALOAD
PUSH1 0xE0
SHR                 ; sel = the first 4 bytes of calldata
DUP1
PUSH4 0x8e16402f
EQ
PUSH2 0x0050
JUMPI               ; sel == 0x8e16402f  ->  owner sweep
PUSH4 0x91dd7346
SUB
PUSH2 0x000e
JUMPI               ; sel != 0x91dd7346  ->  route interpreter (the fallback)
PUSH2 0x017a
JUMP                ; sel == 0x91dd7346  ->  Uniswap v4 unlockCallback

That middle check is the compiler’s shorthand for “is this the v4 callback?”. It subtracts 0x91dd7346 from the selector and branches away only when the result is nonzero, which is just a backwards way of testing equality. Three destinations in total, two of them named functions and the last a fall-through for everything else. Easier to see on the decompiled version:

// reconstructed from the opcodes view

bytes4 sel = bytes4(msg.data[0:4]);

// owner-only, caller-gated
if (sel == 0x8e16402f) return _ownerSweep(/* ... */);

// Uniswap v4 callback
if (sel == 0x91dd7346) return unlockCallback(/* ... */);

// everything else: process remaining bytes
_fallbackLogic(msg.data);

There are only two real selectors in the entire contract. One is an owner-only function to sweep funds out, gated behind a caller check. The other is 0x91dd7346, which is unlockCallback(bytes), and the operator did not choose it either. Uniswap v4 calls that selector back into your contract during a swap, so it has to be there. Both of these are ordinary garbled keccak fragments, exactly the texture we were describing before.

The bot’s own transactions, the 022a1f00 and 032a1f00 ones, match neither. They fall straight through both checks into _fallbackLogic, where the first byte stops being a failed selector lookup and becomes the first instruction of a program. That is the whole trick.

There is no dispatch table for the arb path. The bot reclaimed the four most-scrutinised bytes in any Ethereum transaction, the bytes every explorer and every competitor reads first, and turned them into data.

This was what I meant when I said the selectors were not that different from each other. They were never selectors to begin with: they’re a packed header. This is what a frequency analysis on the first 4 bytes of all collected transactions the operator submitted looks like.

selector transactions share
032a1f00 1,136 40.6%
022a1f01 1,036 37.0%
022a1f00 211 7.5%
0271b900 101 3.6%
0371b901 76 2.7%
031c4b00 72 2.6%
042a1f00 51 1.8%
032a1f01 49 1.8%
029c4b00 27 1.0%
024e2200 22 0.8%
5 rarer values 16 0.6%

Across all 2,797 transactions, only fifteen distinct four-byte values ever appeared. A real contract’s selectors are scattered across the whole 32-bit space, four high-entropy bytes apiece. This is the opposite: a tiny, lopsided set, with the top two values covering more than three quarters of everything.

The bytes after these “selectors” are more packed fields, which we’ll pull apart in a moment. Nothing here is random. It only looked random because we were reading it as a label when it was something else.

A packed instruction format

We have a really tight subset of numbers in the first byte of calldata, and a handful of bytes after it that we still don’t know what they mean. To be able to decipher them, we must first figure out where the boundaries are.

// decompiled fragment of the bot's contract
uint off = 0;

// outer loop
while (off < CALLDATA.length) {
    uint8 hop_count = uint8(CALLDATA[off]);

    // inner loop
    for (uint i = 0; i < hop_count; i++) {
        bytes memory chunk = CALLDATA[off + 3 + i*39 : off + 3 + (i+1)*39];

        // ... continues operating on this chunk ...
    }

    off += 3 + 39 * hop_count;
}

Those loops were the first big breakthrough I had for cracking the calldata meaning. If you look at them closely, you’ll see they provide the big picture of the structure.

The inner loop’s 39-byte chunk is the atom. Since we know from Part 1 that this bot runs arbitrage, we can name it for what it seems to be: a hop, a single pool the trade passes through. This wasn’t just a random guess, I also matched this with the information exposed on the asset movements of each transaction in Arbiscan, but I won’t link any of them for the privacy reasons I mentioned in the intro.

A few hops with a 3-byte header in front make a route, one complete candidate arbitrage, a cycle through however many pools the header says. A route’s length is therefore fixed by its hop count:

3 + (39 × H)

And because the outer loop does not stop at one route, a single transaction can be several routes laid end to end. The contract reads one, then the next, until the bytes run out.

So, three nested things:

hop      = 39 bytes              (one pool)
route    = 3-byte header + hops  (one candidate arbitrage)
calldata = one or more routes    (a menu)

If this format we’re inferring from the decompiled code is right, the lengths are forced:

  • a 2-hop route is 3 + (39 × 2) = 81 bytes
  • a 3-hop route is 3 + (39 × 3) = 120 bytes
  • a 4-hop route is 3 + (39 × 4) = 159 bytes

We can validate this hypothesis by looking at how long the calldata actually is, across the whole dataset of captured transactions.


Calldata length across all 2,797 operator-initiated transactions to the bot’s contract. Uniswap’s internal callbacks aren’t counted here.

Raw byte counts from on-chain data confirmed the arithmetic. 81 and 120 bytes make up the overwhelming majority, with a few 159s here and there. Sharp readers might have noticed there’s no H integer value that would make 3 + (39 × H) equal to 162, 243 or 279, so what’s up with that?

It is because 3 + (39 × H) is actually a special case of the calldata size equation. There’s a hidden term in there. To be more precise, the equation is 3 × R + (39 × H). It just so happens that for calldata sizes 81, 120 and 159, R == 1, but that’s not true for the other values.

If you wonder where R comes from, it’s the outer loop of the pseudocode I showed you at the top of this section. If you wonder what it means, we need to take a deeper look at the strategy encoded in this bot.

Blind arbitrage, revisited

In Part 1 we looked, from the outside, at what this bot does: it cannot see an opportunity coming, so it fires cheap probes and lets most of them miss, paying a sliver of gas each time, waiting for the rare one that lands on a real price dislocation.

A dislocation is just a price that disagrees with itself. The same asset, say WETH, should cost about the same whether you buy it with USDC on one pool or on another. But after a big trade or a burst of activity, those two prices can drift apart for a moment, and in that gap there is a free loop: buy WETH where it is briefly cheap, sell it where it is briefly more expensive, and pocket the difference. That loop is what the bot is hunting.

Here is the part that didn’t fit back then. When a dislocation opens, the bot usually knows the rough neighborhood, which token and roughly which pools, about now, but it cannot know in advance which exact path through those pools will actually turn a profit. That depends on live state it can’t see yet: who else has already traded against the gap, how much of it they closed, which pools moved and which didn’t. The shape of the opportunity only settles once the dust does.

So it doesn’t bet on a single path. A route is one guess at how to capture the opportunity, a particular loop through a particular set of pools. The simplest is a two-hop cycle, buy WETH on pool A and sell it back on pool B, which is exactly the 3 + (39 × H) shape with H = 2. A 3-hop route is the same loop with one more pool in it, a 4-hop route with two more. Each hop is one pool to read and one leg of the cycle. We will run this two-hop loop end to end, with real numbers, at the close of this part. For now only the shape matters.

When the bot isn’t sure which guess will pay, it writes down several of them and bundles them into one transaction. The contract then walks the list, checking each route in turn and taking whichever one clears. The longer calldata is just that, more guesses packed into the same envelope.

To make that concrete too, take the same dislocation, but suppose the bot can’t tell whether pool B or pool C ended up holding the gap. So it hedges, packing two routes into one transaction. The first route:

  • hop one: on pool A, trade USDC for WETH
  • hop two: on pool B, trade that WETH back for USDC

And a second route that bets on a different exit:

  • hop one: on pool A, trade USDC for WETH
  • hop two: on pool C, trade that WETH back for USDC

Both routes buy WETH cheap on pool A and bet on a different pool, B or C, to be the one where it’ll be sold for a profit. The contract checks the first, and if it doesn’t clear, it checks the second. Two routes of two hops each makes R = 2 and H = 4, so the calldata lands at (3 × 2) + (39 × 4) = 162 bytes, one of the straggler lengths from the chart above.

Bundling buys two things, both of which matter to a bot this obsessed with cost:

  • It pays the fixed cost of a transaction once instead of once per guess. Every transaction carries overhead that has nothing to do with the trade itself, and for a bot whose whole model is making a probe cheap enough that a 1-in-100 hit rate still profits, spreading that overhead across several candidates directly lowers the cost of each one.

  • It can take more than one. If the dislocation opened several gaps at once, the contract clears several routes from the same list in a single atomic shot, rather than racing to send a follow-up transaction that a competitor might grab first. This is the answer to a small mystery from Part 1, a single transaction that somehow booked two profits at once.

So before we decode a single value inside it, this is the shape of what the calldata holds: a short menu of candidate arbitrages. One route is the common case, the single 2- or 3-hop probe that is the overwhelming majority of the chart above. The longer payloads are just two or three routes laid end to end, a few guesses fired together for the price of one.

And that closes the loop on the selector table we started this part with. Those tidy four-byte values were never function names. Each one is a route header, and its leading byte announces how many hops the arb walks:

selector transactions share meaning
032a1f00 1,136 40.6% a 3-hop arb instruction
022a1f01 1,036 37.0% a 2-hop arb instruction
042a1f00 51 1.8% a 4-hop arb instruction

Now, what is actually encoded in the rest of the calldata? I pulled transaction after transaction and single-stepped the contract using cast run --debug as it tore a route apart, watching which slice it masked, what it compared each value against, and where each one flowed afterwards. I also manually traced through the decompiled output from Dedaub, and the opcodes view from ethervm (because I find the code comments useful).

You’ll see many weird variable names, as well as confusing comparison syntax, but I decided to leave it mostly that way so you can see what I saw. Let’s go over the process of deciphering and finding the meaning of each of the variables packed in the calldata.

What type of pool is the contract talking to?

This snippet is taken from Dedaub’s decompiled output. We’re looking at a function that was called right at the beginning of the arb evaluation hot-path, and received the full hop struct as input arg (varg0), and returned a struct with information about the pools encoded in the calldata.

// this was inlined into each if-comparison, I extracted it into an aux variable for visibility purposes,
// stores the second (0x1) byte of the struct that holds the hop data, addressed by `varg0 + v4 * 39`
v20 = byte(MEM[varg0 + v4 * 39], 0x1);

// determine how to build the call depending on which pool type we're querying
if (v20 == 2) {
    // uniswap v4
    v9 = UNISWAP_V4_POOL_MANAGER.staticcall(keccak256(v6, 6), keccak256(v6, 6), keccak256(v6, 6)).gas(msg.gas);
    
    MEM[v1 + v2] = address(MEM[0]) * v9;
    MEM[v1 + (v2 + 32)] = int24(MEM[0] >> 160);
    v10 = UNISWAP_V4_POOL_MANAGER.staticcall(keccak256(v6, 6) + 3, keccak256(v6, 6) + 3, keccak256(v6, 6) + 3).gas(msg.gas);

    /* code proceeds to build a 96-byte struct that holds the following information
      [0-31]  sqrtPriceX96  -  lower 160 bits of extsload(pool_slot0_key)
      [32-63] tick          -  int24(slot0_value >> 160)
      [64-95] liquidity
    */
} else if (v20 == 1) {
    // uniswap v3 or Algebra
    v12 = v13 = v6.slot0().gas(msg.gas);
    if (!v13) {
        v12 = v14 = v6.globalState().gas(msg.gas);
    }

    MEM[v2 + v1] = MEM[0] * v12;
    MEM[v1 + (32 + v2)] = MEM[32];
    v15 = v6.liquidity().gas(msg.gas);

    /* code proceeds to build a 96-byte struct that holds the following information
       [0-31]  sqrtPriceX96  -  first word of slot0() or globalState() return
       [32-63] tick          -  second word of slot0() or globalState() return
       [64-95] liquidity
    */
} else if (!v20) {
    // uniswap v2
    v17 = v6.getReserves().gas(msg.gas);

    /* code proceeds to build a 96-byte struct that holds the following information
       [0-31]  reserve0
       [32-63] reserve1
       [64-95] (unused)    
    */
}

Here we see an if/else structure with 3 branches, and a single byte is picked off the calldata to determine which branch execution flows through.

  1. When that byte’s value is 2, a couple of staticcall calls are done to the UNISWAP_V4_POOL_MANAGER.
  2. If it’s 1, then the logic considers two possible scenarios: if it’s a Uniswap V3 pool, then the slot0() call will return a valid value. If not, then it’ll fallback to calling globalState(), an Algebra-style pool. After that, both types of pool have a liquidity() function, so there’s no need to check again.
  3. If v20 == 0 (or like the decompiler put it, !v20) then it knows it’s dealing with a Uniswap V2-style pool, and calls getReserves() on it.

I removed the code that actually builds the return struct, but as you can see, it holds 96 bytes and slightly different information depending on the type of pool. However, the goal seems to be the same: capture the pool’s current state, whatever form it takes, into a fixed 96-byte snapshot. A V2 pool contributes its reserves, a V3, Algebra or V4 pool its price and liquidity, but either way the code downstream gets a uniform record to run its profitability math on, with no idea or care which kind of pool produced it.

The job of that single byte in the hop structure becomes clear: It is the switch that tells the contract which kind of pool it is about to read, and therefore how to read it. It sits in the same place inside every hop, the second byte, and it only ever takes the values 0, 1 or 2. We’ll call it pool_type.

HEADER
  byte 0      hop_count          how many hops follow
  bytes 1–2   ???

HOP  (39 bytes, repeated hop_count times)
  byte 0      ???
  byte 1      pool_type          which kind of AMM this is
  bytes 2–4   ???
  bytes 5–6   ???
  bytes 7–38  ???

Which way should the bot trade?

This next snippet is from a different function, one of three tiny price probes the contract calls once per hop while it sizes up an opportunity. This one reads a Uniswap V2 pool, but the behavior is analogous to the other pool types the contract supports. It is handed the pool’s address (varg0) and the hop’s first byte (varg1), and it returns the pool’s current price.

// Uniswap V2 price probe.
// varg0 = pool address, varg1 = the hop's first byte

v0 = varg0.getReserves().gas(msg.gas);   // MEM[0] = reserve0, MEM[32] = reserve1

if (varg1 == 0) {
    return 10 ** 27 * MEM[32] / (MEM[0] * v0);   // reserve1 / reserve0
} else if (varg1 == 1) {
    return 10 ** 27 * (MEM[0] * v0) / MEM[32];   // reserve0 / reserve1
} else {
    return 0;
}

Here we see a smaller branch, this time on varg1, the hop’s first byte:

  1. getReserves() hands back the pool’s two token balances, reserve0 and reserve1. The price of one token in terms of the other is just their ratio.
  2. When varg1 == 0, the function returns reserve1 / reserve0.
  3. When varg1 == 1, it returns the inverse, reserve0 / reserve1.

So the only thing this byte does is choose which way up to read the ratio. One value is the price of the pair one way, the other is the price the other way.

I pulled the first byte of all 8,115 hops the bot ever encoded, across all 2,797 transactions, and it held exactly two values, 0 and 1, and never anything else. A byte that is only ever 0 or 1 is a flag.

Back in the arbitrage section we said the bot spots a dislocation between, say, USDC and WETH. But knowing a gap exists between two pools is only half the problem, you still have to know which way to cross it: buy WETH cheap on one pool and sell it on the other, or the other way around. Buy when you should have sold and the profit becomes a loss.

This byte chooses which way up the ratio gets read, and it does that while the contract is still only checking what’s there, before it has moved a single token. That’s the entire extent of what I watched it do.

The rest is interpretation. Reading the byte as which side of the pair the bot means to be on is the natural move, and I think it is right, but it is a story laid over the observation of its behavior rather than something on the code itself. Anyone who knows Uniswap will already be objecting here, because every V3 swap carries a boolean called zeroForOne, so surely this byte is just that flag riding in from the calldata.

Well, it’s not. When the contract finally commits, it re-encodes the route into a fresh payload whose per-hop direction field is a computed value, and it carries a second flag alongside it deciding whether the hops get walked forwards or backwards at all. This byte feeds both and is copied into neither, in a code section we will not open until the last part of this series.

Having said that, while still surrounded by a little bit of mystery, we can definitely say the first byte of each hop means direction.

HEADER
  byte 0      hop_count          how many hops follow
  bytes 1–2   ???

HOP  (39 bytes, repeated hop_count times)
  byte 0      direction          which side of the pair is the input
  byte 1      pool_type          which kind of AMM this is
  bytes 2–4   ???
  bytes 5–6   ???
  bytes 7–38  ???

How much does each pool charge?

This snippet comes from deeper in the hot path, inside the function that simulates a route’s round trip to estimate what it pays out. Once per hop, it pulls a three-byte slice out of the hop and drops it into the swap math. Most of this code was inlined, I just added a few variables for clarity.

// inside the per-hop swap simulation
uint24 fee = uint24(MEM[hop] >> 216);   // bytes 2-4 of the hop, in millionths

// the pool takes its fee off the input first
uint amountInWithFee = (1e6 - fee) * amountIn;

// ...then it is a plain constant-product swap
uint numerator   = amountInWithFee * reserveOut;
uint denominator = reserveIn * 1e6 + amountInWithFee;

amountOut = numerator / denominator;

If you have ever looked at an automated market maker, those lines might look familiar. It is the constant-product output formula, the one that says how much of one token you get for a given amount of the other, with the pool’s trading fee skimmed off the input first.

The number is in millionths, so 3000 is 0.30%, the same 997/1000 a Uniswap V2 pool hardcodes. This particular snippet shared above is the code branch for constant-product (V2) pools. The V3, Algebra and V4 pools get a heavier tick-by-tick walk instead. We are looking at the V2 branch only because it is the clearest place to watch this byte do its work (the literal decompiled line is a wall of repeated uint24(MEM[hop] >> 216) wrapped in a fixed-point reciprocal, unreadable, but structurally this exact formula.)

Across all 8,115 hops analyzed, that slice held eight distinct numbers. On the V2 and V3 pools they are the fee tiers any DeFi user knows on sight: 100, 500, 2500, 3000 and 10000, which is 0.01% through 1%. On V4, where a pool may pick any fee it likes, the numbers get stranger (8, 70000, 85000), but the contract runs every one of them through the same slot, and on V4 that same number is also one of the five fields that identify the pool itself.

That three-byte slice riding just after pool_type is the pool’s fee.

HEADER
  byte 0      hop_count          how many hops follow
  bytes 1–2   ???

HOP  (39 bytes, repeated hop_count times)
  byte 0      direction          which side of the pair is the input
  byte 1      pool_type          which kind of AMM this is
  bytes 2–4   fee                the pool's fee tier (e.g. 500 = 0.05%, 3000 = 0.30%)
  bytes 5–6   ???
  bytes 7–38  ???

How big are the pool’s price steps?

Let’s talk about the two bytes sitting right after the fee, representing a small number on its own.

When I tabulated those two bytes against the fee across every hop, they didn’t vary freely. Each fee dragged a specific partner along with it.

When fee was These bytes were
100 1
500 10
3000 60
10000 200
V2 pools 0

That table should ring a bell if you ever spent some time looking inside Uniswap V3. It is the exact fee-to-tick-spacing schedule Uniswap V3 uses: the 0.05% tier steps by 10, the 0.30% by 60, the 1% by 200, all set in the factory’s constructor (the 0.01% 1-step tier came later, enabled by governance rather than baked in, but it obeys the same one-fee-one-spacing rule). Then, the bottom row confirms it: every constant-product (V2) pool carries a 0 here, because those pools price continuously and have no stepped grid to space out.

Those two bytes are read as a sixteen-bit value and filed into the pool-state snapshot, packed into the high half of the very same slot that holds the pool’s liquidity.

// per-pool state snapshot (concentrated-liquidity pools only)
uint16 step = uint16(MEM[hop] >> 200);            // bytes 5-6 of the hop
MEM[snapshot + 64] = (step << 128) | liquidity;   // packed into the high half, beside liquidity

So the contract treats it as live pool state, sitting beside liquidity, read only for the concentrated-liquidity pools and never for the constant-product ones, then handed to code that walks the pool’s price from one liquidity boundary to the next. A sixteen-bit number that rides locked to the fee, lives next to liquidity, and exists only where pools have a stepped price grid. We can safely call this one tick_spacing.

HEADER
  byte 0      hop_count          how many hops follow
  bytes 1–2   ???

HOP  (39 bytes, repeated hop_count times)
  byte 0      direction          which side of the pair is the input
  byte 1      pool_type          which kind of AMM this is
  bytes 2–4   fee                the pool's fee tier (e.g. 500 = 0.05%, 3000 = 0.30%)
  bytes 5–6   tick_spacing       the pool's tick spacing
  bytes 7–38  ???

How much profit is enough?

Two more fields still to be deciphered. We have walked past this one the whole time, because it sits in the three-byte header at the front of every route, right after the hop count: two bytes, read as a single sixteen-bit number, one per route.

Across all 3,266 route records in the dataset, it held just seven distinct values.

Field value Frequency
10783 90.3%
29113 5.5%
7243 2.2%
40011 0.8%
20002 0.7%
15760 0.4%
51430 0.1%
(It’d be trivial to find the contract if I used the real numbers, so these are substituted stand-ins, not the operator’s real thresholds. What is real is the shape: seven distinct values, one of them covering the overwhelming majority, and the ordering among them.)

One value, let’s call it 10783, covers nine routes out of ten. The others appear in small, isolated clusters, the kind of pattern you get when someone changes a setting for a short run and then changes it back.

The contract uses this value deep in the go/no-go decision section, the part that decides whether a route is even worth continuing to look into. It shows up as a multiplier on one side of an inequality.

This inequality check lies before a critical part of the bot’s logic: if the left side stays under the right, the route is worth pursuing. If not, the contract drops it and moves to the next.

This field is a thumb on the scale of that comparison. A larger value pushes the left side up, which makes the test harder to pass, which means fewer routes clear. By that logic a higher number is a stricter bar, and the way the rare high values line up with short isolated bursts looks exactly like the operator tightening the filter to test something.

But that is where the trail goes cold. Every other field I was able to name had an anchor: a published function to dispatch to, a recognizable swap formula, a fee schedule any V3 user knows. This one has none. The values don’t look like any standard (neither before or after mangling), they are a number the operator chose for some reason, so there is nothing external to check them against.

The inequality it lives in is the front door to the heaviest math in the contract, which we’ll closely look into in the next part of this series. However, this number can still be seen as a knob on the threshold without knowing the units it is measured in, or how much more edge a 29113 really demands than a 10783.

I’ll name this value profit_scale, but it’s tentative. I’m confident it is a per-route threshold knob the operator tunes, but that’s as far as I’ll guess. The rest is locked behind the profitability engine. We’ll meet profit_scale again there, and (hopefully) finally understand it.

HEADER
  byte 0      hop_count          how many hops follow
  bytes 1–2   profit_scale       how much edge to demand before committing

HOP  (39 bytes, repeated hop_count times)
  byte 0      direction          which side of the pair is the input
  byte 1      pool_type          which kind of AMM this is
  bytes 2–4   fee                the pool's fee tier (e.g. 500 = 0.05%, 3000 = 0.30%)
  bytes 5–6   tick_spacing       the pool's tick spacing
  bytes 7–38  ???

While it seemed like random noise, the calldata did in fact have a hidden structure. Which brings us to the last unknown piece.

The addresses that led nowhere

Back at the very beginning, what first made me suspect the calldata was obfuscated were those 32-byte chunks whose lower 20 bytes read exactly like an address. I pasted a few of those apparent addresses into Arbiscan and got nothing back: no contract, no history, just empty EOAs that had never once transacted. Properly formatted garbage.

Every hop ends with one of these 32-byte chunks, bytes 7 through 38, by far the largest field in the record, and not one of them resolved to anything real.

So I followed the field into the parsing path, the same loop that reads the rest of the hop. Right after the contract lifts those 32 bytes out of the calldata, and before it ever uses them, it does this dance:

// inside the per-hop loop
// varg0 holds the record start
// v4 holds the hop index.
// MEM[varg0 + v4 * 39 + 7] points to the suspicious looking 32-byte chunk

v6 = v7 = MEM[varg0 + v4 * 39 + 7] ^ 0xa1b2c3d4e5f6a7b8c9daebfc0d1e2f3a4b5c6d7e;
if (msg.data.length > 1) {
    v6 = v8 = 0x4200000000000000000000000000000000000006 ^ v7;  // result stored in v6
}

It took me a while to understand. The arithmetic is two XORs stacked on top of each other. The first part masks the raw 32 bytes against a constant that I’ll keep private to avoid revealing the pools this bot hunts on (0xa1b2c3… is made up by me for demonstration purposes).

The second, tucked behind a guard that is always true (most likely a decompiler artifact), takes that result and XORs it against a second constant, 0x42000…06, this is the actual value in the bytecode, I’m not hiding this one. The output, v6, is what the contract then hands to a staticcall.

This last field on each record in the calldata was never meant to be read directly. It is a real pool reference with two fixed masks laid over it, and the contract peels them off at runtime, with a pair of XORs before it actually uses it.

One caveat though. I keep saying two masks, and that holds for every hop except the very first one in the payload. That first hop’s pool key carries a third thin mask on top of the other two, one more XOR against another constant, covering only a fixed slice of the leading bytes. It is the same trick applied one extra time, it changes nothing about how the unmasking works or anything we conclude from it, and I am folding it into the clean two-XOR story purely for readability.

The second constant is definitely interesting: 0x4200000000000000000000000000000000000006 is a recognizable, public value, sitting in the decompiled source in the clear.

Let’s stop for a second here, because this is the one detail in the whole contract I haven’t been able to crack, and I doubt we’ll get a definitive answer unless the people who wrote the bot read this and decide to share their intent. That is an actual address, 0x4200000000000000000000000000000000000006, is the canonical WETH predeploy on OP Stack chains: Optimism, Base, and the rest of that family each ship with WETH baked into the chain at exactly that slot.

But this is an Arbitrum contract, and Arbitrum is not an OP Stack chain. It has no 0x4200... predeploy range, and its real WETH lives at 0x82aF49447D8a07e3bd95BD0d56f35241523fBab1. On Arbitrum, that address is just an EOA with no code.

But this contract is aware of that. The genuine Arbitrum WETH address is sitting right there in its data section, used for the real token bookkeeping. The 0x4200…0006 value, on the other hand, is never stored as a constant at all. The contract assembles it on the fly, from three tiny pushes, in the instructions right before the XOR:

115A    JUMPDEST
115B    PUSH1 0x06
115D    PUSH1 0x21
115F    PUSH1 0x99
1161    SHL              ; 0x21 << 0x99  = 0x4200000000000000000000000000000000000000
1162    ADD              ; + 0x06        = 0x4200000000000000000000000000000000000006
1163    XOR              ; ^ the partially-unmasked pool key
1164    PUSH2 0x114f
1167    JUMP

That same five-instruction recipe (PUSH 0x06, PUSH 0x21, PUSH 0x99, SHL, ADD) shows up at every call site that needs the constant, never once as a stored 20-byte value.

So the contract uses the correct Arbitrum WETH elsewhere, then goes out of its way to synthesize this other, OP Stack address purely to feed it into an XOR. It is never the target of a CALL, never a token the bot moves, never anything but arithmetic.

I’m going to say this, even though I don’t like it since I don’t believe in coincidences, but this seems like a XOR key that happens to match the OP Stack WETH predeploy address. Whether that’s deliberate misdirection aimed at anyone reverse-engineering the contract, or a remnant of a similar contract the same operators once deployed on an OP Stack chain, I genuinely can’t tell.

When I XORed one of those dead-end calldata chunks back through both constants and watched a live, well-known DEX pool fall out the other end, that was the moment the whole thing clicked.

To make the deciphering concrete, here is a worked example you can verify yourself. None of it touches the operator’s secrets: the first constant below is an illustrative key I made up, and the pool is a famous public one (the Uniswap v3 WETH/USDC 0.05% pool on Arbitrum), not anything this bot routes through. XOR the three values together and the real pool address falls out.

# This is what comes in the calldata
pool_key from calldata :  0x2524e3d011a4192aca83f26ddc414402c55585a8

# It's XORed against this hardcoded key in the contract
XOR  first constant    :  0xa1b2c3d4e5f6a7b8c9daebfc0d1e2f3a4b5c6d7e

# It's XORed again
XOR  WETH              :  0x4200000000000000000000000000000000000006   (OP Stack WETH, public)

# The result is this address (in this example, Uniswap v3 WETH/USDC 0.05%, in Arbitrum)
real pool              :  0xc6962004f452be9203591991d15f6b388e09e8d0

The operator’s real version works exactly the same way. The masked field is 32 bytes wide and carries the pool address in its low 20 bytes, which is the slice shown above and the slice the contract reads once the two XORs have landed.

Neither constant is truly hidden. They are compiled into the bytecode, which is public, and anyone willing to decompile the contract and assemble the pieces can recover them. This was never secrecy in the cryptographic sense. It was friction, raising the cost of reading the routes from “glance at the calldata” to “reverse-engineer the contract”. For a competitor scrolling a block explorer, that friction is the whole game.

So the last and largest field in every hop finally has a name. Those 32 bytes that led nowhere are the pool_key: a masked reference to the exact pool the contract is about to trade through.

And this is the answer to the question this whole article has been circling. Why hide the pools? Because in a blind-arb bot the route list is everything. Which pools you watch, and in what order you walk them, is the entire edge. Anyone who can read your calldata can read your strategy, clone it, and start firing into the same opportunities.

Mask the pools and a competitor staring at your transactions learns nothing but lengths and fee tiers. The single most valuable thing in the payload is the one thing they can’t easily see.

The paradox, resolved

So back to the thing that bothered me from the start. An operator this obsessed with gas, deliberately adding code to obfuscate the hot path. What could possibly be worth the cost?

Now we know the premise was false, there’s barely any cost. The de-obfuscation is a XOR, and it’s one of the cheapest things the EVM can do, costing only three gas. Unmasking a pool is a few of those XORs per hop, a handful of gas. The opacity that protects the operator’s entire edge is, in gas terms, a rounding error.

It’s better than that, even. The masking rides for free on top of something they wanted anyway. They were always going to pack the format tight to save calldata gas, because every byte is billed, and that packing is what ships far fewer bytes than honest ABI-encoded plaintext would.

The XOR just travels inside those same bytes: it is length-preserving, so scrambling the pools changes what the bytes say, not how many there are. The savings come from the packing, the secrecy tucks in for the few gas a hop we already counted, and shrinking the route and hiding it live in the same handful of bytes.

Put a route back together and the whole grammar is this:

HEADER
  byte 0      hop_count          how many hops follow
  bytes 1–2   profit_scale       how much edge to demand before committing

HOP  (39 bytes, repeated hop_count times)
  byte 0      direction          which side of the pair is the input
  byte 1      pool_type          which kind of AMM this is
  bytes 2–4   fee                the pool's fee tier (e.g. 500 = 0.05%, 3000 = 0.30%)
  bytes 5–6   tick_spacing       the pool's tick spacing
  bytes 7–38  pool_key           the pool, XOR-masked

A route is a header plus a handful of these 39-byte hops, and each hop carries everything the contract needs to touch one pool: where it is, what kind of pool it is, its fee tier and tick spacing, and which direction to trade through it.

The thing to appreciate is how tight this is. On-chain, calldata is billed by the byte, 16 gas for every non-zero byte you send. The naive way to encode a swap route, the way you’d get for free from Solidity’s ABI encoder, pads every value out to a 32-byte word: an address becomes 32 bytes, a fee becomes 32 bytes, a flag becomes 32 bytes. This format saves them from paying for that padding. A fee tier lives in 3 bytes. A direction lives in a single byte. Every byte in the calldata is doing work, because every byte is gas.

Here is a real-looking two-hop arbitrage, the everyday case for this bot, written the naive way: one full 32-byte word per field, exactly what you’d get for free from Solidity’s ABI encoder. The two pools are genuine, public Uniswap v3 WETH/USDC pools on Arbitrum (but not routes I’ve seen this bot take).

hop_count     0x0000000000000000000000000000000000000000000000000000000000000002
profit_scale  0x0000000000000000000000000000000000000000000000000000000000002a1f

--- hop 1 ---

direction     0x0000000000000000000000000000000000000000000000000000000000000000
pool_type     0x0000000000000000000000000000000000000000000000000000000000000001
fee           0x00000000000000000000000000000000000000000000000000000000000001f4   (500  = 0.05%)
tick_spacing  0x000000000000000000000000000000000000000000000000000000000000000a   (10)
pool_key      0x000000000000000000000000c6962004f452be9203591991d15f6b388e09e8d0

--- hop 2 ---

direction     0x0000000000000000000000000000000000000000000000000000000000000001
pool_type     0x0000000000000000000000000000000000000000000000000000000000000001
fee           0x0000000000000000000000000000000000000000000000000000000000000bb8   (3000 = 0.30%)
tick_spacing  0x000000000000000000000000000000000000000000000000000000000000003c   (60)
pool_key      0x000000000000000000000000c473e2aee3441bf9240be85eb122abb059a3b57c

The arbitrage this encodes is a plain two-hop loop. Hop one trades USDC into WETH on the 0.05% pool. Hop two trades that WETH back into USDC on the 0.30% pool. If WETH is momentarily a touch cheaper on the first pool than the second, the round trip comes out the other side holding more USDC than it started with, and that difference (minus both pools’ fees) is the profit. The two direction bytes are what make the hops trade opposite ways.

Notice how much of that is zeroes. Every field, whether it needs one byte or twenty, gets blown up to a full 32-byte word. All told, 12 words x 32 bytes = 384 bytes, and the overwhelming majority of them are padding you are paying 16 gas per non-zero byte to avoid and still shipping.

And here is the exact same arbitrage, packed the way this bot actually does it:

0x022a1f00010001f4000a000000000000000000000000c6962004f452be9203591991d15f6b388e09e8d00101000bb8003c000000000000000000000000c473e2aee3441bf9240be85eb122abb059a3b57c

Every field is butted right up against the next one, no padding, in the exact layout we spent this whole part decoding:

02        hop_count      = 2
2a1f      profit_scale   = 10783
-- hop 1 --
00        direction      = 0
01        pool_type      = 1      (Uniswap v3)
0001f4    fee            = 500    (0.05%)
000a      tick_spacing   = 10
0000…c6962004…e09e8d0    pool_key   (32 bytes)
-- hop 2 --
01        direction      = 1
01        pool_type      = 1      (Uniswap v3)
000bb8    fee            = 3000   (0.30%)
003c      tick_spacing   = 60
0000…c473e2ae…9a3b57c    pool_key   (32 bytes)

That is 3 + (39 x 2) = 81 bytes, against 384 for the naive version. Same arbitrage, same information, a little over a fifth of the size. And the two pool_key words are shown here in the clear only so you can line them up against the naive block. In a real transaction each one is XOR-masked exactly as we saw earlier, which hides the pool but does not change its length, so the size win is untouched by the obfuscation.

That was the misdirection. I’d assumed there was expensive decode logic somewhere, and that whatever justified the expense must be enormously valuable. The expensive logic never existed. The valuable thing was just the route list, plain and ordinary, and they got to hide it for a few gas a hop.

We have all the ingredients, but what about the recipe?

Let’s take a step back and look at what the calldata handed us. Every packed field is an input: which pools to touch, what kind of AMM each one is, the fee it charges, the price grid it moves on, which way to trade through it, and a dial for how much edge to demand before committing. Put them side by side and you are holding the complete set of ingredients for an arbitrage attempt.

But the ingredients are not the meal, nothing in the calldata actually decides anything. It never says whether the loop turns a profit, how large a trade to push through it, or whether this candidate even deserves a single unit of gas. Those are the questions the contract answers on its own, in the hot path, thousands of times per burst, and so far we have only glanced at the machinery that does it.

There is one more absence worth pointing at, because it settles a guess from Part 1. Back then, watching the bot wake up seconds before the money landed, I listed a few candidates for its trigger, and one of them was that the timing might be baked into the calldata itself.

We have now read every byte of the format, and there is no clock in it. No timestamps, no block windows, nothing that tells the contract when. The wake-up signal lives entirely off-chain, in infrastructure we can’t see from outside, and the calldata is only the order it phones in once that signal has already fired. What trips that alarm is the one behavioral question we won’t be able to answer at this time.

Remember the shape of this bot from Part 1. It wakes up, fires a dense volley of probes, and watches nearly all of them revert, exactly as intended. The whole economic model balances on one unforgiving requirement: it has to pick up a candidate arbitrage and throw it away, cheaply and almost instantly, the moment it fails to clear. If discarding a dud cost real money, a single burst would bleed the operator dry. The rare winner only pays for itself because rejecting the thousands of losers around it is pretty much free.

Imagine what such a check has to pull off. Before it commits a single token, it has to know whether a loop through two, three, four pools comes out ahead, when those pools might be a constant-product V2, a concentrated-liquidity V3, an Algebra pool, and a V4 pool, each one speaking a different pricing dialect. It has to find the trade size that wrings the most out of the gap, large enough to matter, small enough not to move the price against itself. And it has to do all of that in a few thousand gas, again and again, knowing the answer will almost always be no.

That computation is the most clever thing in the entire contract, and it is the one part we haven’t opened yet. The calldata was the menu on the wall. The kitchen, where a candidate becomes a yes or a no, is where the real work happens, and there is some genuinely elegant math waiting back there, the kind you would not expect to meet in the hot path of a MEV bot.

Everything in this part was the bot deciding where to look. Part 3 is how it decides, in a breath of gas, whether looking was worth it.

1 Like