플래시론을 활용한 아비트라지(2)

Overview

다음은 실제코드와 함께 플래시론을 이용하여 아비트라지를 하는 예제를 설명합니다.

본 예제에서는 클레이스왑과 클레임스왑에 존재하는 KUSDT↔KETH 페어간 아비트라지 기회를 포착하고 실행하는 방법 및 코드를 설명합니다.

아비트라지를 하기 위해서는,

  1. 플래시론을 활용하여 스왑하는 컨트랙트

  2. 기회를 포착하여 트랜잭션을 전송하는 봇

두가지가 필요합니다.

컨트랙트는 세개의 컨트랙트로 구성되어있으며 각각,

  • 기회를 포착한 봇이 트랜잭션을 날려 시작점이 되는 컨트랙트 (A)

  • 플래시론을 이용해 클레이스왑 → 클레임스왑 순서의 차액거래를 하는 컨트랙트 (B)

  • 플래시론을 이용해 클레임스왑 → 클레이스왑 순서의 차액거래를 하는 컨트랙트 (C)

로 구성됩니다. 아래의 설명에서는 각 컨트랙트의 주소를 (A),(B),(C)로 표기하여 설명합니다.

Contract

Arbitrager Contract(A)는 아비트라지 기회를 포착후 Tx를 받는 허브역할을 하게됩니다.

contract Arbitrager {
    
    ILendingPool LENDING_POOL;
    IKlaySwapProtocol KLAYSWAP;
    IClaimSwapRouter CLAIMSWAP;
    ...
    function swapKlaySwapToClaimSwap(address tokenA, address tokenB, uint256 amount) public {
    ...
    }
    function swapClaimSwapToKlaySwap(address tokenA, address tokenB, uint256 amount) public {
    ...
    }
    ...   
}

클레이스왑 → 클레임스왑 순서의 아비트라지를 수행하는 함수(swapKlaySwapToClaimSwap) 와 클레임스왑 → 클레이스왑 순서의 아비트라지를 수행하는 함수(swapClaimSwapToKlaySwap) 가 존재합니다. 각각 상황에 맞추어 호출하면 (B), (C) 컨트랙트를 호출하는 플래시론 동작을 수행합니다.

function swapClaimSwapToKlaySwap(address tokenA, address tokenB, uint256 amount) public {
    address[] memory assets = new address[](1);
    assets[0] = tokenA;
    uint256[] memory amounts = new uint256[](1);
    amounts[0] = amount;
    uint256[] memory modes = new uint256[](1);
    modes[0] = 0;
    bytes memory params = addressToBytes(tokenB);
    LENDING_POOL.flashLoan(
        ArbitragerClaimToKlaySwap,
        assets,
        amounts,
        modes,
        address(this),
        params,
        0
    );
}

두가지의 함수중 swapClaimSwapToKlaySwap 를 기준으로 설명합니다.

swapClaimSwapToKlaySwap

  1. KUSDT → KETH ( 클레임 스왑)

  2. KETH → KUSDT ( 클레이 스왑)

과정의 아비트라지를 수행해주는 함수입니다. 따라서 플래시론을 통해 빌릴 토큰 파라미터인 assets 에 tokenA 주소를, amounts에 파라미터로 받은 amount를 넣고 플래시론을 실행해줍니다.

플래시론 인터페이스 및 파라미터 에 대한 설명은 여기 를 참조해주세요.

LENDING_POOL.flashLoan(...) 을 통해 플래시론을 실행해주고, 플래시론을 통해 로직을 수행할 컨트랙트인

ArbitragerClaimToKlaySwap 에 (C) 주소를 입력합니다.

