OneSwap Series 15 - Arbitrage between Contracts

This article mainly introduces how to use javascript to write a simple offline arbitrage robot to make a risk-free profit in various decentralized exchanges. This automated program does not have a production environment test. It is only for research. Readers assume sole responsibility for the potential risks.

Decentralized Exchange

  • flashSwap: Simply put, a user borrows a certain token from UniSwap’s trading pair pool and calls the pre-deployed arbitrage contract for the risk-free arbitrage with the fund pool of other decentralized exchanges. After that, the borrowed assets of equivalent value are refunded. In this risk-free arbitrage, users only need to pay transaction fees on Ethereum, and do not need to hold any tokens.

Example of UniSwap and OneSwap arbitrage contracts: https://github.com/oneswap/uniswap_oneswap_arbitrage/blob/master/contracts/FlashSwap.sol

Since the algorithm of the trading engine varies among different decentralized exchanges, it is necessary to write specific arbitrage contracts for various exchanges.

Programming

  1. Configure itself to adapt to the fund pools
  2. Query the data of fund pools on different exchanges
  3. Calculate the price of the fund pool on different exchanges
  4. Calculate the number of tokens that need to be borrowed from UniSwap to fill the spread between the fund pools
  5. Calculate the number of tokens that need to be refunded to UniSwap and the number of tokens that can be obtained from another exchange
  6. Calculate whether there is room for arbitrage
  7. Send arbitrage transactions

Configure the arbitrage fund pool

The sample code is as follows:

function initPairs(){
pairs = new Map();
// note: The value here is the address of tokens, and it needs to be in the same order as the addresses of the pair in UniSwap and OneSwap
// eth/usdt; uniSwapPairAddr; oneSwapPairAddr
pairs.set("0x0d4a11d5EEaaC28EC3F61d100daF4d40471f1852;0xD5c97DaA0bfF751e4282BbC5AC8D008738881224;ETH/USDT",
["0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", "0xdAC17F958D2ee523a2206206994597C13D831ec7",
"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", "0xdAC17F958D2ee523a2206206994597C13D831ec7"]);
....
}

pair is a Map, key is a string, and value is an array

  • key indicates the address of the fund pool and the name of the trading pair on the two exchanges, separated by ; in the format of uniSwapPairAddr;oneSwapPairAddr;PairSymbol
  • value indicates the tokens that make up the fund pool on the two exchanges, placed in order, in the format of [uniswapToken0,uniswapToken1,oneswapStock,oneswapMoney]

The ordering rules for tokens that make up the trading pair in UniSwap: tokens with a small address come first. The sorting rules for tokens that make up the trading pair in OneSwap: stock token first, followed by the money token;

For arbitrage of other trading pairs, just add more tokens to the configuration.

Query the deposited funds of the fund pool

Here, to query the deposited funds in the two trading pair contracts, the sample code is as follows:

async function queryUniSwapReserve(pairAddr){
console.log("uniswap pair : " + pairAddr);
let pairContract = await uniswapPairContract.at(pairAddr);
let reserves = await pairContract.getReserves();
return [reserves[0], reserves[1]]
}

async function queryOneSwapReserve(pairAddr){
console.log("oneswap pair : " + pairAddr);
let pairContract = await oneswapPairContract.at(pairAddr);
let reserves = await pairContract.getReserves();
return [reserves[0], reserves[1]]
}

Note: The deposit amount queried here is the same as the token sequence configured in the fund pool in the first step.

That is, the deposit amount for UniSwap: the deposited fund with index=0 of token0 and the deposited fund with index=1 of token1;

The deposit amount for OneSwap: the deposited fund with index=0 of stock and the deposited fund with index=1 of money;

Calculate the current prices of the two fund pools

Unify the order of the deposit amount

The code example is as follows

functionresortUniReserves(tokens, uniReserves){
if(tokens[0] ===tokens[2]){ // uniToken0 == stock
returnuniReserves
}elseif(tokens[0] ===tokens[3]){ uniToken0==money
return[uniReserves[1], uniReserves[0]]
}
}

Adjust the order of the deposit amount of UniSwap to be consistent with that of OneSwap; at this time, the returned amount array is the amount with index=0 of stock tokenand the amount with index=1 of money token.

Calculate the price

functioncalPrice(reserves){
returnreserves[1] /reserves[0]
}

Calculate the prices of trading pairs in the OneSwap and UniSwap fund pools respectively.

Calculate the amount of funds needed to fill the price spread

