OneSwap系列第十五篇之 合约间套利

1,725 阅读6分钟

本文章主要介绍,如何用js编写一个简单的线下套利机器人,在去中心化交易所进行无风险盈利。本自动化程序无生产环境试验,仅用于研究,读者风险自担

去中心化交易所

项目中使用了两个去中心化交易所:UniSwapOneSwap;借助uniSwap提供的flashSwap(闪电贷)的功能,在不同的去中心化交易所中进行无损套利。

  • flashSwap(闪电贷):简单说,用户在一次合约调用中,从uniswap的交易对资金池中借出某种token,调用提前部署好的套利合约,与别的去中心化交易所的资金池进行无风险套利,完后,归还相应价值的借贷资产。在这种无风险套利中,用户只需要支付以太坊上的交易手续费,并且不需要事先持有任何的交易对中的token。

uniswap 与 oneswap套利合约示例:https://github.com/oneswap/uniswap_oneswap_arbitrage/blob/master/contracts/FlashSwap.sol

由于不同的去中心化交易所的交易引擎算法不同,所以需要针对于不同的交易所,编写特定的套利合约。

程序编写

本文描述的机器人是一个简单的MVP(Minimum Viable Product)版本,主要包含下列几种功能:

  1. 套利资金池的配置
  2. 查询不同交易所上资金池的数据
  3. 计算资金池在不同交易所中的价格
  4. 计算填平资金池之间的价格差距,需要从uniswap中借出的token数量
  5. 计算需要还给uniswap的token数量,计算可以从另一个交易所中得到的token数量
  6. 计算是否有套利空间
  7. 发送套利交易

配置套利资金池

在类uniswap的去中心化交易所中,每个资金池都由一个交易对组成(即:包含两种token),所以第一步,配置准备套利的资金池。因为不同的交易所中组成交易对合约的两种token排序算法不同,所以在这步,需要手动设置两个交易token的顺序(以方便下步的价格计算)

示例代码如下:

function initPairs(){
    pairs = new Map();
    // note: 此处value为的tokens地址,需要与uniswap, oneswap上组成pair的地址顺序相同
    // eth/usdt;  uniSwapPairAddr; oneSwapPairAddr
    pairs.set("0x0d4a11d5EEaaC28EC3F61d100daF4d40471f1852;0xD5c97DaA0bfF751e4282BbC5AC8D008738881224;ETH/USDT",
        ["0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", "0xdAC17F958D2ee523a2206206994597C13D831ec7",
        "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", "0xdAC17F958D2ee523a2206206994597C13D831ec7"]);
	....        
}        

pair:为Map类型,key 为string类型,value 为数组类型

  • key: 为两个交易所中资金池的地址,交易对名称,由;分隔;格式 uniSwapPairAddr;oneSwapPairAddr;PairSymbol
  • value: 为两个交易所中组成资金池的token,按顺序放置;格式 [uniswapToken0,uniswapToken1,oneswapStock,oneswapMoney]

uniswap中组成交易对的token排序规则:地址小的排在前面。 oneswap中组成交易对的token排序规则:stock定位的token排在前面,money定位的token排在后面;

当需要进行别的交易对套利时,只需要继续增加配置即可。

查询资金池的质押资金

在类uniswap的去中心交易所中,每个资金池都有两种对应的token质押金额,用来为交易者提供流动性;更详细的信息参见:https://uniswap.org/docs/v2/core-concepts/pools/

此处查询两个交易对合约中的质押资金,示例代码如下:

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]]
}

注意:此处查询的质押金额的与第一步资金池中配置的token顺序相同。

即uniswap的质押金额:索引0为 token0的质押资金,索引1为 token1的质押资金;

oneswap的质押金额:索引0为 stock的质押资金,索引1为 money的质押资金;

计算两个资金池的当前价格

价格计算公式:price = money / stock;

统一质押金额顺序

计算价格时的第一步,是对两个资金池的质押金额顺序进行统一;此处是依据oneswap的顺序为准;

代码示例如下

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]]
    }
}

将uniswap的质押金额的顺序,与oneswap的顺序调整一致;此时返回的金额数组,索引0 为stock token的金额,索引1 为money token的金额。

