Calculus running on fumes
The faint sound of a money printer - Part 3 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.
A few things worth knowing before going in: the contract hunts for price discrepancies between trading venues. It has to decide in a fraction of a cent’s worth of computation whether one is worth chasing, and the machine it runs on (the EVM) has integer arithmetic and nothing else: no decimals, no floating point, no math library.
Earlier parts cover how the bot was found, how it behaves and how its instructions are encoded, but this one reads on its own.
Part 2 ended at a point where we had decoded the whole calldata grammar, every field of every hop, and I called it the menu on the wall: which pools to touch, which way to trade them, how much edge to demand. Then I said the menu was not the meal. Nothing in the calldata actually decides anything. It never says whether a loop turns a profit, how large a trade to push through it, or whether this candidate deserves a single unit of gas. Those are questions the contract answers on its own, thousands of times per burst, and I closed the part promising the kitchen was where the real work happened.
This is the kitchen.
I owe you two things from earlier parts, both of which get paid here.
In Part 2, I named a header field
profitScale. I said I had a pretty good idea of what its purpose was, but admittedly could not fully decipher and promised we would meet it again here.In Part 1, I claimed this whole strategy only works because the bot can say no to any given opportunity almost for free, but never showed you the machinery that makes saying no actually cheap.
Both are answered at the same place, a single internal routine the disassembly puts at offset 0x67e, the function every probe transaction actually runs. It is the longest and strangest thing in the contract and rather than read it top to bottom, we are going to do what we did with the calldata in Part 2, pull on one loose thread at a time and let the shape come apart in our hands.
The following flowchart shows what we know so far, the knowledge we’ve been able to gather from the previous parts of this series. The goal for today is to find out exactly what happens inside the route interpreter at 0x67e and a glimpse beyond.
The cheapest word in the contract: NO
I used cast run --debug to step into each instruction from a given probe transaction (one of many discussed in Part 1), and watched where the calldata pieces went. On a route that ends up not moving any tokens, the contract:
Reads two or three pool prices.
Multiplies a few numbers together.
Compares the result against something, and ends
No state changes, no swaps or token transfers. However, let’s take a mental note of this because it becomes relevant later: the transaction does not end with a REVERT, the “something went wrong, undo it all” exit you would expect from a failed attempt. It just stops as if it had succeeded.
So the overwhelmingly common path through this function is short: read a few prices, do a little arithmetic, stop. There’s something in there acting as a filter, and to understand the filter we have to start from the beginning, with reading the prices.
Turns out that is one of the busiest part of the whole cheap path, because reading a price is only simple if you always deal with a single kind of pool. This contract supports many.
Step 1 of 3: read the prices of every pool
Before the contract can decide anything, it needs to know what each pool in the route is charging right this instant. The catch is that “a pool” is not one thing. Back in Part 2 we found the poolType byte tucked into every hop, the single byte that labels a pool as Uniswap V2 style, V3 or Algebra style, or V4. That byte has to exist because these pool designs store their price in completely different shapes, and you cannot read one the way you read another. So the contract forks three ways on that byte, and each branch knows how to talk to exactly one family of pool. Let’s go over them one by one, starting from the simplest.
Uniswap V2
A Uniswap V2 pool is basically a jar holding two piles of tokens, say some USDC and some WETH. The pool’s whole design keeps those two piles balanced in value, so the price of one token in terms of the other falls straight out of the pile sizes. If the jar holds 4,000 USDC and 1 WETH, then one WETH is worth about 4,000 USDC, and that is the price.
To read it, the contract makes a single call, getReserves(), which hands back the two pile sizes, and divides one by the other. One call, one division. The only decision left is which way up to divide, “WETH per USDC” or “USDC per WETH”, and that is what the direction byte from Part 2 chooses. Here is the decompiled representation of that function:
function 0x104d(uint256 varg0, uint256 varg1) private {
v0 = varg0.getReserves().gas(msg.gas);
if (0 == varg1) {
return 10 ** 27 * MEM[32] / (MEM[0] * v0);
} else if (1 == varg1) {
return 10 ** 27 * (MEM[0] * v0) / MEM[32];
} else {
return 0;
}
}And this is its simplified equivalent.
// Read the price of a Uniswap V2 style pool.
// A V2 pool is two piles of tokens and the price is just their ratio.
function probeV2(address pool, uint8 direction) internal view returns (uint256) {
(uint256 reserve0, uint256 reserve1) = pool.getReserves();
if (direction == 0) return 1e27 * reserve1 / reserve0; // one way up
else return 1e27 * reserve0 / reserve1; // the other way up
}That 1e27 is only there to carry 27 digits of precision, so a small price like “0.0004 WETH per USDC” does not get crushed to zero by integer division. Every branch scales its answer to that same 27-decimal unit.
Uniswap V3 / Algebra
Now a harder family. A Uniswap V3 pool (and Algebra, which is a V3 fork, and V4) does not keep two neat piles. These pools let liquidity providers concentrate their money into narrow price bands instead of spreading it evenly, and once the money is scattered like that, there is no single pair of pile sizes to divide. What these pools store instead is one packed number that stands in for the current price.
function 0x1006(uint256 varg0, uint256 varg1) private {
v0 = v1 = varg0.slot0().gas(msg.gas);
if (!v1) {
v0 = v2 = varg0.globalState().gas(msg.gas);
}
v3 = 0;
if (!varg1) {
v3 = v4 = MEM[0] * v0 * 10 ** 18 >> 96;
} else if (1 == varg1) {
v3 = v5 = 0xde0b6b3a7640000000000000000000000000000 / (MEM[0] * v0);
}
return v3 * v3 / 10 ** 9;
}If you’re wondering what is that 0xde0b6b3a60000... constant, keep reading, we’ll get back to it in a minute
Reading the price takes more work, for two reasons: first, the number this family of pools store is not the price, but the square root of the price, kept in a fixed-point format called Q64.96 (picture a number with 96 binary digits living after the decimal point).
Uniswap tracks the square root rather than the price itself because it makes the pool’s per-trade math simpler: inside a price range the swap equations are linear in the square root of the price (check equation 6.7 in the Uniswap v3 Core whitepaper for a better explanation), so each step is a division instead of a fresh square root.
The catch for anyone reading from outside is that you have to square the number back and rescale it. This branch reads that one value, multiplies it by itself, and shifts it into the same 27-decimal unit the V2 branch produced.
Why V2 code scaled the returned values using 1027 but V3 doesn’t?
It’s not explicit, but it happens in two parts:
First, the square root gets a 1e18 scale on its way out of the Q64.96 format. Squaring the number squares its scale too, leaving the price padded up to 1e36.
The closing division by 109 trims that back down, and 1036 / 109 is exactly 1027. Same unit, just assembled from two constants instead of written as one, and you can spot both halves in the code. The
* 10 ** 18 >> 96
near the top, and the
/ 10 ** 9
on the final line.
The other reason why reading the price of a Uniswap V3-style pool takes more work than V2 is because the function you call to fetch that number has a different name depending on the pool.
A genuine Uniswap V3 pool hands it over through a function called slot0(). An Algebra pool, being a fork that renamed things, calls the very same idea globalState(). The contract does not bother figuring out which one it is looking at. It just calls slot0() first, and if that call comes back empty (because this pool has no such function), it falls back to globalState(). Try the standard name, and if the pool does not answer to it, try the fork’s name.
This is the human-readable equivalent of the decompiled function above:
// Read the price of a Uniswap V3 / Algebra pool.
// These pools store the SQUARE ROOT of the price (Uniswap's Q64.96 format),
// so we read that one number and square it back into a real price.
function probeV3(address pool, uint8 direction) internal view returns (uint256) {
uint256 sqrtPriceX96 = pool.slot0(); // Uniswap V3's name for it
if (sqrtPriceX96 == 0) {
sqrtPriceX96 = pool.globalState(); // Algebra's name for the same thing
}
// undo the square root and rescale, honoring the direction byte
uint256 root = (direction == 0)
? (sqrtPriceX96 * 1e18) >> 96 // sqrt(price), in 1e18 units
: (1e18 << 96) / sqrtPriceX96; // sqrt(1/price), in 1e18 units
// square it -> price, in 1e27 units
return root * root / 1e9;
}The mystery behind 0xde0b6b3a7640000000…
In the decompiled view we saw this mysterious constant, but there’s no such mystery really: it’s just 1e18 shifted up by 96 bits. The number one, in the contract’s usual 1e18 units, pushed into the same Q64.96 scale the sqrt-price lives in. It’s there because dividing it by the sqrt-price gives you the price the other way up, the reciprocal of what the direction == 0 branch computes.
Here’s another mystery for you. If this this is just 1e18 shifted up by 96 bits, then why would the decompiler show it as a constant instead of a far more readable 1e18 << 96?
My guess: the compiler folded it.
When every input to an expression is known ahead of time, the compiler can do the math once, at compile time, and bake the finished number in rather than recompute it on every call. It is a real optimization, it happens all the time, and the decompiled view sits there agreeing with the story.
So, just to sanity check my own deduction process, I went looking for the constant in the raw bytecode, just to see the fold with my own eyes, and turns out it wasn’t there.
No PUSH instruction in the entire contract carries 0xde0b6b3a7640000....
I traced the execution to the part where the constant should be, and what I found there instead was this:
PUSH6 0x03782dace9d9
PUSH1 0x72
SHLMy first instinct here was a paranoid one, because finding runtime arithmetic where a constant should be is pretty much the shape of the trick from Part 2, where the author hand-built the 0x4200... WETH address out of a few little pushes to keep a recognizable constant out of the bytecode. But it wasn’t the author in this case, it was the Solidity optimizer, which has a dedicated pass for precisely this, the constant optimizer.
The compiler’s own documentation gives it one line, in the optimizer settings of the input description: it “tries to find better representations of literal numbers and strings, that satisfy the size/cost trade-off determined by the ‘runs’ setting”. Its implementation weighs the candidate encodings for every large constant, among them: store the literal (twenty-one bytes of code, three gas to push) or rebuild it from smaller pieces (ten bytes of code here, at 9 gas per run), and the `runs` parameter is the thumb on that scale, telling the compiler how many times it should expect each opcode to execute over the contract’s lifetime.
The code in this contract seems to have been compiled using a setting that chose small code over fast. Push a six-byte number, push 114, shift left. That six-byte number is 518, and because 1e18 is 218 x 518 , shifting 518 left by 114 bits (which is 96 + 18) lands exactly on 1e18 << 96.
And 518 is not an arbitrary seed to have picked. Once you have committed to rebuilding the constant with a shift instead of storing it, any factor of two still sitting in the seed is wasted code, because the shift regenerates powers of two for free.
The cheapest number to push is therefore whatever remains after every two has been stripped out, the constant’s odd part, and for 1e18 that is exactly 518.
Six bytes where 1e18 itself would need eight, with the eighteen discarded factors paid back by shifting 114 instead of 96.
Both shift amounts still fit in a one-byte push, and SHL charges the same three gas whatever you hand it, so this seed is two bytes smaller than the obvious one at identical gas. The following table makes it clearer.
There’s a subtle irony here. The third row is what actually got into the bytecode of the contract. See how the compiler decided to optimize for size rather than runtime gas?
We can’t know for certain whether this was a deliberate compiler configuration by the authors, or just a distracted dev. What we can measure is the outcome.
The choice saved them around 2,000 gas of deployment costs, then spent about 25,000 gas across the 4,164 times this contract walked that path. Since the constant lives in the price-reading code, the surcharge lands before the profitability gate, which means it was paid on the thousands of probes that went nowhere just as much as on the handful that paid.
The devil is in the opcodes, indeed.
Uniswap V4
The V4 branch is where it gets interesting, because it does not look like the other two. Here is the decompiled function:
function 0xfbf(uint256 varg0, uint256 varg1) private {
v0 = UNISWAP_V4_POOL_MANAGER.staticcall(keccak256(varg0, 6), keccak256(varg0, 6), keccak256(varg0, 6)).gas(msg.gas);
v1 = 0;
if (!varg1) {
v1 = v2 = address(MEM[0x0]) * 10 ** 18 >> 96;
} else if (1 == varg1) {
v1 = v3 = 0xde0b6b3a7640000000000000000000000000000 / address(MEM[0x0]);
}
return v1 * v1 / 10 ** 9;
}Can you spot the biggest difference? The bottom half is identical to the V3 branch: the same * 10 ** 18 >> 96, the same reciprocal constant we just caught the decompiler inventing, the same square-and-rescale at the end.
Identical is not even the right word. In the bytecode, the V3 and V4 functions both end by jumping into one shared conversion routine, so their bottom halves are not twins, they are the same instructions, shown twice only because the decompiler inlines that routine into each caller.
But the top part is different. The other two branches called a named function on the pool, getReserves() or slot0(), and got a meaningful answer back. This one calls no named getter at all. It reaches for one fixed contract, the UNISWAP_V4_POOL_MANAGER, and hands it a raw keccak256 hash.
This is just the shape of Uniswap V4. V4 tore up the old design where every pool is its own little contract with its own slot0(). In V4 there are no pool contracts at all. Every pool that has ever existed lives as raw storage inside a single vault, the PoolManager, and the only way to read a pool’s price is to know exactly which storage slot that price is filed in.
You compute that slot yourself by hashing the pool’s key, and ask the vault to read the raw slot back to you through a function called extsload (an “external storage load”). That is what the keccak256(varg0, 6) is doing: turning the pool key into the storage slot. I know it looks like it, but it’s not a hack, it is the sanctioned path. Uniswap’s own StateLibrary reads a V4 price exactly this way, hashing the pool id into a slot and calling extsload.
So the three branches are not just three functions, they are three eras of Uniswap. The operator’s code speaks all three fluently. Once the price is in hand, though, the job is the same as the other two: square it, rescale it, honor the direction.
The clever thing about all 3 branches is that every one of them returns the same thing, a plain price scaled to 27 decimal places. Three totally different storage shapes go in, and the instant each branch returns, that difference is gone.
Everything downstream receives a clean, uniform number and never has to know which kind of pool produced it. The whole mess of “this chain has several incompatible pool designs” is sealed inside these three little functions, and nothing past them ever has to think about it again.
Step 2 of 3: multiply the prices around the loop
Now that it collected a clean price for every hop, the contract does the one thing that tells it whether the loop is even worth a second look: it multiplies them all together. The idea behind that is really old.
A circular trade is profitable exactly when the prices around the loop multiply to more than one.
Walk USDC into WETH on the first pool, then WETH back into USDC on the second. Each hop has a price, “how much of the next token you get per unit of this one.” Multiply the first hop’s price by the second’s. If the product comes out above one, the loop hands you back more than you put in, and there is real money on the table.
If it comes out below one, you would lose on the round trip no matter how much or how little you traded, and there is nothing left to think about. You do not need to know the trade size to know the sign of the opportunity. You just need the product of the prices.
Here is that multiplication, the inner loop, cleaned up from the decompiled source:
// chain each hop's price into one round-trip multiplier, in 1e27 fixed-point
uint256 priceProduct = 1e27;
for (uint i = 0; i < hopCount; i++) {
// the 3-way fork above
uint256 price = probePrice(pool[i], direction[i], poolType[i]);
// multiply in, rescale so it can't balloon
priceProduct = priceProduct * price / 1e27;
}probePrice here is just the three-way fork we spent the last few paragraphs on, picking the V2, V3 or V4 branch based on each hop’s poolType. The loop multiplies every hop’s price into a running total and rescales after each step so the number stays in range.
When it finishes, priceProduct is the round-trip multiplier for the whole cycle: start with one unit, walk it all the way around at current prices, and this is what you would end up holding. Above 1e27, the loop is uphill. Below it, downhill. So far, every bit of it came from a handful of cheap reads, without simulating any trade or moving any token.
One aside for the graph-theory crowd
The loops this contract checks do not come from nowhere. Every route arrives pre-chosen in the calldata, Part 2’s menu, which means that somewhere off-chain, before the transaction was ever built, something searched a graph of many different pools and picked these two or three cycles out of an enormous space of possible ones.
We cannot see that machine, but we can say what the textbook version of it looks like, because what I said a few lines above, “a cycle is profitable when the product of its edge rates is > 1” is the exact same statement as “a graph has a negative cycle under the weights -log(rate),” and the bridge between them is three lines of log rules:
Start from the profitable loop,
r1 × r2 × ... × rk > 1Take the logarithm of both sides: logarithms turn products into sums and keep inequalities pointing the same way, so this becomes
log(r1) + log(r2) + ... + log(rk) > 0Multiply by -1, which flips the inequality
The weights -log(r) around the loop now sum to less than zero. That is, by definition, a negative-weight cycle. The transform exists because shortest-path algorithms only understand sums, never products, and Bellman-Ford is the shortest-path algorithm that tolerates negative weights and reports negative cycles as a built-in side effect. This is not part of the Bellman-Ford algorithm itself, it is the reduction that feeds the algorithm.
If you’re curious, Cormen, Leiserson, Rivest and Stein pose it as Problem 24-3, “Arbitrage”, in Introduction to Algorithms 3rd edition. Sedgewick and Wayne close the shortest-paths chapter of Algorithms with it, stating in one line in section 4.4:
…to formulate the arbitrage problem as a negative-cycle detection problem, replace each weight by its logarithm, negated.
They even include a runnable Arbitrage.java
So that is where the menu probably comes from: some off-chain system running this kind of search over a big graph of markets and shipping the winners into calldata. Whether the operator’s finder is literally Bellman-Ford or some cousin of it, we cannot know from out here. What we can see is the division of labor it leaves behind.
Discovery is expensive, so it happens off-chain, against prices that are already going stale while the transaction is in flight. Verification is cheap, so it happens on-chain, at the last possible moment, against prices that are live. The multiplication loop in this section is that verification. The contract is handed a cycle something else already believed in, and all it keeps of the textbook apparatus is the final line: the sign of one product.
Step 3 of 3: comparison to threshold
This is the comparison the debugger stops at right before the transaction quits
// the go/no-go gate
// - profitScale = the two header bytes we couldn't quite crack from Part 2
// - delta / price = the round-trip surplus, expressed as a fraction of the position
bool worthAdvancing = priceProduct * 1e12 * profitScale / 10 < 1e18 * delta / price;Read it as a scale with a thumb on one side. The right side is the surplus the loop appears to offer, the amount by which the product beat one, written as a fraction of the position. The left side is the bar that surplus has to clear, and profitScale is the thumb pressing down on it.
Push profitScale up and the bar goes up, and fewer routes make it over. Push it down and more routes pass through into whatever lies beyond the gate. It is a per-route sensitivity knob: how much apparent edge do I insist on seeing from cheap spot prices before I am willing to spend real gas confirming it.
That is profitScale’s job, and it recasts the distribution I found in Part 2 (reprinted here, and, as flagged there, these particular numbers are consistent stand-ins for the operator’s real thresholds, the shape is what is real)
10783 -> 90.3% (nine routes out of ten)
29113 -> 5.5%
7243 -> 2.2%
...Nine routes out of ten carry the same value, the operator’s default bar. The rare high values, appearing in short isolated bursts, are exactly what pressing down the thumb on the scale looks like: the operator turning the filter stricter for a short experiment, then turning it back.
Every other calldata field we named in Part 2 was a fact about the world, which pool, which fee, which direction. profitScale is the one field that is a fact about the operator’s nerve, a dial for how sure they want to be before they spend.
I still cannot cross one line honestly, because here’s where my knowledge about this particular section stops. What I can prove is direction. profitScale enters the gate once, as a bare multiplier on one side of a single less-than, and making one side of a less-than bigger can only make it harder to pass.
So turning the number up can only shrink the set of routes that clear the gate, and turning it down can only grow it. That argument needs nothing but the shape of the code, and it would survive even if every unit in the inequality turned out to mean something different than I think. What I cannot tell you is by how much.
Imagine a thermostat dial with the markings scraped off. You can tell a higher reading means warmer, and a lower one means colder, but you can’t tell how many degrees one notch is worth. That is this field. I can tell you 29113 is stricter than 10783, and I can tell you which value is the operator’s default.
Is this impossible to know without the actual source code? I don’t know, probably not, but I decided to stop here because I don’t think the juice is worth the squeeze beyond this point.
Why does it use a STOP instead of a REVERT
When this function decides a route is not worth it, it does not revert, it just stops. Let’s have a quick refresher on the two ways a call can end.
A
REVERTis a panic button. It halts execution and rewinds every change the call made, as if the whole thing never happened. It is what you reach for when something has gone wrong and you would rather erase the attempt than commit a mistake (EIP-140 is the spec that pinned down exactly what a revert undoes).A
STOPis the opposite. It halts and keeps everything the call did, ending on a note of “done, this all counts.”
Now hold that next to two facts we already know about this bot.
From Part 2 of this series: a single transaction can carry several routes stacked one after another, a little menu of guesses.
From the gate we just walked through: when a route clears, the contract does not merely take a note of it, it goes on to actually execute that trade, and if the trade lands, the profit is now sitting in the contract’s own balance.
Can you see where this is going? Picture a transaction carrying three routes. The first one clears the gate, gets executed, and banks a small profit. The second and third do not clear. The function reaches its end. If it reverted here, the revert would rewind the entire transaction, and that includes the first route’s completed, profitable trade. It would be deleting money it had already earned. When it needs to stop, it must do so while still keeping whatever it made, even when it’s nothing at all.
The contract does contain REVERTs, a dozen of them, but none is a decision. They are all compiler boilerplate: the bare revert(0, 0) stub Solidity emits when incoming calldata is too malformed for the ABI decoder to make sense of, plus one shared handler that panics on arithmetic overflow. There’s nothing on the execution path that looks like a revert that means “this trade did not work out, undo it”. The bot never writes that line.
Every exit this contract controls for itself is a stop, because by the time the outer layer has finished walking the menu it may be holding a winner, and its one job is not to throw that away. The only thing that can unwind it is an outside protocol refusing to close the books.
This also settles a small debt from Part 1, where I called these empty transactions “reverts.” That was a shorthand from before I had read the code. Strictly they are clean stops, and now you can see the difference is not pedantry but the whole reason stacking several guesses into one transaction is safe to do.
So that is the entire cheap path, start to finish: read a few prices through whichever pool branch fits, multiply them into one round-trip number, weigh that number against profitScale, and almost always stop, having spent a fraction of a cent and kept whatever was already in hand.
We can now see, in full detail, what is happening inside that box from the first diagram at the beginning of the article.
That is the “no” the whole burst pattern is built on. But every so often the gate says yes, and the moment it does, a significantly more expensive and sophisticated piece of code comes alive.
Math with no opcode behind it
Let’s see what happens inside that box labeled ??? at the bottom of the diagram above. Once a probe clears the cheap profitability check, the function we just analyzed turns into something else entirely. Instead of stopping, it keeps going, and the operations it starts running are ones the EVM was just never conceived to do.
The constant lineup
After the profitability check passes, the function code seems to hide something, the same way it hid the pool addresses in Part 2: in plain sight, as constants.
Let me line a few up, pulled straight out of the bytecode, and let you play the same game I played with the 0x4200...0006 address, where an innocent-looking value turned out to be pure arithmetic in disguise.
# List of constants hardcoded in the code that is executed
# after the profitability gate was cleared.
0x0DE0B6B3A7640000
0xFFF97272373D413259A46990580E213A
0xFFF2E50F5F656932EF12357CF3C7FDCC
0xFFE5CACA7E10E4E61C3624EAA0941CD0
... 16 more like these above ...
0x099E8DB03256CE80
0x14057B7EF7678100The first, 0x0DE0B6B3A7640000, is 1000000000000000000. That is 1e18, “one” in eighteen-decimal fixed point, the unit this whole contract counts in.
The second, 0xFFF97272373D413259A46990580E213A, and the ladder of cousins that follow it, you can just find the answer using Google in about five seconds. They are the magic numbers from Uniswap V3’s TickMath, the precomputed constants that turn a tick index into a square-root price, and the whole ladder is here, all nineteen multipliers, sitting in the bytecode as literal pushes, one after another.
Where do these numbers come from? Uniswap V3 defines a tick as a price step of one basis point: moving up one tick multiplies the price by 1.0001 (section 6.1 of the whitepaper). So pricing tick 76,013 means raising 1.0001 to the 76,013th power, and the EVM cannot do that: its EXP opcode only works on whole numbers, and 1.0001 is not one. Uniswap’s answer is the one humans used for the three and a half centuries between Napier’s log tables and the pocket calculator: precompute a table.
Each rung of the ladder is 1/1.0001 raised to a power of two and doubling all the way up to 262,144, written out to 128 binary digits (you can check this yourself: divide any rung by 2128 and compare). To price any tick, write the tick in binary and multiply together the rungs matching its ones. Nineteen rungs cover every legal tick.
That origin also explains why the digits look like noise, and why the constant optimizer left these alone after so eagerly rebuilding 1e18 << 96 from a six-byte seed. Nobody chose these digits.
Each constant is simply the first 128 bits of whatever 1/1.000132 happens to look like in binary, and an expansion like that has no pattern to exploit, no small seed and a shift that lands on it. When a number has no structure, the cheapest encoding of the number is the number itself, so the compiler paid the full literal push nineteen times.
Public, borrowed, copied out of open source. Their presence tells us the contract walks ticks the way Uniswap does, which we already suspected. Also not a secret.
What about the last two constants?
These deserve a sub-section of their own
0x099E8DB03256CE800x14057B7EF7678100
Paste either into a block explorer or a search engine and you get nothing. No contract, no token, no known constant. They look, at a glance, exactly like the kind of high-entropy garbage we spent all of Part 2 learning to distrust. For these two, the revelation came from the code wrapped around them, and the constants only made sense afterwards.
Past the profitability check gate, the decompiled output collapses into a wall of arithmetic. Here is one line of it, verbatim except for the line breaks
v27 = v60 * 0x99e8db03256ce80
+ ((v62 - 10 ** 18) * (v62 - 10 ** 18) / 10 ** 18 * (v62 - 10 ** 18) / 10 ** 18 / 3
+ (v62 - 10 ** 18 - ((v62 - 10 ** 18) * (v62 - 10 ** 18) / 10 ** 18 >> 1))
- ((v62 - 10 ** 18) * (v62 - 10 ** 18) / 10 ** 18 * (v62 - 10 ** 18) / 10 ** 18 * (v62 - 10 ** 18) / 10 ** 18 >> 2));I know it looks hopeless, but it also seem to have some duplication. With a little patience we can reduce it to something smaller in three mechanical steps.
Step one: name the thing that repeats. The subexpression v62 - 10 ** 18 appears ten times in that one line. Call it u and the wall shrinks to
v27 = v60 * 0x099E8DB03256CE80
+ (u * u / 10 ** 18 * u / 10 ** 18 / 3
+ (u - (u * u / 10 ** 18 >> 1))
- (u * u / 10 ** 18 * u / 10 ** 18 * u / 10 ** 18 >> 2));Step two: turn the shifts back into divisions. A right shift by one (>> 1) is a division by 2, and a right shift by two is a division by 4. Now it looks like this
v27 = v60 * 0x099E8DB03256CE80
+ (u * u / 10 ** 18 * u / 10 ** 18 / 3
+ (u - u * u / 10 ** 18 / 2)
- u * u / 10 ** 18 * u / 10 ** 18 * u / 10 ** 18 / 4);Step three: erase the fixed-point bookkeeping. Every one of those / 10 ** 18 exists for the same boring reason: multiplying two numbers that each carry eighteen decimal places of padding gives a number carrying thirty-six, so the code trims the padding back down after every multiply. That’s just for scaffolding purposes, not math-related. Read u as a plain real number, drop the scaffolding, and sort the terms by power. What remains is one line
Sounds familiar? It’s because u − u²/2 + u³/3 − u⁴/4 is the Taylor series expansion (specifically, a Maclaurin series) of ln(1+u). The wall of arithmetic is the opening of a logarithm calculation.
The second wall of arithmetic, a few lines further down, reduces the same way. Its repeated lump involves both mystery constants at once, 0x099E8DB03256CE80 and 0x14057B7EF7678100.
I’m not pasting the 3-steps refactor again, but if we do the same variable replacement, naming it r and stripping the same scaffolding, what remains is 1 + r + r²/2 + r³/6 + r⁴/24, capped by a shift left by a computed amount. This is the Taylor series expansion of er, a strong indication someone built a logarithm and an exponential in here, by hand.
The EVM has no logarithm opcode and no floating point. All the magic happens around a single data type, a 256-bit integer. Every node on the network re-executes every transaction and has to land on the same bits, and floating point can’t promise that. Results drift with register width, with the order a compiler happens to pick, with whose libm rounded the last digit. Integers agree everywhere, so the chain gives up the entire numeric tower above them and buys reproducibility instead.
And yet the moment a proposed arb route clears the gate, this contract starts computing logarithms and exponentials, in the hot path, on a gas budget measured in fractions of a cent.
Now here’s the interesting part. Building ln and exp this way forces two specific numbers to exist in the code. A truncated series like u − u²/2 + u³/3 − u⁴/4 is only accurate when u is tiny, a number flirting with zero. In other words, if it was just the series, the contract would only be able to accurately compute ln or exp for only small inputs, which doesn’t sound too useful. So the series alone cannot be the whole function.
There has to be a preliminary step that drags an arbitrary input into the tiny window where the series is accurate. In principle any repeated division would do the dragging, but on binary hardware halving is the one choice that stays cheap in both directions, because a power of two is the only factor integer machinery handles without real work.
Getting back out costs almost nothing either: on the exponential side it is a single shift left, and on the logarithm side it is one multiply and one add, because logarithms turn all of those halvings into a sum. So the code halves, shifts right, again and again, counting the halvings as it goes.
The catch is that every halving changes the answer you are trying to compute, and by the same amount every time. For example ln(x/2) = ln(x) − ln(2), so each halving leaves a debt of exactly ln(2). Halve the input k times and you owe k × ln(2), to be added back at the end. Which means the code must carry the number ln(2) on board.
It is the exchange rate between the operation the hardware provides for free and the function you are trying to compute. And look back at the reduced line: v60 × C, some running count (v60) times the mystery constant, added to the series. That is exactly the shape of such counter operation being unrolled.
The exponential runs the same scheme in reverse. Its series 1 + r + r²/2 + ... is only honest for a tiny r, so a big exponent has to be split into “a pile of doublings plus a small leftover,” and to count how many doublings fit inside x you divide x by ln(2). Division being division, the code multiplies by the reciprocal instead, and the reciprocal of ln(2) is also a number we can easily represent: log2(e).
We’re now in a position to make the following hypothesis: if the wall of arithmetic really is what it looks like, the two unexplained constants sitting in the middle of it must be exactly ln(2) and log2(e), written in the contract’s usual 1e18 fixed point. Convert to decimal and divide them by 1e18
ln(2) and log2(e), the two constants you need, and essentially the only two you need, to build a logarithm and an exponential out of integer shifts and a short polynomial.
This whole technique straddles two worlds: base e, where the math lives, and base 2, where the hardware lives, since the one thing integer machinery multiplies by for free is a power of two. Moving between those worlds costs one constant in each direction. ln(2) converts a count of doublings into natural-log units. log2(e) converts a natural-log quantity into a count of doublings (by the change-of-base rule it equals 1/ln(2), the same number wearing its other name).
And since translating there and back has to be a no-op, the two constants must be exact reciprocals of each other. The bytecode agrees: multiply the two raw values together and you get 0.999999999999999995 × 1e36, which is one, in squared 1e18 fixed point, off by a rounding crumb in the last digits. Two supposedly random numbers do not multiply to one by accident. They were born as a pair, and the code that follows uses them as one: each translates the other’s output back.
The shape of the code unveiled the algorithm, and the constants confirmed it. The technique they belong to is called range reduction (libm engineers call it argument reduction). This is how real math libraries have always been built. See for example fdlibm’s e_exp.c, Sun’s reference math library from the 1990s whose descendants still sit inside practically every libc, and the comment at the top describes our wall of arithmetic almost word for word: “find r and integer k such that x = k*ln2 + r”, then “scale back to obtain exp(x): exp(x) = 2^k * exp(r)”. Its logarithm opens the same way, “find k and f such that x = 2^k * (1+f)”.
A production libm squeezes more accuracy out of fewer terms than the plain truncated series we found here, and the trick is in the coefficients: a Taylor cut fits perfectly at one point and drifts as you leave it, so libm builders nudge every coefficient slightly off the textbook values (using the Remez algorithm, which both of the above linked files mention by name) until the error spreads evenly across the whole interval, buying dozens of extra bits of accuracy from the same handful of multiplies.
The author of this bot did not bother, and we should be glad they didn’t because nudged coefficients would have been more anonymous constants, and it was the exact textbook divisors, the 2, 3, 4 of the logarithm and the 2, 6, 24 of the exponential, that made it easier for us to figure out what was going on.
In any case, the one-line version is all we need to move to the next section, just squeeze the input into a small interval where a short polynomial is accurate, and pay for the squeezing with ln(2) and a shift on the way in and out.
Building ln and exp from shifts and a short polynomial
I know the past few sections were math-heavy, but bear with me just a little longer because we’re really close to finally cracking this part of the code.
Computing ln(x) directly for an arbitrary large x is hard. But ln has a property you can exploit: ln(x) for x near 1 is easy, a few terms of a simple series get you there, so you reduce the range. Write any positive x as a power of two times a leftover, x = 2k · m, where m has been squeezed into the interval [1, 2). Then ln(x) = k · ln(2) + ln(m). The k · ln(2) part is one multiply by that first mystery constant. The ln(m) part is now a number near 1, where a short polynomial nails it. Here’s the pseudocode of how such a function would look like
// ln(x), where x is a plain integer, and the result is in 1e18 fixed-point.
function ln(uint256 x) internal pure returns (int256) {
// 1. lift x into fixed point, then range-reduce: halve until the
// mantissa lands in [1, 2]
uint256 m = x * 1e18;
uint256 k = 0;
while (m / 2 > 1e18) { k += 1; m /= 2; }
// 2. m is the mantissa now; let u = m - 1, a small number near 0
int256 u = int256(m) - 1e18;
// 3. ln(1 + u) as a short series: u - u^2/2 + u^3/3 - u^4/4
int256 ln_m = u
- (u * u / 1e18) / 2
+ (u * u / 1e18 * u / 1e18) / 3
- (u * u / 1e18 * u / 1e18 * u / 1e18) / 4;
// 4. reassemble: ln(x) = k*ln(2) + ln(m)
return int256(k) * 0x099E8DB03256CE80 + ln_m; // <- ln(2) in 1e18
}The while loop is the range reduction, halving x and counting the halvings into k. The series is the Mercator series for ln(1+u), truncated after four terms because u is small enough that the rest does not matter at this precision. The last line glues the two halves together with the ln(2) constant. A halving loop, a handful of multiplications, one polynomial. That is a logarithm.
The exponential is the mirror image, and it uses the second constant. To compute exp(x), you want 2something · exp(small remainder), because multiplying by a power of two is a bit shift and costs nothing. So you set k = floor(x · log2(e)), the whole number of factors of two hiding in exp(x), leave a small remainder r = x - k · ln(2), expand exp(r) as a short Taylor series, and shift the result left by k.
// exp(x), x and result in 1e18 fixed-point.
function exp(int256 x) internal pure returns (uint256) {
// 1. range-reduce: exp(x) = 2^k * exp(r), with k an integer and r small
int256 k = x * 0x14057B7EF7678100 / 1e18 / 1e18; // <- log2(e); k = floor(x/ln2)
int256 r = x - k * 0x099E8DB03256CE80; // <- remainder, using ln(2)
// 2. exp(r) as a short series: 1 + r + r^2/2 + r^3/6 + r^4/24
uint256 exp_r = uint256(
1e18 + r
+ (r * r / 1e18) / 2
+ (r * r / 1e18 * r / 1e18) / 6
+ (r * r / 1e18 * r / 1e18 * r / 1e18) / 24
);
// 3. multiply the 2^k back in, for free, as a shift
return exp_r << uint256(k);
}Same skeleton, run backwards. log2(e) finds the power of two, ln(2) computes the remainder, a short series handles what is left, and a shift reassembles the whole.
If you compare the two functions closely, two asymmetries jump out. First, where did exp’s reduction loop go? ln needed one because its k is the input’s bit-length, how many halvings until the mantissa fits inside [1, 2), and no amount of adding, multiplying, or dividing a number tells you how many bits long it is.
The code has no choice but to discover k by trying: halve, count, repeat. In exp the roles flip. Its k is proportional to the input’s value, just x divided by ln(2), and that is closed-form arithmetic, one multiply by the reciprocal the contract already carries as log2(e).
The un-reduction collapses too, because multiplying 2k back in is a single SHL, and the EVM shifts by any amount in one opcode. The loop did not disappear, it got absorbed into arithmetic on the way in and one shift on the way out.
Second, k’s line divides by 1e18 twice, when every other multiply in this contract divides once. The single division is the usual fixed-point cleanup, two padded numbers multiply into double padding and one division brings the result back down to padded form.
But k can’t stay padded. It is a bare count of doublings, about to be used as a raw shift amount, and shifting by “56 followed by eighteen decimal places” is nonsense. So the code strips both layers, the constant’s and x’s, and what is left is a plain integer.
One detail follows from that line being a truncating division: k is a floor, not a rounding, so the remainder r lands anywhere in [0, ln 2) rather than snug around zero. Four series terms still hold the error under a tenth of a percent across that whole window, which, as we are about to see, is the same precision everything downstream settles for anyway.
These tidy little representations of these functions are my own doing, nothing suggests they’re laid out like that. Dedaub’s decompiled view shows no ln or exp, only that endless wall of arithmetic smeared across its callers, the same subexpression repeated a dozen times.
But if we go down one more level, into the disassembly, we can clearly see the functions are right there. Two of them, each a real routine with its own address, called the ordinary way. Jump destination 0x2a62 opens by PUSHing ln(2) and 1e18, runs a halving loop and the 2, 3, 4 series: that is the logarithm, and it is called exactly twice. 0x1c21 opens by pushing 1e18, ln(2) and log2(e), runs the 2, 6, 24 series and ends on a shift left: that is the exponential, and it is called from three different places. Two functions, five call sites, all visible in the raw opcodes.
Here is that exponential routine, disassembled straight from the deployed bytecode at offset 0x1c21. I’ve only added comments at the landmarks, so you can watch the exp() above map onto it opcode for opcode.
0x1c21 JUMPDEST
0x1c22 PUSH8 0x0de0b6b3a7640000 ; 1e18, the fixed-point “one”
0x1c2b SWAP1
0x1c2c PUSH8 0x099e8db03256ce80 ; ln(2)
0x1c35 SWAP1
0x1c36 DUP3
0x1c37 DUP1
0x1c38 PUSH8 0x14057b7ef7678100 ; log2(e)
0x1c41 DUP4
0x1c42 MUL
0x1c43 DIV
0x1c44 DIV ; k = x * log2(e), both 1e18 paddings stripped by the two DIVs
0x1c45 SWAP2
0x1c46 DUP3
0x1c47 MUL
0x1c48 SWAP1
0x1c49 SUB ; r = x - k*ln(2), the small remainder
0x1c4a PUSH1 0x18 ; 24 -> divide the 4th series term (r^4/24)
0x1c4c DUP4
0x1c4d DUP3
0x1c4e DUP1
0x1c4f MUL
0x1c50 DIV
0x1c51 PUSH1 0x06 ; 6 -> r^3/6
0x1c53 DUP6
0x1c54 DUP5
0x1c55 DUP4
0x1c56 MUL
0x1c57 DIV
0x1c58 SWAP2
0x1c59 PUSH1 0x02 ; 2 -> r^2/2
0x1c5b DUP8
0x1c5c DUP7
0x1c5d DUP6
0x1c5e MUL
0x1c5f DIV
0x1c60 SWAP6
0x1c61 DUP9
0x1c62 ADD ; sum the terms: 1 + r + r^2/2 + r^3/6 + r^4/24
0x1c63 SWAP2
0x1c64 DIV
0x1c65 ADD
0x1c66 SWAP2
0x1c67 DIV
0x1c68 ADD
0x1c69 SWAP2
0x1c6a DIV
0x1c6b ADD
0x1c6c SWAP1
0x1c6d SHL ; << k, multiply the 2^k back in for freeThe DUPs and SWAPs are just the stack shuffling any stack machine does to keep its operands in reach, the plumbing between the real work. Strip them out and what is left is exactly the four lines of exp() above: build k, subtract off k · ln(2) to get r, sum the little factorial series 1 + r + r²/2 + r³/6 + r⁴/24, then shift left by k.
The three constants we spent this whole section identifying are the first three pushes. The factorial denominators, 0x18 is 24 and 0x06 is 6, are sitting in the code as plain small numbers. Nothing here is folded, hidden, or inferred. It is a hand-written exponential in the open, once you know to read past the plumbing.
So the wall in the decompiled output was never how this contract is written. It is the decompiler inlining both routines into every caller and showing you the paste. Which makes this the third time in one article that the pretty view has lied to us: it folded a constant that does not exist in the bytecode, it duplicated a shared conversion tail into two functions, and now it has dissolved two real functions into noise.
So that is what’s sitting in there. A contract deployed by someone who cares about spending as little gas as possible, carries its own natural logarithm and its own exponential, hand-built out of bit-shifts and a four-term polynomial, with the two range-reduction constants that every serious math library has carried since the 1990s pushed into the bytecode as literals.
Not imported, because there is nothing to import from. Written by hand, in integer arithmetic, by a person who worked out the range reduction and the series terms and the exchange rate between base two and base e, and then wired the result into the path a transaction takes once it has decided real money is about to move.
With what we’ve seen so far, we can’t tell why any of it is there.
Let’s go back over everything we’ve established about what this bot does. It wakes up, fires cheap probes, reads a few pool prices, multiplies them together, and holds the product up against a threshold. After that point, we only have what we’ve watched it do on Arbiscan: borrow a token, swap it through two or three pools, repay the loan, and keep whatever is left over.
Borrowing, swapping, repaying: none of it needs anything more exotic than multiplication and division.
Somebody paid in engineering effort, and they paid in gas, to add hand-rolled ln() and exp() functions on a contract whose entire economic model is refusing to spend gas, and they put the bill behind a gate that only opens when an opportunity is real.
What does a blind arbitrage bot need with a logarithm?