There are mainly three situations here:

  1. The prices are the same or almost the same (you can set the price slippage by yourself), which means that there is no arbitrage opportunity and you can exit directly.
  2. uniSwapPrice> oneSwapPrice, then borrow the money token from UniSwap, and buy the stock token in the OneSwap market. Raise the price to the same level as in UniSwap, and calculate the amount to be borrowed
  3. uniSwapPrice <oneSwapPrice, then borrow the stock token from UniSwap, and sell the stock token in the OneSwap market. Lower the price to the same level as in UniSwap, and calculate the amount to be borrowed

Note: The order book function in the OneSwap market complicates the data query and calculation in calculating the arbitrage space; therefore, only the amount of the AMMdeposit is considered here;

But in this case, the order book data can be put into consideration as well; because for every arbitrage, if there happens to be an order book that can be traded in OneSwap, the price of the fund pool cannot be increased/decreased to the same level as in UniSwap. Under this circumstance, there is still room for arbitrage in the next query, and arbitrage can continue. The disadvantage is such that the arbitrage, which is supposed to be completed by only one transaction, may require multiple transactions; yet the advantage is that the offline calculation and data query become less complex.

The sample code is as follows

functiontillUniSwapPriceNeededAmount(uniReserves, oneReserves, tokens){
letuniPrice=calPrice(uniReserves)
letonePrice=calPrice(oneReserves)
letamount;
letborrowToken;

// Borrow money for stock arbitrage, and increase the price in the OneSwap market
if(uniPrice>onePrice){
if(uniPrice<onePrice*1.03){ return{amount: -2} }
uniPrice=onePrice*1.03
amount=Big(oneReserves[0]).times(Big(oneReserves[1])).times(Big(uniPrice)).sqrt().minus(Big(oneReserves[1]))
borrowToken=tokens[3] // Borrow money
}
......

console.log("calculate amount end: ", amount.toString())
return{
amount:amount,
borrowToken: borrowToken,
}
}

The return value of the above function is (amount(Big), borrowToken(string));

  • amount: the number of tokens borrowed from UniSwap
  • borrowToken: the address of the borrowed tokens

There is a hard-coded value ​​in the calculation: 1.03, which is arbitrarily set and has not been tested by actual data;

  • 1.03 indicates arbitraging only 3% of the price of the current OneSwap trading pair
  1. uniPrice = onePrice * 1.03

This value must be set because the amounts of deposited funds in the two decentralized exchanges differ greatly. For the same price range, the slippage varies a lot on the price curve of different pools; the smaller the deposit amount of the fund pool, the greater the slippage. As a result, costs will exceed returns when all the price spread is filled, which will lower the rate of return.

In the calculation, the derivation of two formulas is involved when the price range is filled:

Borrow the money token and buy the stock token to increase the price of the OneSwap fund pool.

$ (M + {\Delta m}) * (S — {\Delta s}) = K = M * S $

$ P_0 = M / S = K / S² = M² / K $;

$ P_1 = (M + {\Delta m}) / (S — {\Delta s}) = K / (S — {\Delta s})² = (M + {\ \Delta m})² / K $

So: $ (M + {\Delta m})² / P_1 = M² / P_0 $

Then: $ {\Delta m} = \sqrt{P_1 * S * M} — M $

Borrow the stock token and sell the stock token to lower the price of the OneSwap fund pool.

Similar to the above derivation, write the result directly here: $ {\Delta s} = \sqrt{S * M / P_1}-S $

Calculate the number of tokens refunded to UniSwap and the number of tokens obtained from OneSwap

Note: Assuming that a user borrows Token A from UniSwap, then he needs to refund Token B in the equivalent value (price*amount);

The second step is to calculate the number of Token B obtained when inputting some Token A in OneSwap. The sample code is as follows:

unctioncalReceivedAndRequiredAmount(amountAndBorrowToken, uniReserves, oneReserves, tokens){
letuniRequired;
letoneSwapReceived;

// Step 1: Calculate the number of tokens that need to be refunded to UniSwap
if(amountAndBorrowToken.borrowToken===tokens[2]){ uniRequired=getAmountInUniSwap(amountAndInputToken.amount, uniReserves[1], uniReserves[0]) }
else{ uniRequired=getAmountInUniSwap(amountAndInputToken.amount, uniReserves[0], uniReserves[1]) }

// Step 2: Calculate the number of tokens obtained from OneSwap
if(amountAndInputToken.borrowToken===tokens[2]){ oneSwapReceived=getAmountOutOneSwap(amountAndInputToken.amount, oneReserves[1], oneReserves[0]) }
else{ oneSwapReceived=getAmountOutOneSwap(amountAndInputToken.amount, oneReserves[0], oneReserves[1]) }

console.log("calculate profit, oneSwapReceived: ", oneSwapReceived.toString(),"; uniRequired: ", uniRequired.toString())
return{uniRequired: uniRequired, oneSwapReceived: oneSwapReceived}
}