计算价格

依据公式:price = moneyTokenAmount / stockTokenAmount

示例代码如下

function calPrice(reserves){
    return reserves[1] / reserves[0]
}

分别计算出 oneswap、uniswap资金池中交易对的价格

计算填平价格差距时所需的资金数量

依据计算出的价格,计算填平两个资金池价格差距时所需要资金数量。

此处主要有三种情况:

  1. 价格相同,或者相差不大(可以自己设置价格滑点),表示无套利机会,直接退出。
  2. uniSwapPrice > oneSwapPrice,则从uniSwap借出money token,在oneSwap市场中,购买stock token,将价格拉升至与uniSwap相同,计算需要借出的数量
  3. uniSwapPrice < oneSwapPrice,则从uniSwap借出stock token,在oneSwap市场中,卖出stock token,将价格拉低至与uniSwap相同,计算需要借出的数量

注意:虽然oneSwap市场中,添加了订单簿的功能,但是在计算套利空间时,加上订单这部分计算,会导致数据查询、计算过程比较复杂;所以此处只考虑 AMM的质押金额;

但是这种考虑方式,也可以兼容订单簿的数据;因为每次置换时,如果碰巧oneSwap有订单簿可以成交,会导致最后将资金池的价格无法拉升/降低至与uniSwap价格相同,导致下次查询时,还存在套利空间,可以继续进行套利。弊端在于,可能将一笔交易完成的套利,切分成多笔;优点在于,线下计算、数据查询复杂度降低。

示例代码如下

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

    // 借入money,去套利stock,把oneswap市场的价格拉升
    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] // 借出money
    }
    ......
    
    console.log("calculate amount end: ", amount.toString())
    return {
        amount:amount,
        borrowToken: borrowToken,
    }
}

上述函数的返回值为{amount(Big), borrowToken(string)};

  • amount:从uniSwap借出token的数量
  • borrowToken: 借出token的地址

计算中出现两个硬编码的值:1.03,为随意设置,未经过实际数据测试;

  • 1.03: 表示只套利当前OneSwap交易对价格的3%
    • uniPrice = onePrice * 1.03

设置该数值的原因在于,两个去中心化交易所的资金池质押数量相差悬殊,相同的价格区间,在不同资金池的价格曲线上,滑点会有很大差异;质押数量越小的资金池,滑点越大,会导致当填平所有价格差距时,付出大于回报,拉低收益率。

在计算时,填平价格区间时涉及两个公式的推导过程:

  1. 借出money token,买stock token,拉升oneSwap资金池价格

    (M+Δm)(SΔs)=K=MS(M + {\Delta m}) * (S - {\Delta s}) = K = M * S

    P0=M/S=K/S2=M2/KP_0 = M / S = K / S^2 = M^2 / K;

    P1=(M+Δm)/(SΔs)=K/(SΔs)2=(M+ Δm)2/KP_1 = (M + {\Delta m}) / (S - {\Delta s}) = K / (S - {\Delta s})^2 = (M + {\ \Delta m})^2 / K

    所以:(M+Δm)2/P1=M2/P0(M + {\Delta m})^2 / P_1 = M^2 / P_0

    则:Δm=P1SMM{\Delta m} = \sqrt{P_1 * S * M} - M

  2. 借出stock token,卖stock token,拉低oneSwap资金池价格

    与上述推到类似,此处直接写结果:Δs=SM/P1S{\Delta s} = \sqrt{S * M / P_1} - S

计算还给uniSwap的token数量和从oneSwap得到的token数量

接下来计算需要还给uniSwap的token数量。

注意:假设从uniSwap借的是A token,那么这里还回去的就是等值的B token;

第二步计算,向oneSwap输入A token数量时,获得的B token数量.

示例代码如下:

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

    // 第一步:计算需要还给uniSwap的token数量
    if (amountAndBorrowToken.borrowToken === tokens[2]){ uniRequired = getAmountInUniSwap(amountAndInputToken.amount, uniReserves[1], uniReserves[0]) }
    else{ uniRequired = getAmountInUniSwap(amountAndInputToken.amount, uniReserves[0], uniReserves[1]) }
    
    // 第二步:计算从oneSwap获得的token数量
    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}
}

