Hidden in plain calldata
The faint sound of a money printer - Part 2 of 5
Just in case you’re new to the series, the subject is an unverified arbitrage bot on Arbitrum, one of eight contracts the same operator deployed over about two months, and this part takes apart the instructions its transactions carry.
The one thing worth knowing going in is that the bot fires dense bursts of cheap transactions at the chain, and every single one of them carries a short, tightly packed payload that no block explorer can make sense of.
At the end of Part 1, 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 that different (keep reading, 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.
This is a sophisticated operator who counts gas in single digits. Why would they spend any of it behind obfuscation?
Let’s see what was actually in there.
Suspicious selectors
Every call to an Ethereum contract starts with four bytes. In a normal contract those four bytes are a function selector, the first four bytes of the hash of the function’s signature. When you call transfer(address,uint256) on a token, your wallet sends 0xa9059cbb followed by the arguments of the function, and the contract uses those four bytes as a lookup key. It compares them against the selectors it knows, finds the match and then jumps to that function. That’s the reason wwhy a block explorer can show you a friendly “Method: Transfer” next to a transaction.

Where do real selectors come from?
By taking the function’s signature, the name and argument types with no spaces, and running them 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 tradeoff is that a hash is one-way and high-entropy (easy to go from function signature to selector, but virtually impossible to go the other way).
The output looks like noise, because it is noise. Line them up and see for yourself:
See the selectors? Maybe there are a few repeated characters here and there, but 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, revertIt is always the same shape: 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 you just saw 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:
Individually, they could pass for hash noise, because that’s what you expect to see. 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 -> fallback
PUSH2 0x017a
JUMP ; sel == 0x91dd7346 -> Uniswap v4 unlockCallbackThat 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 isunlockCallback(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.
At this point, we cracked the first question of the list at the beginning of the article: the “selectors” never repeated and never decoded to a known signature because 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.
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 tag when it was already an instruction.
A packed instruction format
After the really tight subset of numbers in the first few bytes of calldata, the handful of bytes after it don’t really share the same similarities, although there are some spots where a long string of zeroes is suspiciously aligned.
In any case, to decipher them we need to find out where the boundaries are. Let’s look at the code:
// decompiled fragment of the bot's contract
uint off = 0;
// outer loop
while (off < CALLDATA.length) {
uint8 hopCount = uint8(CALLDATA[off]);
// inner loop
for (uint i = 0; i < hopCount; i++) {
bytes memory chunk = CALLDATA[off + 3 + i*39 : off + 3 + (i+1)*39];
// ... continues operating on this chunk ...
}
off += 3 + 39 * hopCount;
}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 (more details on that soon), we can name it after what it seems to be: a hop, a single pool the trade passes through.
Of course, this can also be confirmed by looking at the transaction asset movements in Arbiscan, but I won’t link any of them for the privacy reasons I mentioned in the intro.
Whatever happens inside that outer loop involves one or more hops, so calling each of these bundles of hops a route is a pretty educated guess.
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 x 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 the format we’re inferring from the decompiled code is right, the lengths are forced:
a 2-hop route is
3 + (39 x 2) = 81bytesa 3-hop route is
3 + (39 x 3) = 120bytesa 4-hop route is
3 + (39 x 4) = 159bytes
The structure seems to be 3 + (39 x H). We can validate this hypothesis by looking at how long the calldata actually is, across the whole dataset of captured transactions.

The arithmetic is confirmed by raw byte counts from on-chain data. 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 x H) equal to 162, 243 or 279, what’s up with that?
It is because 3 + (39 x 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 x R + (39 x H). It just so happens that for calldata sizes 81, 120 and 159, R = 1, but that’s not true for the other calldata values.
If you wonder where R comes from, it’s the outer loop in the pseudocode I pasted a few paragraphs above. For understanding its meaning, 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 how the bot works. It can’t see an opportunity coming, so it fires cheap probes and lets most of them miss, paying a sliver of gas each time while waiting for the rare one that lands on a real price dislocation.
A dislocation is just a price disagreement. 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, but it can’t 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 after the operations that built it settle.
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 x 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.
A concrete example
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 x 2) + (39 x 4) = 162 bytes, one of the straggler lengths from the chart above.
Bundling provides two major benefits for 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. If you want a cheap enough probe 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.
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 previous chart. 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 here we have a curious coincidence: the first byte of each “selector” seems to match the number of hops are encoded in the payload:
Now, what is actually encoded in the rest of the calldata? I pulled a lot of transactions 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 think the opcodes structure is really well commented). 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, for a function that was called right at the beginning of the arb evaluation hot-path, and received the full hop struct as input (called 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);
// determines how to build the call depending on which pool type is being queried
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.
When that byte’s value is
2, a couple ofstaticcallcalls are done to the UNISWAP_V4_POOL_MANAGER.If it’s
1, then the logic considers two possible scenarios: if it’s a Uniswap V3 pool, then theslot0()call will return a valid value. If not, then it’ll fallback to callingglobalState(), an Algebra-style pool. After that, both types of pool have aliquidity()function, so there’s no need to check again.If
v20 == 0(or like the decompiler put it,!v20) then it knows it’s dealing with a Uniswap V2-style pool, and callsgetReserves()on it.
I removed the code that actually builds the return struct for clarity purposes, 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 poolType.
HEADER
byte 0 hopCount how many hops follow
bytes 1–2 ???
HOP (39 bytes, repeated hopCount times)
byte 0 ???
byte 1 poolType 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. The section I’m using as example is the one that handles 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:
getReserves()hands back the pool’s two token balances,reserve0andreserve1. The price of one token in terms of the other is just their ratio.When
varg1 == 0, the function returnsreserve1 / reserve0.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 hopCount how many hops follow
bytes 1–2 ???
HOP (39 bytes, repeated hopCount times)
byte 0 direction which side of the pair is the input
byte 1 poolType which kind of AMM this is
bytes 2–4 ???
bytes 5–6 ???
bytes 7–38 ???What 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 only value in it that came from calldata is fee, and it sits exactly where a swap fee belongs, scaling the input down by (1e6 - fee) / 1e6.
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, but the fee enters all of them the same way. We are looking at the V2 branch only because it is the clearest place to watch the fee 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 poolType is the pool’s fee.
HEADER
byte 0 hopCount how many hops follow
bytes 1–2 ???
HOP (39 bytes, repeated hopCount times)
byte 0 direction which side of the pair is the input
byte 1 poolType 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.
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 liquiditySo 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 tickSpacing.
HEADER
byte 0 hopCount how many hops follow
bytes 1–2 ???
HOP (39 bytes, repeated hopCount times)
byte 0 direction which side of the pair is the input
byte 1 poolType 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 tickSpacing 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.
(These seven numbers are the 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 profitScale, 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 profitScale again there, and (hopefully) finally understand it.
HEADER
byte 0 hopCount how many hops follow
bytes 1–2 profitScale how much edge to demand before committing
HOP (39 bytes, repeated hopCount times)
byte 0 direction which side of the pair is the input
byte 1 poolType 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 tickSpacing the pool's tick spacing
bytes 7–38 ???The second question of the list at the beginning of the article is almost answered: while it seemed like random noise, the calldata did in fact have a hidden structure. Which brings us to the third and last question.
The addresses that led nowhere
Back at the very start, 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 JUMPThat 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, but 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
poolKey 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 : 0xc6962004f452be9203591991d15f6b388e09e8d0The 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 poolKey: 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 hopCount how many hops follow
bytes 1–2 profitScale how much edge to demand before committing
HOP (39 bytes, repeated hopCount times)
byte 0 direction which side of the pair is the input
byte 1 poolType 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 tickSpacing the pool's tick spacing
bytes 7–38 poolKey the pool, XOR-maskedA 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).
hopCount 0x0000000000000000000000000000000000000000000000000000000000000002
profitScale 0x0000000000000000000000000000000000000000000000000000000000002a1f
--- hop 1 ---
direction 0x0000000000000000000000000000000000000000000000000000000000000000
poolType 0x0000000000000000000000000000000000000000000000000000000000000001
fee 0x00000000000000000000000000000000000000000000000000000000000001f4 (500 = 0.05%)
tickSpacing 0x000000000000000000000000000000000000000000000000000000000000000a (10)
poolKey 0x000000000000000000000000c6962004f452be9203591991d15f6b388e09e8d0
--- hop 2 ---
direction 0x0000000000000000000000000000000000000000000000000000000000000001
poolType 0x0000000000000000000000000000000000000000000000000000000000000001
fee 0x0000000000000000000000000000000000000000000000000000000000000bb8 (3000 = 0.30%)
tickSpacing 0x000000000000000000000000000000000000000000000000000000000000003c (60)
poolKey 0x000000000000000000000000c473e2aee3441bf9240be85eb122abb059a3b57cThe 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:
0x022a1f00010001f4000a000000000000000000000000c6962004f452be9203591991d15f6b388e09e8d00101000bb8003c000000000000000000000000c473e2aee3441bf9240be85eb122abb059a3b57cEvery field is butted right up against the next one, no padding, in the exact layout we spent this whole part decoding:
02 hopCount = 2
2a1f profitScale = 10783
-- hop 1 --
00 direction = 0
01 poolType = 1 (Uniswap v3)
0001f4 fee = 500 (0.05%)
000a tickSpacing = 10
0000…c6962004…e09e8d0 poolKey (32 bytes)
-- hop 2 --
01 direction = 1
01 poolType = 1 (Uniswap v3)
000bb8 fee = 3000 (0.30%)
003c tickSpacing = 60
0000…c473e2ae…9a3b57c poolKey (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 poolKey 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 have not opened. 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.




