This Solidity tutorial will provide an introduction to DEX arbitrage with real, working, profitable open-source code. Solidity developers have the ability batch multiple swaps together and if they are not profitable revert the entire transaction only paying the transaction fee. The creation of EVM blockchains which have low transaction fees has created a playground for arbitrage traders.
- Introduction To Dex Arbitrage Video
- How DEX Arbitrage Works
- DEX Arbitrage Smart Contract
- Researching Exchanges, Tokens & Routes
- Creating A Trading Bot Controller
- Results Trading On Aurora
- Competing & Higher Stakes
Introduction To Dex Arbitrage Video
How DEX Arbitrage Works
There are many forms of arbitrage trading, in this tutorial we are going to concentrate on DEX arbitrage. This is buying a digital asset on one decentralized exchange and selling it on another. For this we will need a smart contract and a controller to execute the transactions.
The general idea is to take advantage of mispricing between exchanges. When someone executes a large trade in to one liquidity pool it can create an inbalance distorting the price and causing slippage for that trader. Arbitrage bots will then work to restore the balance by taking liquidity from other markets.
We will use a solidity smart contract as a relay between our controller and the exchanges. This is useful as it allows for fast execution of complex queries and batching multiple swaps into a single transaction. Critically we can revert the entire transaction and only lose the transaction fee if it is not profitable with one line of code.
require(endBalance > startBalance, "Trade Reverted, No Profit Made");
DEX Arbitrage Smart Contract
I’m only going to go over the highlights but the full source code for this is in https://github.com/jamesbachini/DEX-Arbitrage/blob/main/contracts/Arb.sol
I will be using the Uniswap v2 router which has been forked multiple times on just about every blockchain in existence.
Checking Prices & Trade Profitability
The code is setup to query the router for a minimum quantity out given a specific input.
function getAmountOutMin(address router, address _tokenIn, address _tokenOut, uint256 _amount) public view returns (uint256) {
address[] memory path;
path = new address[](2);
path[0] = _tokenIn;
path[1] = _tokenOut;
uint256[] memory amountOutMins = IUniswapV2Router(router).getAmountsOut(_amount, path);
return amountOutMins[path.length -1];
}
From there it’s possible to query multiple dexes in a single query with something like this:
function estimateDualDexTrade(address _router1, address _router2, address _token1, address _token2, uint256 _amount) external view returns (uint256) {
uint256 amtBack1 = getAmountOutMin(_router1, _token1, _token2, _amount);
uint256 amtBack2 = getAmountOutMin(_router2, _token2, _token1, amtBack1);
return amtBack2;
}
The function above takes two router addresses for two different DEX’s and two tokens. It checks whether it would be profitable to swap token1 for token2 on router1 and then swap it back on router2.
This smart contract function doesn’t have any hard coded addresses which makes it quite flexible if a new DEX comes online or there is a new token which is being traded as it can be queried without deploying new contracts.
Note that this is very simplified and professional arbitrage bots would more likely use the getReserves to more accurately calculate optimal trade sizing.
Executing Batched Trades
Trades need to be batched together to make sure they are profitable before we let them go through. This means combining two swaps on two different exchanges into a single transaction.
function dualDexTrade(address _router1, address _router2, address _token1, address _token2, uint256 _amount) external onlyOwner {
uint startBalance = IERC20(_token1).balanceOf(address(this));
uint token2InitialBalance = IERC20(_token2).balanceOf(address(this));
swap(_router1,_token1, _token2,_amount);
uint token2Balance = IERC20(_token2).balanceOf(address(this));
uint tradeableAmount = token2Balance - token2InitialBalance;
swap(_router2,_token2, _token1,tradeableAmount);
uint endBalance = IERC20(_token1).balanceOf(address(this));
require(endBalance > startBalance, "Trade Reverted, No Profit Made");
}
This function is quite ugly from a gas optimisation perspective. Fortunately this wasn’t an issue because there are no transaction fees on Aurora.
The function checks balances then carries out two swaps before checking that the final balance is greater than what we started with. If no profit is made the whole transaction gets rolled back to the original state.
Finally we need a way to withdraw ERC20 funds from the contract.
function recoverTokens(address tokenAddress) external onlyOwner {
IERC20 token = IERC20(tokenAddress);
token.transfer(msg.sender, token.balanceOf(address(this)));
}
Researching Exchanges, Tokens & Routes
I used DeFillama to get a list of exchanges on the Aurora chain. The four that I looked at were:-
- Trisolaris
0x2CB45Edb4517d5947aFdE3BEAbF95A582506858B - WannaSwap
0xa3a1ef5ae6561572023363862e238afa84c72ef5 - AuroraSwap
0xA1B1742e9c32C7cAa9726d8204bD5715e3419861 - Rose
0xc90dB0d8713414d78523436dC347419164544A3f
You can find the router address by doing a swap manually and then using the block explorer to check the “Interacted With” contract address.
Next I needed some token addresses to trade. I found a list by looking at the managed token list section on the Trisolaris swap page.
This linked to here: https://raw.githubusercontent.com/aurora-is-near/bridge-assets/master/assets/aurora.tokenlist.json
From there it was a case of testing each token with the different routers and finding out which had pools set up and storing those to a json file. This created an active route list which could be cycled through containing router and token addresses which were live and available to trade.
The following config file contains a list of [router1,router2,symbol,token1,token2]
https://github.com/jamesbachini/DEX-Arbitrage/blob/main/config/aurora.json
Creating A Trading Bot Controller
So to get started let’s clone the github repo and deploy the smart contract.
git clone https://github.com/jamesbachini/DEX-Arbitrage.git
cd DEX-Arbitrage
npm install
Then put a private key in the .env file, don’t worry about the contract address yet as it hasn’t been deployed. There are no fees on Aurora so you don’t need a funded account. Next deploy the smart contract.
npx hardhat run --network aurora .\scripts\deploy.js
Add the deployed contract address to .env
Once the contract was deployed I purchased a number of different base assets to use as collateral. The more base assets the more opportunities there are to trade different markets. The assets I chose to deploy were wETH, wNEAR, USDT, Aurora, atUST, USDC simply because they had the most volume and opportunities.
There are 3 scripts in the directory for checking contract balances, sending funds and recovering funds easily so you don’t need to do it manually for each asset. If you are going to try setting this up it would pay to try it first with a tiny amount of assets and make sure you can recover the funds correctly from the contract when you are done.
The next step is to create a trading bot controller which fires routes and token addresses at the smart contract to find out if there are any opportunities for trades and then executes those trades.
Again I am only going to go through the important bits but the full code is here:
https://github.com/jamesbachini/DEX-Arbitrage/blob/main/scripts/trade.js
First step is to create an instance of our contract.
[owner] = await ethers.getSigners();
const IArb = await ethers.getContractFactory('Arb');
arb = await IArb.attach(arbContract);
We then cycle through our routes to look for trades using the checkDualDexTrade function in our smart contract.
const amtBack = await arb.estimateDualDexTrade(targetRoute.router1, targetRoute.router2, targetRoute.token1, targetRoute.token2, tradeSize);
const multiplier = ethers.BigNumber.from(config.minBasisPointsPerTrade+10000);
const sizeMultiplied = tradeSize.mul(multiplier);
const divider = ethers.BigNumber.from(10000);
const profitTarget = sizeMultiplied.div(divider);
if (amtBack.gt(profitTarget)) {
await dualTrade(targetRoute.router1,targetRoute.router2,targetRoute.token1,targetRoute.token2,tradeSize);
} else {
await lookForDualTrade();
}
Once we have found a trade which is profitable we execute it using the dualDexTrade function.
const tx = await arb.connect(owner).dualDexTrade(router1, router2, baseToken, token2, amount);
await tx.wait();
Note that there is some logic to handle too many trades at any one time. I found that executing too quickly didn’t give the RPC nodes a chance to catch up which gave duplicate nonce errors. There is a hardhat module called NonceManager but I couldn’t get it to work consistently.
Another consistent headache is working with BigNumbers in Javascript. Token balances are huge because they have 18 decimals and 1 ETH is handled in wei as a BigNumber 1e18. There is a BigNumber library built in to Ethers utils but I’d recommend thoroughly testing any kind of computation or comparisons using BigNumber values.
Results Trading On Aurora
I allocated about $20 of capital to each base asset and after 12 hours of testing I ended up with the following results:
# weth: 69.58bps
# wnear: 966.04bps
# usdt: 124.10bps
# aurora: 7.26bps
# atust: 585.36bps
# pad: 0.00bps
# usdc: 194.33bps
This is in basis points (percent of a percent) so wNear was the top performer which provided a 9.66% return in less than a day.
The issue is that it doesn’t scale well. The obvious next step was to start deploying more capital and the following evening I ran it with about $300 in each pool.
# weth: 8bps (0bps/hr)
# atust: 0bps (0bps/hr)
# wnear: 192bps (0bps/hr)
# aurora: 3bps (0bps/hr)
# usdc: 2bps (0bps/hr)
# usdt: 36bps (0bps/hr)
wNear again was the highest performer with just under a 2% return. Still very good but only $6 in the grand scheme of things. The more capital you allocate the less opportunities there are for trades because the slippage increases and makes them unprofitable.
For low volume there are some excellent returns to be had trading on small decentralised exchanges. Risk is limited to counterparty risk with the DEX’s and any bugs in our own smart contracts.
Once this is published the opportunity will likely be gone and the code is provided as an educational tool and starting point rather than something to git clone and profit with directly. Also note that the code is unaudited and not production ready to be used for financial transactions.
Here are some of the most profitable transactions that from that testing period:-
$2 USDT https://explorer.mainnet.aurora.dev/tx/0x06461ac7c56d1e973b2011640dfc9b9d4340a457d4419354eaead4eb4bdfabbd/token-transfers
$4 NEAR https://explorer.mainnet.aurora.dev/tx/0x7e9aa5f29a89a5969c663825e4669bb24c0717835f6bb5f3dfc431d47c3e16cb/token-transfers
$2 USDC https://explorer.mainnet.aurora.dev/tx/0xcc9857eda19701c7dff06e7bae50066b83953c961be1939b7b4f1981c339b318/token-transfers
Competing & Higher Stakes
The opportunities on Aurora are currently capped by the lack of trading volume which is very low. The reason this is profitable at all is because there is such a small opportunity that no one else is bothering to trade it. The competition is nearly non-existent.
In contrast to this if I wanted to arbitrage trade on Ethereum mainnet between Uniswap and Sushi the opportunities would be much greater because there is so much more trading volume. However this code wouldn’t be profitable and the trades wouldn’t even cover the transaction fees.
At higher stakes execution becomes critical and it becomes a search for MEV (Miner Extractable Value). There is a whole industry of developers known as searchers building MEV systems to profit from various on-chain opportunities.
Some common examples include:-
- Sandwich trades front running big orders.
- Liquidation bots collecting fees from DeFi borrowing/lending protocols.
- Trading between multiple DEX’s, similar to what we are doing here but with 10+ hops between exchanges
- Special situation opportunities such as NFT mints and other code to automate DeFi tasks
If you are interested in learning more about how the searcher community operates check crypto twitter and the flashbots discord server.
Searchers will usually use flashbots to bundle their transactions and bid for execution priority with a conglomerate of miners that operate on Ethereum L1.
Gas optimisation becomes a priority due to the high gas fees on Ethereum mainnet and it’s a good place to learn about how the cutting edge market participants are optimising their code.
There are plenty of easier opportunities in the far out regions of alternate layer 1 and layer 2 blockchains, low liquidity DEX’s and emerging DeFi ecosystems where anyone who can put together a smart contract can profit from DEX arbitrage strategies like the one show in this article.
If you wanted to take this a step further I’d suggest looking into running a local or private geth RPC node for whatever chain you are trading and also expanding the complexity of the trades. I left in a function to show how this could be expanded to find triangular arbitrage opportunities.
function estimateTriDexTrade(address _router1, address _router2, address _router3, address _token1, address _token2, address _token3, uint256 _amount) external view returns (uint256) {
uint amtBack1 = getAmountOutMin(_router1, _token1, _token2, _amount);
uint amtBack2 = getAmountOutMin(_router2, _token2, _token3, amtBack1);
uint amtBack3 = getAmountOutMin(_router3, _token3, _token1, amtBack2);
return amtBack3;
}
It’s basically the same function as our dualDexTrade but we now have 3 routers, 3 tokens and a lot more variations we can optimise for.
I hope that this has served as a good introduction to DEX arbitrage trading and a useful tutorial for solidity developers.