该函数的返回值为 两个资金池得到/需求的数量

  • 索引0: uniSwap资金池需要的数量
  • 索引1: oneSwap资金池获得的数量

该函数涉及两个公式,推导过程如下:

  1. 需要还给uniSwap的token数量

    (MΔm)(S+Δs)=K=MS(M - {\Delta m}) * (S + {\Delta s}) = K = M * S

    所以:(MΔm)(S+Δs)=MS(M - {\Delta m}) * (S + {\Delta s}) = M * S

    则:Δs=SΔm/(MΔm) {\Delta s} = S * {\Delta m} / (M - {\Delta m})

    因为有0.3%的手续费,所以最后的公式要添加上手续费的计算:Δs=SΔm1000/((MΔm)997) {\Delta s} = S * {\Delta m} * 1000 / ((M - {\Delta m}) * 997 )

  2. 可以从oneSwap获得token数量

    (M+Δm)(SΔs)=K=MS(M + {\Delta m}) * (S - {\Delta s}) = K = M * S

    所以:(M+Δm)(SΔs)=MS(M + {\Delta m}) * (S - {\Delta s}) = M * S

    则:Δs=SΔm/(M+Δm) {\Delta s} = S * {\Delta m} / (M + {\Delta m})

    因为有0.5%的手续费,所以最后的公式要添加上手续费的计算: {\Delta s} = Δs50/1000{\Delta s} * 50 / 1000

计算是否有套利空间

依据上步计算出的两个市场的数量,计算可以盈利的数量;同时,在这里需要考虑两个方面:

  1. 盈利的token是否可以覆盖 这笔交易的手续费
  2. 因为此处的盈利为链下计算,交易上链可能需要一段时间(尤其是针对以太坊的拥堵情况)

所以,需要此处只有盈利极大的情况下(比如:100 usdt),才可以发送套利交易。

这里的100 美金为综合这段时间以太坊的手续费考虑 + 交易上链的时间(因为这段时间有可能链上还有用户在交易,抹平价格差)。

示例代码如下

// 获利太小的情况下,退出
if (oneSwapReceived < uniRequired.times(slippage)){
    console.log("oneSwapReceived: ", oneSwapReceived.toString(), "; uniRequired * slippage : ", uniRequired.times(slippage).toString())
    return {input: -1}
}

此处,并没有引入获利金额,而是用了一个盈利滑点控制。需要后续进行优化,引入盈利金额进行控制。因为一般情况下,每个币种一天的单价不会变化太大;所以,可以在配置里,写入当天币种的单价,来计算盈利。

发送套利交易

在上述所有计算之后,如果出现套利机会,就可以发送套利交易,调用uniSwap的flash swap(闪电贷)进行获利。

示例代码如下:

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);
    }
}

上述函数有两个逻辑:

  1. 套利合约需要的参数:
    • 套利合约需要两个参数:第一个为,uniSwap的token0 是否为 oneSwap的stock token,依据配置情况传入即可。第二个参数为:oneSwap的市场参数。
    • 在oneSwap中,相同的交易对(如:ETH/USDT),可能存在两个市场,一个开启限价单功能,一个未开启限价单功能;false: 表式开启限价单功能。
  2. 调用uniSwap时,需要借出的金额
    • 如果借出的token 为token0,则数量写在第一个参数,反之亦然。

组装机器人

通过将上述的功能组装起来,就可以得到一个可以运行的机器人。

示例代码如下:

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])
        }
    }
}

总结

至此,一个套利机器人的MVP初版完成,大部分的核心功能都已具备。但是,在以太坊的去中心化交易所中套利时,除了套利程序这个基本的要素之外,还有一个必备的要素:与套利程序链接的以太坊节点,最好是直接链接矿池的节点,这样可以保证交易及时上链,保证套利交易的成功率。

套利的成功与否,作者认为取决于上述两点要素:套利程序的性能,链接的以太坊节点;二者缺一不可,套利程序可以保证捕捉到去中心化交易所中的套利机会;链接的节点,可以保证将捕捉到的机会真正兑现;下面的链接文章描述了在以太坊上套利时,节点的重要性。

Ethereum is a Dark Forest