contract ArbitragerClaimToKlaySwap is IFlashLoanReceiver {
    function executeOperation(
        address[] calldata assets,
        uint256[] calldata amounts,
        uint256[] calldata premiums,
        address initiator,
        bytes calldata params
    ) external returns (bool){
        uint estimateOut;
        uint estimateOut2;
        address[] memory path = new address[](2);
        // tokenA
        path[0] = assets[0];
        // tokenB
        path[1] = bytesToAddress(params);
        estimateOut = CLAIMSWAP.getAmountsOut(amounts[0], path)[1];
        IKlaySwapExchange klaySwapPool = IKlaySwapExchange(KLAYSWAP.tokenToPool(path[0], path[1]));
        estimateOut2 = klaySwapPool.estimatePos(path[1], estimateOut);
        require(estimateOut2 > amounts[0]);
        _swapClaimSwap(path[0], path[1], amounts[0]);
        _swapKlaySwap(path[1], path[0], estimateOut);
        for (uint i = 0; i < assets.length; i++) {
            checkApprove(assets[i], address(LENDING_POOL));
        }
        IKIP7(assets[i]).transfer(msg.sender,estimateOut2-amounts[0]-premiums[0]);
        return true;
    }
    
    function _swapKlaySwap(address tokenA, address tokenB, uint256 amount) private {
        checkApprove(tokenA, address(KLAYSWAP));
        KLAYSWAP.exchangeKctPos(tokenA, amount, tokenB, 10, new address[](0));
    }
    
    function _swapClaimSwap(address tokenA, address tokenB, uint256 amount) private {
        checkApprove(tokenA, address(CLAIMSWAP));
        address[] memory path = new address[](2);
        path[0] = tokenA;
        path[1] = tokenB;
        CLAIMSWAP.swapExactTokensForTokens(amount, 10, path, address(this), 4230424911);
    }
    
    function checkApprove(address token, address spender) public {
        if (token == address(0x0000000000000000000000000000000000000000)) {
            return;
        }
        if (!approvedTokens[spender][token]) {
            IKIP7(token).approve(spender, uint(2 ** 256 - 1));
            approvedTokens[spender][token] = true;
        }
    }
    ...
}

위 코드는 ArbitragerClaimToKlaySwap 컨트랙트의 일부분입니다. 플래시론을 통해 대출이 실행되었을때 수행할 코드를 작성하기위해 IFlashLoanReceiver 를 상속 받습니다. ArbitragerClaimToKlaySwap 는 클레임스왑 → 클레이스왑의 아비트라지를 수행하는 컨트랙트입니다. 따라서 function executeOperation(...)에서 실질적인 아비트라지를 진행합니다. 빌린 자산인 asset[0] 가 tokenA, 파라미터로 넘어온 데이터인 params가 tokenB가 되게됩니다. 함수의 동작은 다음과같은 순서로 진행됩니다.

  1. 클레임 스왑의 getAmountsOut 과 클레이스왑의 estimate를 이용하여 거래를했을때 수익이 나는지 확인합니다.(require(estimateOut2 > amounts[0]);)

  2. _swapClaimSwap 를 통해 클레임스왑에서 A→B 토큰으로 교환을 하고

  3. _swapKlaySwap 를 통해 클레이스왑에서 B→A 토큰으로 교환을 합니다.

  4. 마지막으로 클레이뱅크에 빌린 자산을 돌려주기위해 approve 를 확인해주는 checkApprove 함수를 실행합니다.

이로써 A→B→A 토큰 교환을 마치고 대출금액 및 플래시론 수수료를 상환한 뒤 남은 차액을 얻게됩니다.

swapKlaySwapToClaimSwap 를 사용하는 ArbitragerKlaySwapToClaim 컨트랙트역시 로직은 동일하며 순서만 다릅니다.

Arbitrage Bot

Arbitrage Bot의 전체 소스코드는 http://github.com/klaybank/arbitrager 에 업로드 되어있습니다.

const Caver = require('caver-js')
const BigNumber = require("bignumber.js");
const IKIP7ABI = require('./build/contracts/IKIP7.json');
const IKlayswapProtocolABI = require('./build/contracts/IKlaySwapProtocol.json');
const IKlayswapExchangeABI = require('./build/contracts/IKlaySwapExchange.json');
const IClaimSwapRouterABI = require('./build/contracts/IClaimSwapRouter.json');
const ArbitragerABI = require('./build/contracts/Arbitrager.json')

먼저 봇 실행에 필요한 기본적인 Caver, BigNumber 및 컨트랙트의 ABI들을 추가해줍니다.

const KETHAddress = "0x34d21b1e550d73cee41151c77f3c73359527a396"
const KUSDTAddress = "0xcee8faf64bb97a73bb51e115aa89c17ffa8dd167"
const KlayswapFactoryAddress = "0xc6a2ad8cc6e4a7e08fc37cc5954be07d499e7654"
const ClaimSwapRouterAddress = "0xEf71750C100f7918d6Ded239Ff1CF09E81dEA92D"
const Klayswap_KUSDT_KETH_ExchangeAddress = "0x029e2a1b2bb91b66bd25027e1c211e5628dbcb93"
const ArbitragerAddress = "Deployed Contract Address"
const tryKUSDTAmount = new BigNumber("10000" + "000000") // 10000 KUSDT

위에는 각 컨트랙트들의 주소를 나타내며, ArbitragerAddress의 경우 직접 배포한 컨트랙트의 주소가 위치하게 됩니다.

