ENGINEERING
4th November 2020

Arbitraging Uniswap and SushiSwap in Node.js

The fourth in a series from our engineering team focused on the intersection of money and technology. See our previous from J. Otto on why Bitcoin is easier for payments than Stripe. Check out our open engineering roles here.

Uniswap and SushiSwap are like stock exchanges, but on the Ethereum blockchain. Arbitrage in this case means buying something on one exchange and immediately selling it elsewhere for a profit.

There are many arbitrage opportunities on Ethereum, but this particular example is easy to explain in a short blog post because SushiSwap is a fork of Uniswap which means their APIs are the same. You’ll need node.js and an Ethereum node to follow along (either run your own or use Infura/Etherscan).

The business logic for this bot will be:

  1. Monitor latest prices on Uniswap and SushiSwap
  2. Decide whether to trade
  3. Execute trade

Can profit be made here? Maybe. This post will help you hit the ground running, and then give some ideas at the end for how to be competitive and maybe profit.

Here's the high-level pseudo code of how we’ll architect this bot:

subscribeTo(uniswap, "usdc", "eth", (latestTrade) => {
latestUniPrice = latestTrade.price;
if (profitableArb(latestUniPrice, latestSushiPrice)) {
executeTrade("usdc", "eth");
}
});
subscribeTo(sushiswap, "usdc", "eth", (latestTrade) => {
latestSushiPrice = latestTrade.price;
if (profitableArb(latestSushiPrice, latestUniPrice)) {
executeTrade("usdc", "eth");
}
});

In practice, we’d do this for the intersection of all markets on both Uniswap (Uniswap currently has ~19,000 markets) and SushiSwap (currently has ~220 markets), but this blog post will focus on USDC/ETH alone.

First, we’ll cover subscribing to the latest trades from Uniswap and SushiSwap.

Monitoring latest prices

See these ~60 lines of node.js code to copy and paste to see the price of Ethereum quoted in USDC every time there’s a trade on Uniswap (you’ll need to connect to an Ethereum node)

To make it work for SushiSwap you only need to change one variable: the address of the smart contract to subscribe to (each market for Uniswap and SushiSwap is a separate smart contract). In this case, change the uniswapUsdtWethExchange variable on line 5 to 0x397ff1542f962076d0bfe58ea045ffa2d347aca0. Where did I get that address? I grabbed it from here: https://sushiswap.vision/pairs but it also be found directly from the getPair method on the SushiSwap factory contract here: To scale this up for the intersection of all markets, you can use the allPairs() + getPair() methods from the Uniswap and SushiSwap “factory” smart contracts. (The Uniswap platform is made up of 3 components: “factory”, “router” and N “pair’s”)

Decide whether to trade

So now you’re monitoring the price for USDC/ETH on both Uniswap and SushiSwap. How do you know if a trade is profitable or not? There are 3 (math) factors:

  1. the Uniswap and SushiSwap trade fee (0.3% on each)
  2. the Ethereum transaction fee (approximately $4 USD at time of writing).
  3. Slippage on the Uniswap market and slippage on the SushiSwap market

The first one is the most important: the price difference after a trade fee:

function estimateProfitAfterTradingFees(uniswapPrice, sushiswapPrice) {
const diff = Math.abs(sushiswapPrice - uniswapPrice);
const diffRatio = diff / Math.max(sushiswapPrice, uniswapPrice);
// multiply by 2 because we trade 2 times
// (once on Uniswap and once on SushiSwap)
const fees = UNISWAP_FEE * 2;
return diffRatio - fees;
}

If the profit after trading fees is greater than $0.01 USD equivalent, should we do the trade? No, because the Ethereum transaction fee (gas) will probably cost $4 USD equivalent. OK, what if the profit was $4.01, should we do it then? Yes, if the amount we’re buying doesn’t move the price. OK, how do I know if it moves the price? You calculate the slippage, which can be derived from the size of both “reserves” (liquidity).

Uniswap and SushiSwap are “AMM’s” - a fancy term for an object that looks like this:

{ token0Reserves: 400, token1Reserves: 1 }

With 3 methods: depositLiquidity, withdrawLiquidity, swap.

Notice the actual data in the object: 2 numbers, 1 for each token (400 and 1). Those numbers represent the number of tokens in this smart contract, the liquidity.

Notice that if you multiply those reserve numbers, the result is 400. This is referred to as the “product” (mathematical product) and is defined by the initial depositor into the smart contract based on the size of their deposits of each token (it’s an arbitrary number but it never changes after inception, so that means we can treat it as a mathematical relationship)

To get the price of token1 simply find the ratio: 400/1 or 400. To get the price of token0, take the inverse of the ratio: 1/400 or 0.0025. These AMMs are 2-way: a user can buy token0 selling token1, or buy token1 selling token0.

Back to the point, how do we calculate the slippage? We’ll use the relationship between the constant product of 400 and reserve sizes to see prices at various percentages of the supply of token1 reserves.

For example, to calculate price of token1 after buying 50% of token1’s supply, we’ll solve for how many units of token0 would need to exist to maintain the constant product of 400 if only 0.5 units (50% of the original quantity of 1) of token1 exist.

constant product = token0 reserves * token1 reserves;
400 = token0Reserves * (1*0.5)
Solve for token0Reserves: 400 = 0.5 * token0Reserves
400/0.5 = 800

