First, I want you to read through this excellent article from EigenPhi describing a bait attack on a selection of MEV bots.
Here is a summary of the attack steps:
Exploiter creates a token with a customized
transfer
function that can send targeted calldata when certain conditions are metExploiter broadcasts a “fishing” transaction that creates an opportunity for an MEV bot, along with a special-purpose contract that primes the
transfer
function to behave differently when called by that particular bot’s address.The bot finds the opportunity and builds a transaction that swaps WETH → SUS through a V2 pool, then swaps SUS → WETH through a V3 pool, profiting from the difference.
The bot simulates this transaction locally and decides it is valid and profitable. The bot broadcasts it, and the attack is carried out in the confirmed block.
Inside the MEV bot contract’s V3 callback, the customized
transfer
function executes, delivering specialized calldata to the bot contract through thedata
argument touniswapV3SwapCallback
. Thisdata
was decoded and processed without any further checks, which is where the WETH and USDT were transferred away to the exploiter.
It’s a dark forest out there, so you have to think deeply about things like this.
The article recommends two ways to protect your bot:
Set a requirement for
tx.origin = owner
in the callbackCheck for balance changes in externally-callable functions
Checking tx.origin — A False Sense of Security
Using tx.origin
as a protective mechanism has been covered extensively:
https://neptunemutual.com/blog/issues-with-authorization-using-txorigin/
https://solidity-by-example.org/hacks/phishing-with-tx-origin/
In essence, tx.origin
always resolves to an address of the externally-owned account (EOA) that originated the transaction.
Since smart contracts cannot broadcast their own transactions, tx.origin
can only be an EOA. Requiring tx.origin == msg.sender
is therefore a simple method that some contracts use to prohibit external smart contracts from interacting.
Using tx.origin
as the only method of protection is foolish and unsafe, because you or your bot can be tricked into executing a transaction that harms you. The check will pass since you broadcast the transaction, and therefore it only prevents unauthorized users from calling the contract on their own.
The blanket advice “don’t rely on tx.origin
” is dangerous as well, for a simple reason: sometimes you want your contract to be externally-callable by addresses other than yours. That’s the case with uniswapV3SwapCallback
, where the V3 pool contract itself calls your contract after transferring the output of your swap and expecting payment. In this case, the calling V3 pool’s address will be msg.sender
, and you have no information about where the transaction came from unless you utilize tx.origin
.
Ironically, a tx.origin
check would not have prevented this botter from being exploited, since the poisoned transfer
function was triggered by their own transaction.
However, inside of a V3 callback where msg.sender
is expected to be a non-owner address, we can use tx.origin
to exclude others who might try to trigger our callback outside of the usual methods.
Balance Checks — Number Go Up Protection
I’ve shared several contracts that perform generalized payload execution. It’s become apparent from helping several users in Discord that the partial swap issue is continuing to cause heartburn.
The design of the V3 pool contract is that it performs “best effort” swaps. It expects you to call swap
with a direction (zeroForOne
= True
or False
), an amount (amountSpecified
> 0 for an exact input, < 0 for an exact output), and a price limit (4295128739 < sqrtPriceLimitX96
< 1461446703485210103287273052203988822378723970342).
It will attempt to execute the swap as far as possible until the amount or the price limit has been reached. You cannot omit either of these values!
When I was developing the UniswapLpCycle
helper, I tested it using pools with V2 and V3 pools holding well-known tokens.
However, a side effect of low-liquidity pools is that they often hit the price limit before they consume or deliver the requested amount. That’s fine during normal simulation, because the net result is an unprofitable swap that the bot will ignore.
However, what if the pool is profitable at the time of simulation, but the price changes drastically between the current block and your TX? Your arb will still execute, but if it uses through a very low liquidity pool that was just “ran through” by a fellow Degen Code reader running the same bot, you’ll be stuck holding a partial balance.
This can also occur if the network is under heavy load and latency is high. Some readers have sent me transactions with as much as a half-minute delay, after which the opportunity had long since disappeared and resulted in another partial swap and frustration.
Executor Contract — Updated with Balance Check
The Uniswap V3 routers (there are two, remember?) implement a post-swap check to prevent this partial execution from occuring and souring the user experience.
The time has come to implement this balance check safeguard in the contract. It has some drawbacks (increased gas usage), but not having to clean up after partial swaps is probably worth the hassle.