The return value of this function is the amount obtained from the two fund pools/the amount required;

  • Index 0: The amount required by the UniSwap fund pool
  • Index 1: The amount obtained from the OneSwap fund pool

This function involves two formulas, and the derivation process is as follows:

  1. The number of tokens that need to be refunded to UniSwap

$ (M — {\Delta m}) * (S + {\Delta s}) = K = M * S $

So: $ (M — {\Delta m}) * (S + {\Delta s}) = M * S $

Then: $ {\Delta s} = S * {\Delta m} / (M — {\Delta m})$

We should add the 0.3% trade fee to the final formula: $ {\Delta s} = S * {\Delta m} * 1000 / ((M-{\Delta m}) * 997) $

2. The number of tokens you can get from OneSwap

$ (M + {\Delta m}) * (S — {\Delta s}) = K = M * S $

So: $ (M + {\Delta m}) * (S — {\Delta s}) = M * S $

Then: $ {\Delta s} = S * {\Delta m} / (M + {\Delta m})$

We should add the 0.5% trade fee to the final formula: $$ {\Delta s} = {\Delta s} * 50/1000 $$

Calculate whether there is arbitrage space

  1. Can the transaction fee be covered by the tokens gained?
  2. The profit here is calculated off-chain, and it may take a while for the transaction to be on the chain (especially in the congestion of Ethereum)

Therefore, it is necessary to send arbitrage transactions only when the profit is high enough (for example: 100 USDT).

The $100 here is derived from the comprehensive consideration of the transaction fee of Ethereum during this period + the time when the transaction gets on the chain (because there may be users on the chain during this period to reduce the price spread).

The sample code is as follows

// Exit when the profit is negligible
if(oneSwapReceived<uniRequired.times(slippage)){
console.log("oneSwapReceived: ", oneSwapReceived.toString(), "; uniRequired * slippage : ", uniRequired.times(slippage).toString())
return{input: -1}
}

Here, we did not introduce the profit amount but use only the profit slippage. We need to further optimize it by introducing the profit amount for control. That is because under normal circumstances, the unit price of each token will not change much in one day; therefore, you can write the price of the token of the day in the configuration to calculate profit.

Send an arbitrage transaction

The sample code is as follows:

asyncfunction sendTx(tokenAndAmount, uniSwapPairAddr, tokens){
let bytes = web3.eth.abi.encodeParameters(['bool','bool'],[tokenAndAmount.uniSwapToken0IsStock, false]);
let contract = await uniswapPairContract.at(uniSwapPairAddr);
if (tokenAndAmount.inputToken === tokens[0]) {
await contract.swap(tokenAndAmount.amount, 0, arbitrageAddr, bytes);
}else if ((tokenAndAmount.inputToken === tokens[1]) ){
await contract.swap(0, tokenAndAmount.amount, arbitrageAddr, bytes);
}
}

The above function has two sets of logic:

  1. Parameters required for an arbitrage contract:
  • An arbitrage contract requires two parameters: first, whether the token0 of UniSwap is the stock token of OneSwap. Just pass it in according to the configuration. Second, the market address of OneSwap.
  • In OneSwap, one trading pair (such as ETH/USDT) may have two markets, one with the limit order function and the other without it; false means the limit order function is enabled.

2. The amount to be borrowed when calling UniSwap

  • If token0 is borrowed, write its quantity in the first parameter, and vice versa.

Assemble the robot

The sample code is as follows:

async function loop(){
initPairs()
console.log("enter loop")
while (true) {
console.log("enter ...")
await work()
}
}

async function work(){
console.log("work ...: ")

for (let pair of pairs) {
let pairAddrs = spiltPairs(pair[0])
console.log("\n\n\ncheck pair: ", pairAddrs[2])
let uniReserves = await queryUniSwapReserve(pairAddrs[0]); // toke1, token2
let oneReserves = await queryOneSwapReserve(pairAddrs[1]); // stock, money
let tokenAndAmount = hasChanceToArbitrage(pair[1], uniReserves, oneReserves)
console.log("calculate profit amount: ", tokenAndAmount.profit, "; input token amount: ", tokenAndAmount.amount)
if (tokenAndAmount.amount > 0) {
await sendTx(tokenAndAmount, pairAddrs[0], pair[1])
}
}
}

Summary

The author believes that the success of arbitrage depends on the above two factors: the performance of the arbitrage program and the connected Ethereum node; both are indispensable. The arbitrage program can ensure the arbitrage opportunities on decentralized exchanges; the connected nodes can guarantee the realization of such opportunities. The article below elaborates on the importance of nodes for arbitrage on Ethereum.

Ethereum is a Dark Forest

A fully decentralized exchange protocol on Smart Contract, with permission-free token listing and automated market making.