That means there will be 800 units of token0 and 0.5 units of token1 in the reserves after buying 50% of token1. Thus the new price (ratio) will be 800/0.5 = $1,600. Does that mean it costs $1,600 to buy 50% of the supply here? No, the actual cost paid is somewhere between the original price of $400 and final price of $1,600. In this case we received 0.5 units for increasing the token0 reserves by 400 units (800-400)/0.5 = 800. That’s an average price of 800 token0 for 1 unit of token1. (a 100% price increase). Don’t be fooled, it’s not a linear relationship, buying 80% of the supply would cost an average price of 1333 units of token0 per 1 unit of token1 (233% price increase). Note this exponential relationship because you’ll see it often in liquidity pools where small orders can move the price significantly.

I recommend this for further reading on Uniswap.

With slippage, we can augment our estimateProfitAfterTradingFees function with another function, findMaxBet, to decide how many units of token0 we can buy before the price moves past our break even point:

const profitRate = estimateProfitAfterTradingFees(uniPrice, sushiPrice);
const maxBet = findMaxBet(profitRate, uniReserves, sushiReserves);
const expectedProfit = maxBet * profitRate;
if (expectedProfit > 0) {
executeTrade(maxBet);
}

BUT this trade will fail to complete 100% of the time. Why? Because either a competing arb bot will make the trade for less profit, or a generic front running bot will clone your transaction with a higher gas price.

A naive solution would simply allocate 100% of estimatedProfit to gas and then reduce it until transactions start failing (competing bots).

function getGasPrice(n = 1) {
const fixedFee = 0.0001 * n;
const gasPrice = (expectedProfit - fixedFee) / ESTIMATED_GAS_USAGE;
}

Executing the trade

Before we can execute a "swap" on Uniswap or SushiSwap we need to call the "approve" method on each individual ERC20 you want to trade with each exchange you want to trade. For our scenario, we'll need 4 approvals:

const uniswapRouterAddress = "0x7a250d5630b4cf539739df2c5dacb4c659f2488d";
const sushiswapRouterAdress = "0xd9e1ce17f2641f24ae83637ab66a2cca9c378b9f";
const usdcErc20Address = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
const wethErc20Address = "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2";
// allow Uniswap and Sushiswap to move up to 1000.0 of my units of USDC
approveUniswap(usdcErc20Address, 1000.0);
approveSushiswap(usdcErc20Address, 1000.0);
// allow Uniswap and Sushiswap to move up to 5 of my units of ETH
approveUniswap(wethErc20Address, 5.0);
approveSushiswap(wethErc20Address, 5.0);
const gasPriceGwei = "100"; // in GWEI
const gasPriceWei = ethers.utils.parseUnits(gasPriceGwei, "gwei");
const wallet = new ethers.Wallet(
Buffer.from(
"", // paste your private key from metamask here
"hex"
)
);
const signer = wallet.connect(provider);
function approveUniswap(
erc20Address,
amountToApproveInNativeUnitFloat
) {
const erc20Contract = new ethers.Contract(erc20Address, erc20Abi, signer);
return erc20Contract.decimals().then((decimals) => {
return erc20Contract
.approve(
uniswapRouterAddress,
ethers.utils.parseUnits(
`${amountToApproveInNativeUnitFloat}`,
decimals
),
// manually set gas price since ethers.js can't estimate
{ gasLimit: 100000, gasPrice: gasPriceWei }
);
});
}

With the approvals done, we can finally execute a trade:

const uniswapRouterAbi = [
"function swapExactTokensForTokens(uint amountIn, uint amountOutMin, address[] calldata path, address to, uint deadline) external returns (uint[] memory amounts)",
];
function buyEthWithUsdc(amountUsdcFloat) {
const exchangeContract = new ethers.Contract(uniswapRouterAddress, uniswapRouterAbi, signer)
// usdc uses 6 decimals
return exchangeContract.swapExactTokensForTokens(
ethers.utils.parseUnits(`${amountUsdcFloat}`, 6),
ethers.utils.parseUnits(`${amountUsdcFloat}`, 6, // this is the expected minimum output
[usdcErc20Address, wethErc20Address], // notice the ordering of this array, give usdc, get weth
wallet.address,
createDeadline(), // Math.floor(Date.now() / 1000) + 20
createGasOverrides() // { gasLimit: ethers.utils.hexlify(300000), gasPrice: gasPriceWei }
);
}
// aka sellEthForUsdc
function buyUsdcWithEth(amountEthFloat) {
const exchangeContract = new ethers.Contract(uniswapRouterAddress, uniswapRouterAbi, signer)
// eth uses 18 decimals
return exchangeContract.swapExactTokensForTokens(
ethers.utils.parseUnits(`${amountEthFloat}`, 18),
0,
[wethErc20Address, usdcErc20Address], // notice the ordering of this array: give weth, get usdc
wallet.address,
createDeadline(), // Math.floor(Date.now() / 1000) + 20
createGasOverrides() // { gasLimit: ethers.utils.hexlify(300000), gasPrice: gasPriceWei }
);
}

Dark Forest Tactics

This guide executes 2 separate transactions per trade, but in practice we’d deploy a smart contract that could batch these trades into a single transaction. We’d also attempt to hide our transactions so as to prevent generic front running bots.

Come work with us!

If you’re a software engineer interested in helping us contextualize and categorize the world’s crypto data, we’re hiring. Check out our open engineering positions to find out more.