The JSON-RPC method eth_call
(documentation HERE) is quite powerful, but rarely discussed. We will explore a very useful way to use eth_call
here.
Before continuing, I recommend watching this video from notable MEV senpai libevm:
The video will not make you an eth_call
expert, but it does give a very nice overview of why eth_call
exists and what you can do with it.
For background, I’ve been extending my payload executor / Flashbots project to support multi-pool (triangle) arbitrage. As a result, I’ve encountered a lot of really bizarre ERC-20 tokens.
The issue with these bullshit ERC-20 tokens is that they often implement fees on transfer, have white/grey/blacklists that restrict transfers to and from certain address, and can be arbitrarily paused. At first glance this seems OK, since I’m doing atomic arbitrage and do not intend to hold KEANUINUDOGE long-term, but there’s a more subtle issue.
Often these arbitrage pathways look very profitable (calculated on good faith), but revert when simulated. My bot has no sense of a token’s “legitimacy” when comparing arbitrage pathways, and it cannot reasonably determine whether a potential arbitrage path is likely to fail.
I can’t hand-craft every arbitrage path or whitelist every token input. I don’t have the time to pick through tens of thousands of token contracts on Etherscan, and neither do you.
What’s the solution? My brothers in Christ, it’s eth_call
!
Simulating Token Transfers
To begin, let’s take an example of a bullshit ERC-20 token: NICE. From the contract comments:
// SushiToken with Governance.
contract NiceToken is ERC20("NiceToken", "NICE"), Ownable {
// START OF NICE SPECIFIC CODE
// NICE is a copy of SUSHI https://etherscan.io/token/0x6b3595068778dd592e39a122f4f5a5cf09c90fe2
// except for the following code, which implements
// a burn percent on each transfer. The burn percent (burnDivisor)
// is set periodically and automatically by the
// contract owner (PoliceChief contract) to make sure
// NICE total supply remains pegged between 69 and 420
// It also fixes the governance move delegate bug
// https://medium.com/bulldax-finance/sushiswap-delegation-double-spending-bug-5adcc7b3830f
Some joker has decided that the SUSHI token needed to be forked with a burn method that pegs the supply between 69 and 420. That’s kind of funny but my bot is very serious and has a poor sense of humor and yells a lot.
Here’s the transfer function that burns the fee portion prior to sending the remainder:
function transfer(address recipient, uint256 amount) public virtual override returns (bool) {
// calculate burn amount
uint256 burnAmount = amount.div(burnDivisor);
// burn burn amount
burn(msg.sender, burnAmount);
// fix governance delegate bug
_moveDelegates(_delegates[msg.sender], _delegates[recipient], amount.sub(burnAmount));
// transfer amount minus burn amount
return super.transfer(recipient, amount.sub(burnAmount));
}
// we need to implement our own burn function similar to
// sushi's mint function in order to call _moveDelegates
// and to keep track of totalSupplyBurned
function burn(address account, uint256 amount) private {
_burn(account, amount);
// keep track of total supply burned
totalSupplyBurned = totalSupplyBurned.add(amount);
// fix governance delegate bug
_moveDelegates(_delegates[account], address(0), amount);
}
My options:
Implement a NICE-specific method that queries the contract for the current burn rate, recalculate the true swap amounts, and re-evaluate the arbitrage opportunity (terrible waste of time)
Blacklist the token entirely (efficient waste of time)
Test the token transfer using
eth_call
and eliminate it if any fuckery is detected (gigabrain move)
Let’s test the token on the console, then review how we might accomplish this in an automatic way. Start a Brownie console connected to Ethereum mainnet (either fork or live, it does not matter):
>>> token = Contract.from_explorer('0x53F64bE99Da00fec224EAf9f8ce2012149D2FC88')
Fetching source of 0x53F64bE99Da00fec224EAf9f8ce2012149D2FC88 from api.etherscan.io...
Now let’s run a very simple test of eth_call
using Brownie’s built-in call()
method on the transfer()
function call. First generate a fake account, then attempt to transfer 100 NICE tokens from the official contract to the new account.
>>> account = accounts.add()
mnemonic: 'tunnel salad direct disease disease educate voyage slab cricket unable hip winner'
>>> token.transfer.call(account, 100, {'from':token.address})
True
Cool, that worked. But the problem is that the only information we know is that the transaction would have succeeded. It does not tell us how many tokens were actually transferred, and there’s no clear way to tell without running this on a fork.
Let’s do that just for educational purposes, so you can see how this token actually operates:
(.venv) devil@hades:~/bots$ brownie console --network mainnet-fork
Brownie v1.19.1 - Python development framework for Ethereum
BotsProject is the active project.
Launching 'ganache-cli --chain.vmErrorsOnRPCResponse true --wallet.totalAccounts 10 --hardfork istanbul --fork.url https://rpc.ankr.com/eth --miner.blockGasLimit 12000000 --wallet.mnemonic brownie --server.port 6969 --chain.chainId 1'...
Brownie environment is ready.
>>> token = Contract.from_explorer('0x53F64bE99Da00fec224EAf9f8ce2012149D2FC88')
Fetching source of 0x53F64bE99Da00fec224EAf9f8ce2012149D2FC88 from api.etherscan.io...
>>> account = accounts.add()
mnemonic: 'dog bulb screen idea salmon happy inside erupt message sniff notice image'
>>> tx = token.transfer(account, 100, {'from':token.address})
Transaction sent: 0x588fa08d32a11534ea323f1009c11c44e43ab566ae58379d5b56050811b3c04c
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 1
NiceToken.transfer confirmed Block: 15447914 Gas used: 71740 (0.60%)
>>> tx.info()
Transaction was Mined
---------------------
Tx Hash: 0x588fa08d32a11534ea323f1009c11c44e43ab566ae58379d5b56050811b3c04c
From: 0x53F64bE99Da00fec224EAf9f8ce2012149D2FC88
To: 0x53F64bE99Da00fec224EAf9f8ce2012149D2FC88
Value: 0
Function: NiceToken.transfer
Block: 15447914
Gas Used: 71740 / 12000000 (0.6%)
Events In This Transaction
--------------------------
└── NiceToken (0x53F64bE99Da00fec224EAf9f8ce2012149D2FC88)
├── Transfer
│ ├── from: 0x53F64bE99Da00fec224EAf9f8ce2012149D2FC88
│ ├── to: 0x0000000000000000000000000000000000000000
│ └── value: 10
└── Transfer
├── from: 0x53F64bE99Da00fec224EAf9f8ce2012149D2FC88
├── to: 0x7761270B4dd10cfac3E813bb47a635e68E941581
└── value: 90
>>> token.balanceOf(account)
90
It’s clear that this is a fee/tax token, and you can see 10% of the transfer being sent to the zero address. Send 100, receive 90. Ultrasound money!
Live Testing
The issue with this approach is that every time you want to test a token, it requires an isolated process and the use of a local fork. This can be automated, but it’s quite slow.
Lucky for us, eth_call
allows us to do on-demand testing of tokens against a live network!
Unlucky for us, it’s really complicated…
To get the hang of using eth_call
to its full potential, I’ll layer the complication on in small steps.
We used the native Brownie method .call()
above to simulate a state-changing transaction. Brownie’s .call() implementation is a simple wrapper over the web3py Eth.call method, so we can treat them as functional equivalents. The major difference is that web3py’s implementation allows you to submit an eth_call
to an arbitrary address, where Brownie’s wrapper only runs off a contract object.
The web3py method requires one argument, transaction
, which is a dictionary of relevant transaction parameters. The required parameters are:
from
to
data
The optional parameters are:
gas
gasPrice
(for type 0 transactions)maxFeePerGas
/maxPriorityFeePerGas
(for type 2 transactions)value
nonce
Transaction Example
We will build a transaction dictionary with all necessary parameters to access the transfer()
method on the NICE contract.
We will build the transaction dictionary using web3py. The data
parameter can be built three ways:
1. Brownie encode_input
>>> nice.transfer.encode_input(account, 100)
'0xa9059cbb000000000000000000000000071437d6919f75c7d40363aacd26ac7df39c71fe0000000000000000000000000000000000000000000000000000000000000064'
Brownie’s encode_input
method allows you to generate calldata for a particular function from a contract object.
2. Web3.py build_transaction
>>> import web3
>>> w3 = web3.Web3(web3.WebsocketProvider())
>>> w3.eth.contract(
address=nice.address,abi=nice.abi
)
.functions.transfer(account.address,100)
.build_transaction({'gas':1_000_000})
{
'chainId': 1,
'data': "0xa9059cbb000000000000000000000000071437d6919f75c7d40363aacd26ac7df39c71fe0000000000000000000000000000000000000000000000000000000000000064",
'gas': 1000000,
'maxFeePerGas': 27474079452,
'maxPriorityFeePerGas': 1000000000,
'to': "0x53F64bE99Da00fec224EAf9f8ce2012149D2FC88",
'value': 0
}
3. ABI Encode With eth_abi
>>> import eth_abi
>>> w3.keccak(text='transfer(address,uint256)')[:4].hex()+eth_abi.encode_abi(['address','uint256'], [account.address, 100]).hex()
'0xa9059cbb000000000000000000000000071437d6919f75c7d40363aacd26ac7df39c71fe0000000000000000000000000000000000000000000000000000000000000064'
Three Ways, One Result
You’ll notice that the generated calldata is identical for each of the three options. This is exactly as intended, and you should have expected it. If not, visit the lesson Generalized Vyper Smart Contracts to get familiar with function selectors and calldata.
Accessing and Modifying Storage
This section will likely seem like a curveball, but it’s necessary to understand before you can really explore the state override functionality of eth_call
.
All of the data inside a smart contract is stored at a particular address within the EVM. We take it for granted that data can be read at any time, but mostly because we’re used to contracts that expose that data via read-only view methods.
What if we want to modify that data for testing purposes? What if the smart contract author did not provide a view function?
This is where the eth_getStorageAt
method comes in. Recall that EVM is a public blockchain and should be readable by anyone. The data may be obscured or encoded, but it cannot be hidden.