tryKUSDTAmount 는 한번 아비트라지를 시도할때 사용할 금액을 나타내며, 이 예시에서는 10,000 KUSDT를 기준으로 설명합니다.

const caver = new Caver(new Caver.providers.WebsocketProvider("wss://public-node-api.klaytnapi.com/v1/cypress/ws"));
const KUSDTContract = new caver.klay.Contract(IKIP7ABI, KUSDTAddress)
const KETHContract = new caver.klay.Contract(IKIP7ABI, KETHAddress)
const klayswapFactoryContract = new caver.klay.Contract(IKlayswapProtocolABI, KlayswapFactoryAddress)
const klayswap_USDT_ETH_ExchangeContract = new caver.klay.Contract(IKlayswapExchangeABI, Klayswap_KUSDT_KETH_ExchangeAddress)
const claimSwapRouterContract = new caver.klay.Contract(IClaimSwapRouterABI, ClaimSwapRouterAddress)
const arbitragerContract = new caver.klay.Contract(ArbitragerABI, ArbitragerAddress)

매 블록마다 기회를 포착하기위해 소켓을 이용하며, 각 컨트랙트들을 ABI와 주소를 이용해 초기화해줍니다.

const keyring = caver.wallet.keyring.createFromPrivateKey('Input your private key here')
caver.wallet.add(keyring)
caver.klay.accounts.wallet.add(
    caver.klay.accounts.createWithAccountKey(keyring.address, keyring.key.privateKey))

또한 아비트라지 트랜잭션을 보낼 EOA의 private key를 이용하여 키링을 설정해줍니다.

const subscription = caver.rpc.klay.subscribe(
    'newBlockHeaders',
    async (error, result) => {
        if (error) {
            console.error(error);
            return;
        }
        let estimateUsdtToETHKlayswap = await klayswap_USDT_ETH_ExchangeContract.methods.estimatePos(KUSDTAddress, tryKUSDTAmount).call();
        let estimateETHToUsdtClaimSwap = (await claimSwapRouterContract.methods.getAmountsOut(estimateUsdtToETHKlayswap, [KETHAddress, KUSDTAddress]).call())[1]
        let KlayswapToClaimRes = new BigNumber(estimateETHToUsdtClaimSwap)
        console.log(`diff1 : ${KlayswapToClaimRes.toString()}`)
        if (KlayswapToClaimRes.isGreaterThan(tryKUSDTAmount)) {
            let txRes = await arbitragerContract.methods.swapKlaySwapToClaimSwap(KUSDTAddress, KETHAddress, tryKUSDTAmount).send(
                {
                    from: keyring.address,
                    gas: 5000000
                }
            )
            console.log(`try arbitrage klayswap -> claimSwap tx : ` + txRes.hash)
        }
        let estimateUsdtToETHClaimSwap = (await claimSwapRouterContract.methods.getAmountsOut(tryKUSDTAmount, [KUSDTAddress, KETHAddress]).call())[1];
        let estimateETHToUsdtKlayswap = await klayswap_USDT_ETH_ExchangeContract.methods.estimatePos(KETHAddress, estimateUsdtToETHClaimSwap).call()
        let ClaimSwapToKlayswapRes = new BigNumber(estimateETHToUsdtKlayswap)
        console.log(`diff2 : ${ClaimSwapToKlayswapRes.toString()}`)
        if (ClaimSwapToKlayswapRes.isGreaterThan(tryKUSDTAmount)) {
            let txRes = await arbitragerContract.methods.swapClaimSwapToKlaySwap(KUSDTAddress, KETHAddress, tryKUSDTAmount).send(
                {
                    from: keyring.address,
                    gas: 5000000
                }
            )
            console.log(`try arbitrage claimSwap -> klayswap tx : ` + txRes.hash)
        }
})
    .on('connected', console.log)
    .on('error', console.error);

블록마다 아비트라지기회를 포착하고 실행하는 코드부분입니다.

