OneSwap Series 15 - Arbitrage between Contracts

in uniswap •  4 years ago 

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

Two decentralized exchanges are used in the project: UniSwap and OneSwap; with the function of flashSwap provided by UniSwap, risk-free arbitrage can be implemented in various decentralized exchanges.

  • 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

The robot described in this article is a simple MVP (Minimum Viable Product) version, which mainly includes the following functions:

  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

In a UniSwap-like decentralized exchange, each fund pool is composed of a trading pair (that is, containing two kinds of tokens), so the first step is to configure a fund pool for arbitrage. Considering the difference in the sorting algorithms of the two tokens that make up the trading pair contract on different exchanges, in this step, you need to manually set the order of the two trading tokens (to facilitate the price calculation in the next step)

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

In UniSwap-like decentralized exchanges, each fund pool has two corresponding token deposit amounts to provide traders with liquidity; for more details, see: https://uniswap.org/docs/v2/core-concepts/pools/;

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

Price calculation formula: price = money / stock;

Unify the order of the deposit amount

The first step in calculating the price is to unify the order of the deposit amount for the two fund pools; here it is based on the order of OneSwap;

The code example is as follows

function resortUniReserves(tokens, uniReserves){
    if (tokens[0] === tokens[2]){ // uniToken0 == stock
        return uniReserves
    }else if (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 token and the amount with index=1 of money token.

Calculate the price

According to the formula: price = moneyTokenAmount / stockTokenAmount

The sample code is as follows

function calPrice(reserves){
    return reserves[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

Based on the calculated price, calculate the amount of funds needed to fill the price spread between the two fund pools.

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 AMM deposit 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

function tillUniSwapPriceNeededAmount(uniReserves, oneReserves, tokens){
    let uniPrice = calPrice(uniReserves)
    let onePrice = calPrice(oneReserves)
    let amount;
    let borrowToken;

    // 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
  • 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^2 = M^2 / K $;

$ P_1 = (M + {\Delta m}) / (S - {\Delta s}) = K / (S - {\Delta s})^2 = (M + {\ \Delta m})^2 / K $

So: $ (M + {\Delta m})^2 / P_1 = M^2 / 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

Next, calculate the number of tokens that need to be refunded to UniSwap.

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:

unction calReceivedAndRequiredAmount(amountAndBorrowToken, uniReserves, oneReserves, tokens){
    let uniRequired;
    let oneSwapReceived;

    // 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) $

  1. 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

Based on the numbers of the two markets worked out in the previous step, calculate the profitable quantity; at the same time, two factors need to be considered here:

  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

After all the above calculations, if there is an arbitrage opportunity, you can send an arbitrage transaction and call UniSwap's flash swap (flash loan) for profit.

The sample code is as follows:

async function 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.
  1. 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

By assembling the above functions, we can get a running 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

So far, the first version of the MVP of an arbitrage robot has been completed, and most of the core functions have already been made available. However, during arbitrage on decentralized exchanges of Ethereum, there is another essential element in addition to the arbitrage program: the Ethereum node the arbitrage program connects to, preferably is the mining pool. This can ensure that transactions are on the chain in time, thus guaranteeing the success rate of arbitrage transactions.

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

Authors get paid when people like you upvote their post.
If you enjoyed what you read here, create your account today and start earning FREE STEEM!