const subscription = caver.rpc.klay.subscribe(
    'newBlockHeaders',
    async (error, result) => {
        if (error) {
            console.error(error);
            return;
        }
    ...
    ...

부분에서 newBlockHeaders 를 subscribe하여 매 블록마다 이벤트를 받을 수 있도록 구성합니다. 이 subscribtion은 블록이 생성될때마다 async (error, result) => {...} 함수 콜백을 실행시켜줍니다.

따라서 콜백함수 내부에서 매 블록마다 클레이스왑 → 클레임스왑 에서 기회가 있는지, 클레임스왑 → 클레이스왑 에서 기회가 있는지 체크하게 됩니다.

let estimateUsdtToETHKlayswap = await klayswap_USDT_ETH_ExchangeContract.methods.estimatePos(KUSDTAddress, tryKUSDTAmount).call();
let estimateETHToUsdtClaimSwap = (await claimSwapRouterContract.methods.getAmountsOut(estimateUsdtToETHKlayswap, [KETHAddress, KUSDTAddress]).call())[1]
let KlayswapToClaimRes = new BigNumber(estimateETHToUsdtClaimSwap)
console.log(`diff1 : ${KlayswapToClaimRes.toString()}`)
if (KlayswapToClaimRes.isGreaterThan(tryKUSDTAmount)) {
    let txRes = await arbitragerContract.methods.swapKlaySwapToClaimSwap(KUSDTAddress, KETHAddress, tryKUSDTAmount).send(
        {
            from: keyring.address,
            gas: 5000000
        }
    )
    console.log(`try arbitrage klayswap -> claimSwap tx : ` + txRes.hash)
}

콜백함수 내 위 코드는 클레이스왑 → 클레임스왑 에서의 KUSDT→KETH→KUSDT 경로의 아비트라지 기회가 있는지 포착합니다.

클레이 스왑에서 10,000 KUSDT를 KETH로 바꾼 예측값(estimateUsdtToETHKlayswap)를 구합니다. 이 예측값을 다시 이용하여 클레임 스왑에서 KETH를 KUSDT로 바꾼 예측값(estimateETHToUsdtClaimSwap) 를 구합니다.

이때 estimateETHToUsdtClaimSwap 의 값이 tryKUSDTAmount 보다 크다면 아비트라지 기회가 생긴 것 이므로, Contract 섹션에서 설명했던 arbitragerContract의 swapKlaySwapToClaimSwap 함수를 통해 플래시론 및 아비트라지를 수행하게 됩니다.

let estimateUsdtToETHClaimSwap = (await claimSwapRouterContract.methods.getAmountsOut(tryKUSDTAmount, [KUSDTAddress, KETHAddress]).call())[1];
let estimateETHToUsdtKlayswap = await klayswap_USDT_ETH_ExchangeContract.methods.estimatePos(KETHAddress, estimateUsdtToETHClaimSwap).call()
let ClaimSwapToKlayswapRes = new BigNumber(estimateETHToUsdtKlayswap)
console.log(`diff2 : ${ClaimSwapToKlayswapRes.toString()}`)
if (ClaimSwapToKlayswapRes.isGreaterThan(tryKUSDTAmount)) {
    let txRes = await arbitragerContract.methods.swapClaimSwapToKlaySwap(KUSDTAddress, KETHAddress, tryKUSDTAmount).send(
        {
            from: keyring.address,
            gas: 5000000
        }
    )
console.log(`try arbitrage claimSwap -> klayswap tx : ` + txRes.hash)
}

클레임스왑 → 클레이스왑 의 경우 역시 마찬가지로 클레임 스왑에서 10,000 KUSDT를 KETH로 바꾼 예측값(estimateUsdtToETHClaimSwap) 를 구합니다. 이 예측값을 다시 이용하여 클레이 스왑에서 KETH를 KUSDT로 바꾼 예측값(estimateETHToUsdtKlayswap) 를 구합니다. 그 후 과정은 arbitragerContract의 swapClaimSwapToKlaySwap를 이용하는 것 외에 이전 설명과 동일합니다.

마치며

아비트라지 기회는 위에서 언급한 클레이스왑↔클레임스왑 사이에만 존재하는것은 아닙니다. 또한 KUSDT↔KETH 페어 사이에서만 발생하는 것 역시 아닙니다. 다양한 Dex(클레이스왑, 클레임스왑, 팔라, UFO, …) 과 다양한 페어(KUSDT↔KETH, KLAY↔KUSDT, CLA↔KUSDT, …)를 이용하여 여러 기회를 포착할 수 있습니다.

따라서 봇 코드를 고도화 다양한 경로를 감시 및 아비트라지 할 수 있으며, 더욱 고도화 한다면 고정된 시도금액 ( 예시에서 10,000 KUSDT ) 이 아닌 더 큰 금액 또는 작은 금액으로 가변적으로 조절할 수 있습니다.

플래시론은 이러한 아비트라지 기회를 포착하고 실행할때, 무담보 대출을 실행하여 자본이 없거나 부족하더라도 아비트라지를 안정적으로 성공하도록 도와줍니다.

위 예시 코드에서는, Arbitrager contract에 쌓인 토큰을 꺼내는 함수 및 보안을 위한 사항들이 고려되어있지 않으므로 사용시에는 적절한 수정을 통해 사용하는 것을 권장합니다.

Last updated