0件ヒットしました

    初めてDEXアビトラに挑戦した時の思い出を書いてみたいと思います。 かなりヘッポコなミスもあり、非技術系の人にも楽しく読んでもらえると思います。 当時のコードもほぼそのまま公開しちゃいます!

    「仮想通貨botter Advent Calendar 2022」の8日目に寄稿しています。

    簡単に言うと

    • DEXに(今と比べたら)全然人がいない時に参入できたよ。
    • イーサリアムメインネットのアビトラbotに初挑戦して頑張ったら儲かった。
    • 今これと同じことをやっても当然儲からないけど、なんかの参考にしてくださいね。

    簡単な自己紹介

    良い機会なんで簡単に自己紹介をしておきます。

    • 専門は電子工学(そっち系の本業あり)
    • プログラミングが好きだったので独学で色々遊んできた。
    • プログラマーとしてお金が絡む開発運用に携わった経験はなし。
    • 本業は割とサボって遊んでいる
    • 英語は割と出来る

    botterとして総合的なレベル感で言うと雑魚です。とにかくハイレベルなことは出来ません。 色々と手を伸ばすのが好きなので広く浅く知識がある感じでした。

    運良く本業がフィットして、DEX参入当時から7割くらい仕事をサボっても問題ない感じのポジションを取れていたので とにかくひたすら時間を注ぎ込んで何とかキャッチアップしてきました。

    当時のDEX界隈の雰囲気

    私が Uniswap について興味を持ったのは、2020年夏、いわゆる最初のDeFiバブル真っ只中でした。 その頃、センチメントを測るために Uniswap の公式ツイッターのフォロワー数を計測していたグラフが出てきたんですけど、 Uniswap の公式アカウントのフォロワー数が3万人とかしかいなかったようです。

    Image from Gyazo

    界隈のマニアは大体 Uniswap を使ったことがあるけど、一般的には近寄り難いものって感じの空気感だったように思います。 特に従来の強いシステムトレーダーの人たちはあまり Uniswap には興味がないという感じだったかと思います。 心理的な参入障壁ってやっぱり大きいですよね。これが今思えば非常に美味しい状況でした。

    ちなみに当時 AMPL というトークンがマニアの間でめちゃくちゃ流行っていました。 こっちの公式アカウントのフォロワー数は当時 1万人くらいですね。(右軸赤線)

    今だったら、かなり弱い DeFi プロジェクトでもすぐにフォロワー数1万人くらい行くと思うので、 今の感覚で行くと、ほんとに流行ってたのかな?という気になるんですが、もうめちゃくちゃに流行っていたんです。

    このあたりから見ても当時のバブルはまだまだマジョリティ層の参入していない段階だったことがわかります。 当時は全くどんな未来が待っているのか想像もつきませんでしたが。

    Image from Gyazo

    最初に自分がやっていたこと

    自分は最初は特にDEXトレードbotには興味がなく、むしろ裁量トレードの補助としてUniswapの監視を行うことから入りました。 当時はほんとにUniswapの使い方なんかもみんな今以上に適当で、オンチェーン分析もほとんど見かけない感じだったので、 これを見てセンチメントを見てるだけでめちゃくちゃ勝てたんですよね。

    ただ、そんな中で、イーサリアムメインネットでのフロントランニングとかできないかな〜というのだけは少し試してました。 今思うとなんでそんな難しいことやるんだって感じですが、ロマンがあるからやりたかったんです笑

    ちなみにフロントランニングというのは、他の人の注文を見てから、先回りして直前に同じ注文を入れて相手に不利なレートで買わせたりする手法です。最悪ですね笑。

    フロントランニングというものは従来サービスプロバイダーの専売特許であったわけです。要は取引所とか、ブローカーにしかできないことでした。 しかし、イーサリアム上のDEXでは注文をブロックに取り込んでもらうために基本的にまずパブリックに公開する必要があります。 つまり、イーサリアムでは誰でも他人の注文を確定する前に検知できます。

    それって、フロントランニングが民主化されたってことやん!やべえ!かっこいい!やりたい!って感じでした。

    そういうわけで、botを頑張って作って走らせたりしていたのですが、僕の未熟すぎるbotはとんでもない詐欺トークンを掴まされてしまいました。 その時に書いたのが以下の記事です。ちなみにこの記事を書いたらBinance のボスが僕のTwitterアカウントをフォローしてきました。 (彼は東証で働いていたし、日本語が読めるみたいですね。情報アンテナの高さにビビるんですが)

    Image from Gyazo

    フロントランニングでは(少なくとも現状では)一度どうしてもターゲットと同じトークンを買う必要があるので、こういうトークンをつかまされるリスクがあったわけですが こういったハメ技みたいな手法は当時一般には知られていませんでした。(そういうことにしておいてください)

    まあちゃんとアカウントを分けていたので、ごく軽傷だったのですが、このような攻撃を防ぐのはちょっと自分には難しいと感じたので フロントランニングはやめました。全然儲かりませんでした。 撤退撤退! もちろんメジャーな安全な通貨をターゲットに続けることは出来たんですが、そこはもっと強い人がいるんで無理だったんですよね。

    ちなみに記事の中で、こういった詐欺の判別は難しいと書いてますが頑張れば出来るっぽいですね。このあたりはまあ実力がないので諦めて正解ですね。 (→Rosさん @Ros_1224 がちょうど昨日の記事で解説をしてくれてました!すげえ!必読です!)

    収益機会(仮説)の発見

    そんな風に遊ぶ日々の中、DeFiの盛り上がりは止むことを知らず、Uniswapが広めたエアドロップという手法が界隈を席巻していた ある日のことでした。

    とあるマイナーDeFiトークンがエアドロップされ、わたしは何の気なしに、そのUniswap でのレートと、CEXでのレートを表示して遊んでいました。 すると、非常に大きな乖離が観測できることに気付きました。 つねにCEXのレートの方が常時高くなっているような状態の中で、時折非常に大きな乖離が出ます。

    この時、頭に思い浮かんだのは、Twitterでかつて見かけた一つの画像でした。 草コインという名前の提唱者である田中さん(@tanaka_bot_1)が作った画像だと思います。

    Image from Gyazo

    これだけ見るとなんのことか分からないかもしれませんが、この画像には当時の状況が非常に的確に表されていると思っています。 (元ツイの有料サロンであるコインランの記事は読んでいないので以下はわたしの理解です)

    • 前提として、技術革新によりDeFi(裏インターネット)が盛り上がっている。
    • エンジョイトレーダー層は、DeFiに参入障壁があり、CEXにしか生息できない。
    • 性格の悪いDeFiオタクたちは情報格差を利用してDeFiで草トークンを荒稼ぎしている。
    • その結果起こる草トークンと金の流れに注目!

    もちろん、これは仮説でしかないわけですが、実際にトークンの値段が、CEX高、DEX安である現象を観測すると、想像以上にこういう流れは強いのではないかと思われました。

    そして、この図の中で省略されている要素があることに気付きました。 大方のDeFiオタクたちは面倒くさがり屋なので、CEXにトークンを直接持ち込んで売るようなことはしない という点です。 特にエアドロップのような無料で手に入ればブツはさっさとUniswapで処分しちまうというのが、自分のイメージするDeFiユーザの人間像でした。

    つまり、こういう太い金の流れがあるはずでした。

    Image from Gyazo

    Uniswapから草を取引所に持ち込んで流通させる 運び屋 労苦を厭わず世間の要望に応える人がいるのです。 特にトークンのエアドロップは、当時は予告なく突然(ノリで)行われることもあり、 CEXに在庫があまりない状態から始まったりすることもありました。

    結論として、エアドロトークンをエアドロ直後にDEXからCEXに持ち込んで、サプライ・アンド・デマンドのギャップを埋めるマンになれば儲かるのでは!という仮説 が生まれ、 急遽botを作ることになりました。

    もう少し真面目に書いておくと、Uniswapの流動性が高くない中で、トークンをもらった人が適当に投げ売りをするので、 Uniswapのプールのレートがごく少数の売りで一瞬ものすごく変動します。 トークンをもらった人は、かなり情報感度の高い人なので本質的にガバナンス()トークンに価値がないことが分かっているし、 そもそも値付けの根拠となる要素が一切なく、また一番大事なこととして無料でトークンを受け取っているので 投げ売りを行うインセンティブがめちゃくちゃ強いという点に注目しました。

    ちなみに本記事を書く際に、この図は所在がわからなくなっていたのですが、Twitterで聞いたらすぐに、 くりぷとべあーさん@cryptoo_bear)が教えてくれました。流石ですね!

    Image from Gyazo

    ここまでのまとめ

    • Uniswapの監視をしたりして遊んでいた。
    • エアドロトークンをDEXからCEXに運んだら儲かりそうな気がしたので急遽botを作り始めた。

    突然の思いつきからbotを動かすまでの過程をまとめておきます。

    botの仕様

    シンプルです。CEX高、DEX安を仮定する訳ですから、以下のようにします。

    とはいえ、自分はアビトラは初挑戦だったので作りながら仕様を検討して行った感じですね。

    • CEXにトークンを置く
    • ウォレットにUniswapプールのペアとなるトークンを置く
    • CEXの板を常時監視。
    • DEXのレートを毎ブロック監視。
    • 次のブロックでDEXでトークンを買った場合の実効約定価格を計算。
    • CEXでトークンを成行注文で売った場合の約定値を板から計算。
    • 十分に利益が出そうなら、DEXにスワップトランザクションを発行(Exact Outputで)
    • トランザクションが成功したら、CEXで成行注文ですぐに売却
    • CEXから売却後の金をウォレットに送金
    • ウォレットからトークンをCEXに送金
    • 最初に戻る

    ちなみに、トークンの価格変動リスクをモロに取ってますが、これは草トークンと向き合う以上は仕方ないことです。

    また自分はイーサリアムメインネットのフルノードをクラウドで運用していたので、そことやりとりしています。 それが大きな優位性だったのかは分かりません。 まあ接続がめちゃくちゃ安定しているという安定感はありました。

    突貫工事による開発

    その当時の開発ログが以下の様な感じでした。上のログほど新しいログになりますので、一番下から見ると流れがわかります。

    タイムスタンプを見ると、ちょうど24時間くらいで最初のバージョンを開発 できたようです。(Fri 03:56) 正直今見ると時間かかりすぎなんですが、とにかくその時持っていた知識をつなぎ合わせてなんとか作ったのがよくわかります。

    また、これを見て封印していた記憶が蘇ってきたんですが、途中でフロントランニングされました笑(あとで詳しく説明します)

    Image from Gyazo

    Image from Gyazo

    最初のコード(バージョン0)

    ここまで開発した時点のコードはこんな感じでした。

    GitHubからオリジナルのコードを取ってきて編集してあります。 ちなみに当時とは Uniswap V2 SDKの仕様が大きく変わっているためこのままでは動かないと思います。あと編集した後に 自分で読んでもなんでこんなことしてるんだ?と意味がわからない部分があります笑

    このバージョンでは

    • UniswapとCEXの鞘を簡易的にモニター
    • Uniswapでまあまあうまく注文が入るようにはした。
    • まずは、Uniswapで鞘を発見しオーダーするのを優先した感じか。
    • 多分このバージョンでは CEXのオーダーは手動で入れていた。
      • DEXのコードをまともにするだけで多分手一杯だったんだと思います。
    • 送金などは手動で行なっていた。 送金はGOXの可能性があるので、特に優先度が低かったです。
    • DEX-CEXの鞘が10%ある場合にDEXに注文を入れる仕様。 これは見返して驚きました。どんだけ鞘があるんだ笑

    DEXのオーダーを自動化するというのは当時は大きな優位性だったので限られた実力の中でそこから着手するのは順当ですね。 DEXの注文確定後にCEXのオーダーを入れる場合、少し遅れても問題ないですが、 DEXのレートが大きく変動した際の大きな鞘を手動で思い通りに取るのは当時のイーサリアムメインネットの状況では全く不可能でした。

    簡単な解説をつけておきました。そこだけ読めば雰囲気がわかります。

    import Redis from 'ioredis';
    import Web3 from 'web3';
    import IUniswapV2Router02 from '@uniswap/v2-periphery/build/IUniswapV2Router02.json'
    import { ChainId, Fetcher, Token, Pair, Route, TokenAmount, WETH, Price } from '@uniswap/sdk'
    import { ethers } from 'ethers';
    import { logger } from '../logging';
    import { erc20Abi } from '../erc20';
    import { UNISWAP_V2_ROUTER} from '../constants';
    import { GAS_LIMIT } from '../constants';
    import { gasOracle, estimateGasCategory } from '../ethgasstation';
    import { sendRawTransaction, buyTokenTransactionExactOut, decodeSwapLogs } from "../order";
    
    const redis = new Redis();
    const PROVIDER = new ethers.providers.JsonRpcProvider();
    const web3 = new Web3('ws://localhost:8546');
    const GAS_PRICE_MARGIN = 10;
    
    // Swap config
    const token_address = process.env.TOKEN_ADDRESS;
    const token_address_base = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
    const routerContract = new web3.eth.Contract(IUniswapV2Router02['abi'], UNISWAP_V2_ROUTER);
    const chainname = 'mainnet';
    const my_address_from = process.env.MY_ADDRESS_FROM;
    const my_address_to   = process.env.MY_ADDRESS_TO;
    const my_key          = process.env.MY_KEY;
    const my_order_size_real = process.env.TOKEN_ORDER_SIZE;
    
    // Param
    const ORDER_BLANK_BLOCKS = 6;
    let last_order_block = 0;
    
    let ordered = false;
    
    // Get symbol name
    const erc20 = new ethers.Contract(token_address, erc20Abi, PROVIDER);
    
    logger.info(`Token Address             : ${token_address}`);
    
    //
    // ---------------------------------------------------------------------------------------
    // (解説)以下のコードブロックは、イーサリアムの新規ブロックを受信するごとに実行されます。
    // ---------------------------------------------------------------------------------------
    //
    const monitor_new_block = async(block) => {
    try {
      // Validation (解説)たまに空のブロックが受信されるので対策
      if (!block) { return; }
    
      // Get current timestamp
      const now = new Date();
    
      // Just in case fetch token object everytime
      const token = await Fetcher.fetchTokenData(ChainId.MAINNET, token_address, PROVIDER);
      const token_decimals = token.decimals
    
      // TODO(解説)なぜTODOなんだろうか?
      const token_symbol = await erc20.symbol();
    
      //
      // ---------------------------------------------------------------------------------------
      // (解説)Uniswapのプールペアの状態を取得しています。当然ですがブロックごとに必ず最新の状態を取得する必要あり
      // ---------------------------------------------------------------------------------------
      //
      const token_base = await Fetcher.fetchTokenData(ChainId.MAINNET, token_address_base, PROVIDER);
      const pair_base = await Fetcher.fetchPairData(token_base, token, PROVIDER);
      const price_base = pair_base.priceOf(token_base).toSignificant();
      const price_base_token = pair_base.priceOf(token).toSignificant();
    
      logger.info(`Block                              : ${block.number}`);
      logger.info(`Uniswap [Token/base]               : ${price_base_token}`);
    
      //
      // ---------------------------------------------------------------------------------------
      // (解説)なんと最初は、板を見ずに CEX の約定履歴だけで取引判断をしていたようです笑
      // また、理由は忘れましたが、直接APIを叩かずに、Redisから値を取得していたようです。
      // ---------------------------------------------------------------------------------------
      //
      // Buy it if there is enough price delta.
      const last_buy = await redis.get(`TOKEN-USD-buy`);
      logger.info(`CEX (ticker buy) [Token/USD]       : ${last_buy}`);
    
      //
      // ---------------------------------------------------------------------------------------
      // (解説)単純に CEX と uniswap のレート差をを見ています。
      // CEXと10%以上の鞘があれば取引するみたいです。どんだけ鞘があるんだ笑
      // ---------------------------------------------------------------------------------------
      //
      // If there is enough price delta
      const delta = last_buy / price_base_token - 1
      if (delta > 0.1) {
        logger.info(`Large delta!!               : ${delta}`);
    
        //
        // ---------------------------------------------------------------------------------------
        // (解説)当時はガス代を予測する必要がありました。
        //  EIP-1559 の導入前だったので、ガス代を適切に予測するのがトランザクションをちゃんと成功させる鍵でした。
        //  このために ether gas station という大手の予測サイトが公開していた予測コードを自分のサーバで走らせて
        //  Oracle として利用していました。ここは結構こだわっていた記憶があります。
        //  普通にどこかのサイトのAPIを使えばいいんですが、興味もあったので自前でここはガス代予測をしていました。
        //  ガス代の設定を10で割っているのは、なぜかオラクルが10GWEI単位で値を返す仕様だったためです。
        // ---------------------------------------------------------------------------------------
        //
        // Make order
        logger.info(`Gas Oracle (fastest)        : ${gasOracle.fastest/10}`);
        const buy_gas_price = web3.utils.toWei((gasOracle.fastest/10 + GAS_PRICE_MARGIN).toString(), 'gwei');
    
        logger.warn("Make order transaction.");
        const my_order_size_wei = web3.utils.toWei(`${my_order_size_real}`);
    
        try {
          const token_balance = await erc20.balanceOf(my_address_from);
          const token_balance_eth = web3.utils.fromWei(String(token_balance));
          logger.info(`Balance : ${token_balance}`)
    
          // Check last order
          if (last_order_block != 0 && (block.number - last_order_block) < ORDER_BLANK_BLOCKS) {
            logger.warn(`Last order block is too close. Last order : ${last_order_block}  This block : ${block.number}`)
            return
          }
    
          //
          // ---------------------------------------------------------------------------------------
          // (解説)
          // これは多分デバッグ用のフラグですね。
          // 一回だけ注文をしたら以降は鞘を検知しても動かないようにしていて、テストをしていたようです。
          // 多分ローカルでここだけ編集したりして動かしていると思います。
          // ---------------------------------------------------------------------------------------
          //
          last_order_block = block.number
          if (ordered) {
            return
          }
          ordered = true;
    
    
          //
          // ---------------------------------------------------------------------------------------
          // (解説)
          // 指定した条件でTXオーダーを作って、署名している。
          // ---------------------------------------------------------------------------------------
          //
          const tx_buy = await buyTokenTransactionExactOut(web3, PROVIDER, my_address_from, my_address_to, token_base, token, my_order_size_wei, pair_base, '1', buy_gas_price, GAS_LIMIT); // 0.01% slippage
    
          //
          // ---------------------------------------------------------------------------------------
          // (解説)TXを発行して、confirmationを待つ。Slippageでエラーになると失敗。
          // この段階では、CEXのオーダーが入ってないので手動でCEXではオーダーしていた様子笑
          // ---------------------------------------------------------------------------------------
          //
          const receipt_buy = sendRawTransaction(web3, chainname, tx_buy, my_key)
          .once('transactionHash', txHash => { logger.info('Buy transactionHash:', txHash) })
          .on('confirmation', (confirmationNumber, receipt) => {
            logger.info('Buy confirmations:', confirmationNumber)
            if (confirmationNumber == 1) {
              logger.warn(`Set open position status for ${token_symbol}`);
              // redis.set('tokens.' + token_symbol, `BOUGHT at ${receipt.logs.hash}`);
              redis.set('tokens.' + token_symbol, `BOUGHT`);
            }
          })
          .on('error', err => {
            logger.error(err);
            logger.error(`Buy Transaction failed for ${token_symbol} from ${my_address_from}. This is usually minimum-amount-out violation.`);
          })
          // .once('receipt', receipt => { logger.info('Buy receipt:', receipt) })
          .once('receipt', async receipt => {
            // Log and max/min limit config
            logger.warn('Buy receipt was received')
            logger.trace('Buy receipt Logs:', receipt.logs)
            const [in_wei, out_wei] = decodeSwapLogs(receipt.logs)
            logger.info('Amount IN [wei]    :', in_wei)
            logger.info('Amount IN [ether]  :', web3.utils.fromWei(in_wei))
    //        logger.info('Amount OUT [wei]   :', out_wei)
            logger.info('Amount OUT [ether] :', web3.utils.fromWei(out_wei))
          })
        } catch(err) {
          // It loos like error is not caught in this loop if transaction fails. Check .on('error')
          logger.error("Catch!");
          logger.error(err);
        }
      }
    
      } catch (error) {
        if (
          (error.message == 'call revert exception (method="symbol()", errorSignature=null, errorArgs=[null], reason=null, code=CALL_EXCEPTION, version=abi/5.0.2)') ||
          (error.message == 'call revert exception (method="getReserves()", errorSignature=null, errorArgs=[null], reason=null, code=CALL_EXCEPTION, version=abi/5.0.2)')
        ) {
          // This is known issue. Still need to check how to supress this message.
          logger.error(error.message);
        } else {
          logger.error(error);
        };
      }
    };
    
    //
    // ---------------------------------------------------------------------------------------
    // (解説)
    // 新しいブロックヘッダーの受信のたびに先ほどのメソッドを実行するように登録
    // ---------------------------------------------------------------------------------------
    //
    const run = async() => {
      // Subsribe the Websocket
      const sub_new_block = await web3.eth.subscribe('newBlockHeaders', (error, result) => {
        if (!error) {
        }
      });
      sub_new_block.on("data", monitor_new_block);
    };
    
    run()

    さらに24時間後

    ひいこら言いながら24時間後になんとか次のコードを書き上げました。 CEX周りのコードを次に改善していってますね。振り返ると自分で面白いです。 多分予想以上に儲かったので体力を削って頑張ってますね。

    Image from Gyazo

    Image from Gyazo

    バージョン1

    このバージョンを バージョン1 と呼びましょう。このバージョンでは、

    • DEX周りのコードにちょっと改良が入りました。
    • ちゃんとCEXの板を見て取引をするようになりました。
    • CEXの成行注文を自動化しました。
    • 3%の鞘があればTXを発行するようになっていますね(急速に鞘が落ちているのに注目)。
    • 送金はまだ自動化されていません。
    import Web3 from 'web3';
    import IUniswapV2Router02 from '@uniswap/v2-periphery/build/IUniswapV2Router02.json'
    import { ChainId, Fetcher, Token, Pair, Route, TokenAmount, WETH, Price } from '@uniswap/sdk'
    import { ethers } from 'ethers';
    import { logger } from '../logging';
    import { erc20Abi } from '../erc20';
    import { UNISWAP_V2_ROUTER} from '../constants';
    import { GAS_LIMIT } from '../constants';
    import { gasOracle, estimateGasCategory } from '../ethgasstation';
    import { sendRawTransaction, buyTokenTransactionExactOut, decodeSwapLogs } from "../order";
    
    const PROVIDER = new ethers.providers.JsonRpcProvider();
    const web3 = new Web3('ws://localhost:8546');
    const GAS_PRICE_MARGIN = 10;
    
    // Swap config
    const token_address = process.env.TOKEN_ADDRESS;
    const token_address_base = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
    const routerContract = new web3.eth.Contract(IUniswapV2Router02['abi'], UNISWAP_V2_ROUTER);
    const chainname = 'mainnet';
    const my_address_from = process.env.MY_ADDRESS_FROM;
    const my_address_to   = process.env.MY_ADDRESS_TO;
    const my_key          = process.env.MY_KEY;
    const my_order_size_real = process.env.TOKEN_ORDER_SIZE;
    
    // CEX
    const auth = XXXXX;
    const client = new CEXClient(auth);
    const EXCHANGE_SYMBOL = 'TOKEN_NAME';
    const EXCHANGE_PAIR = 'TOKEN_PAIR_NAME';
    
    // Param
    const ORDER_BLANK_BLOCKS = 60;
    const PRICE_MIN_DELTA = 0.03; // ratio
    
    //
    let last_order_block = 0;
    let ordered = false;
    let waiting_for_receipt = false;
    
    // Get symbol name
    const erc20 = new ethers.Contract(token_address, erc20Abi, PROVIDER);
    
    logger.info(`Token Address             : ${token_address}`);
    
    //
    // -------------------------------------------------------------
    // (解説)
    // CEXのオーダーブックから、成り行き注文の実行約定価格を計算
    // -------------------------------------------------------------
    //
    const check_book = async (order_size) => {
      const resp = await client.getOrderBook( EXCHANGE_PAIR );
      // Check average execution price for fixed size.
      let sum_execution = 0;
      let sum_size = 0;
      for (const x of resp.bids) {
        const price = parseFloat(x[0]);
        const size = parseFloat(x[1]);
        if ( sum_size + size > order_size ) {
          sum_execution += price * (order_size - sum_size);
          break;
        }
        sum_size += size;
        sum_execution += price * size;
      }
      return sum_execution / order_size; // Average execution price
    };
    
    //
    // -------------------------------------------------------------
    // (解説)
    // CEXのトークンの残高を取得
    // -------------------------------------------------------------
    //
    const check_balance = async(symbol) => {
      const account = await client.account();
      return account.filter(x => x.coin == symbol)[0].balance;
    };
    
    //
    // -------------------------------------------------------------
    // (解説)
    // CEXで成り行きでトークンを売る
    // -------------------------------------------------------------
    //
    const market_sell = async(size) => {
      const resp = await client.order({
        pair: EXCHANGE_PAIR,
        side: 'sell',
        size: size,
        type: 'market'
      });
      logger.info(resp);
    };
    
    //
    // ---------------------------------------------------------------------------------------
    // (解説)以下のコードブロックは、イーサリアムの新規ブロックを受信するごとに実行されます。
    // ---------------------------------------------------------------------------------------
    //
    const monitor_new_block = async(block) => {
    try {
      // Validation (解説)たまに空のブロックが受信されるので対策
      if (!block) { return; }
    
      // Get current timestamp
      const now = new Date();
    
      // Just in case fetch token object everytime
      const token = await Fetcher.fetchTokenData(ChainId.MAINNET, token_address, PROVIDER);
      const token_decimals = token.decimals
      const token_symbol = await erc20.symbol();
    
      //
      // ---------------------------------------------------------------------------------------
      // (解説)Uniswapのプールペアの状態を取得しています。当然ですがブロックごとに必ず最新の状態を取得する必要あり
      // ---------------------------------------------------------------------------------------
      //
      const token_base = await Fetcher.fetchTokenData(ChainId.MAINNET, token_address_base, PROVIDER);
      const pair_base = await Fetcher.fetchPairData(token_base, token, PROVIDER);
      const price_base = pair_base.priceOf(token_base).toSignificant();
      const price_base_token = pair_base.priceOf(token).toSignificant();
    
      // logger.log(log_level, `Price [Token/base]        : ${price_base}`);
      logger.info(`Block                              : ${block.number}`);
      logger.info(`Uniswap [Token/base]               : ${price_base_token}`);
    
      //
      // ---------------------------------------------------------------------------------------
      // (解説)ちゃんと成行注文の実行約定価格を計算して、DEXとのレートの差を計算するようになりました。
      // ---------------------------------------------------------------------------------------
      //
      // Buy it if there is enough price delta.
      const average_execution_price = await check_book(my_order_size_real);
      logger.info(`CEX      (expected) [Token/USD]    : ${average_execution_price}`);
    
      // If there is enough price delta
      const delta = average_execution_price / price_base_token - 1
      if (delta > PRICE_MIN_DELTA) {
        logger.warn(`Large delta!!               : ${delta}`);
    
        //
        // ---------------------------------------------------------------------------------------
        // (解説)当時はガス代を予測する必要がありました。
        //  EIP-1559 の導入前だったので、ガス代を適切に予測するのがトランザクションをちゃんと成功させる鍵でした。
        //  このために ether gas station という大手の予測サイトが公開していた予測コードを自分のサーバで走らせて
        //  Oracle として利用していました。ここは結構こだわっていた記憶があります。
        //  普通にどこかのサイトのAPIを使えばいいんですが、興味もあったので自前でここはガス代予測をしていました。
        //  ガス代の設定を10で割っているのは、なぜかオラクルが10GWEI単位で値を返す仕様だったためです。
        // ---------------------------------------------------------------------------------------
        //
        logger.warn(`Gas Oracle (fastest)        : ${gasOracle.fastest/10}`);
        const buy_gas_price = web3.utils.toWei((gasOracle.fastest/10 + GAS_PRICE_MARGIN).toString(), 'gwei');
        const my_order_size_wei = web3.utils.toWei(`${my_order_size_real}`);
    
        try {
          // Check wallet token balance
          const token_balance = await erc20.balanceOf(my_address_from);
          const token_balance_eth = web3.utils.fromWei(String(token_balance));
          logger.info(`Wallet Balance              : ${token_balance}`)
    
          //
          // ---------------------------------------------------------------------------------------
          // (解説)
          // CEXにトークンの残高がない場合(DEXで買ったトークンのCEX側への送金が終わっていない場合)
          // ちゃんとオーダーをスキップするようになりました。
          // ---------------------------------------------------------------------------------------
          //
          // Check exchange balance
          const exchange_balance = await check_balance(EXCHANGE_SYMBOL)
          logger.info(`Exchange Balance            : ${exchange_balance}`);
          if (exchange_balance < my_order_size_real) {
            logger.info("Exchange balance is less than order size. Skip order");
            return
          }
    
          //
          // ---------------------------------------------------------------------------------------
          // (解説)イーサリアムのブロックは立て続けにいくつも受信する可能性があります。
          // また、トランザクションの成否がわかるまでの待ち時間が非常に長いです。
          // そのため、非同期に呼び出された際に同じ収益機会を何度も発見して、TXを何度も発行する可能性があります。
          // これを防ぐため、グローバル変数を利用して、オーダーを抑制しています。
          // あまりいい方法ではないですが、まあ問題なく動いていたような記憶があります。
          // ---------------------------------------------------------------------------------------
          //
          // Check transaction submission status
          if (waiting_for_receipt) {
            logger.warn("Skip order until receipt is received for previous transaction.")
            return
          }
    
          //
          // ---------------------------------------------------------------------------------------
          // (解説)
          // これは多分デバッグ用のフラグですね。
          // 一回だけ注文をしたら以降は鞘を検知しても動かないようにしていて、テストをしていたようです。
          // 多分ローカルでここだけ編集したりして動かしていると思います。
          // ---------------------------------------------------------------------------------------
          //
          // Skip execution if it's executed.
          if (ordered) {
            return
          }
          ordered = true;
    
          //
          // ---------------------------------------------------------------------------------------
          // (解説)
          // 指定した条件でTXオーダーを作って、署名している。
          // ---------------------------------------------------------------------------------------
          //
          const tx_buy = await buyTokenTransactionExactOut(web3, PROVIDER, my_address_from, my_address_to, token_base, token, my_order_size_wei, pair_base, '1', buy_gas_price, GAS_LIMIT); // 0.01% slippage
    
          //
          // ---------------------------------------------------------------------------------------
          // (解説)TXを発行して、confirmationを待つ。主にSlippageでエラーになると失敗。
          // ---------------------------------------------------------------------------------------
          //
          waiting_for_receipt = true;
          const receipt_buy = sendRawTransaction(web3, chainname, tx_buy, my_key)
          .once('transactionHash', txHash => { logger.info('Buy transactionHash:', txHash) })
          .on('confirmation', (confirmationNumber, receipt) => {
            logger.info('Buy confirmations:', confirmationNumber)
            if (confirmationNumber == 1) {
              // Note that is function is executed again and again.
            }
          })
          .on('error', err => {
            logger.error(err);
            logger.error(`Buy Transaction failed for ${token_symbol} from ${my_address_from}. This is usually minimum-amount-out violation.`);
            logger.warn("Clear order flag.")
            waiting_for_receipt = false;
          })
          // .once('receipt', receipt => { logger.info('Buy receipt:', receipt) })
          .once('receipt', async receipt => {
            // Log and max/min limit config
            logger.warn('Buy receipt was received')
            logger.trace('Buy receipt Logs:', receipt.logs)
            const [in_wei, out_wei] = decodeSwapLogs(receipt.logs)
            logger.info('Amount IN [wei]    :', in_wei)
            logger.info('Amount IN [ether]  :', web3.utils.fromWei(in_wei))
            logger.info('Amount OUT [ether] :', web3.utils.fromWei(out_wei))
    
            //
            // ---------------------------------------------------------------------------------------
            // (解説)DEXのオーダーがマイナーによって無事ブロックに取り込まれたら、即座にCEXで成行注文
            // リトライの処理が入ってないですね。今ならちゃんと入れると思います。
            // ---------------------------------------------------------------------------------------
            //
            // Put order to exchange.
            logger.warn('Put order to exchange!')
            const resp = await market_sell(my_order_size_real);
            logger.info(resp);
    
            //
            // ---------------------------------------------------------------------------------------
            // (解説)もしかしたらCEXのオーダーが失敗するかもしれないので、フラグをここまでキープしていますね。
            // 一度もCEXオーダーがが失敗したことはないです。
            // ---------------------------------------------------------------------------------------
            //
            // Just in case, clear flag after order is exected on the exchange.
            logger.warn("Clear order flag.")
            waiting_for_receipt = false;
          })
        } catch(err) {
          // It loos like error is not caught in this loop if transaction fails. Check .on('error')
          logger.error(err);
        }
      }
    
      } catch (error) {
        if (
          (error.message == 'call revert exception (method="symbol()", errorSignature=null, errorArgs=[null], reason=null, code=CALL_EXCEPTION, version=abi/5.0.2)') ||
          (error.message == 'call revert exception (method="getReserves()", errorSignature=null, errorArgs=[null], reason=null, code=CALL_EXCEPTION, version=abi/5.0.2)')
        ) {
          // This is known issue. Still need to check how to supress this message.
          logger.error(error.message);
        } else {
          logger.error(error);
        };
      }
    
    
    };
    
    //
    // ---------------------------------------------------------------------------------------
    // (解説)
    // 新しいブロックヘッダーの受信のたびに先ほどのメソッドを実行するように登録
    // ---------------------------------------------------------------------------------------
    //
    const run = async() => {
      // Subsribe the Websocket
      const sub_new_block = await web3.eth.subscribe('newBlockHeaders', (error, result) => {
        if (!error) {
          // logger.debug("Web3 subscription for pending transactions succeeded");
          // logger.debug(result);
        }
      });
      sub_new_block.on("data", monitor_new_block);
    };
    
    run()

    (やらかし)Decimalsの罠とフロントランニング

    途中うっかりフロントランニングされてしまった件について簡単に解説しておきます。 これはスワップの入力にUSDTやUSDCを使う際に発生するミスです。

    USDTやUSDCは、通常のトークンと違い、Decimalsが6なので(通常は18)、この扱いを間違うと死にます。 要は、1ドルを指定する場合は、USDTやUSDCでは "1000000" です。 ERC20のデフォルトの仕様だったら "1000000000000000000" が1ドルの筈なんですが、 この二つのステーブルコインは仕様がクソだったのでDecimalsが6にされてしまいました。 DAIはちゃんと18なんですがね。

    自分のコードは、Exact Outputのスワップなので、Maximum Input Amountを指定するわけですが、 100ドルのつもりで通常の感覚で、100 * 10**18 としてしまうと、実際は、100 * 10**12 * 10**6 = 1000億ドルになります。 つまり、スワップ時に、「トークン XXX枚をスワップします!払う合計金額は100ドルまで!」と言ってるつもりで、 「トークン XXX枚をスワップします!払う合計金額は1000億ドルまで!」みたいなことになっていたわけです。 つまりどんなレートでもいいから売ってくれって注文になっていたってことです。アホすぎます。

    それを見た他のbotが、俺の先にトークンを買って俺に即座に高値で売りつけてきました。 完全に全額持ってかれてもおかしくなかったんですが、相手の資金が足りなかったようでまあなんとかそこまではいかずに済みました。

    このような事態を容易に引き起こすため、非標準のDecimalsを使うのはマジで害悪しかないのですが、 USDTやUSDCトークンをデプロイした開発者はよっぽどアホだったんでしょうね! (ということにしておいてください。)

    次のバージョン

    あとは省略しますが、次のバージョンでは送金周りの処理を改善したりしました。

    先ほどのバージョンまでは送金を手動で行なっていました。 とにかく暗号通貨の送金ではGOXが怖いので、CEX-DEXアビトラという初挑戦に対して送金の自動化はすぐには行えなかったからです。

    仕方がないので、トレードのたびに手動でトークンをCEXに送金する処理を行なっていました。 携帯に通知が飛ぶようにして、必要なら夜中でも起きて資金を移動させる作業をしていたような記憶があります。 これはマジで疲れました。流石に厳しいので程なく完全に送金部分まで自動化しました。

    後は取引履歴を自動で記録するようにしたりですね。

    収益

    当時の収益グラフはこんな感じでした。

    Image from Gyazo

    当初の鞘は本当に凄くて、例えるなら4%の鞘を1日50回以上拾えるようなあり得ない歪み方をしていました。

    仮に、100万円+100万円の資金を用意したとしましょう(USD + トークン現物)。 これに対して、4% x 50回のアビトラを回転させたら、1日で200万円儲かるわけです。

    これを見て分かる大事なことはただ一つ。

    • 歪みは綺麗に対数的に収束していく(1時間でも早く稼働させることが大事!

    ということでした。一日稼働が遅れるだけで、何百万円という機会損失になります。 開発に48時間かかったのはまあ厳しい目でいうとちょっとあれですね。 直後にbotを投入していたら収益が倍になるくらいのインパクトはあったはずです。

    当時の感想

    アビトラって思った以上に儲かるんだなあとほんとに驚きました。 極論すると、「お金が安く売ってる」みたいな状態なんでよく考えたら当たり前なんですがアビトラを一切やったことがなかったので新鮮でした。

    あとは、アビトラって人間の脆弱性に根ざしてるよなあなどと考えていました。 例えば、50分で終わると言われて頼んだ仕事が、52分かかったからって「2分損した!」と怒る人ってあんまりいないですよね。 でも50分と52分の差って4%もあります。まあ大抵の場合はそんな細かいことを気にしない方が人生ハッピーなんですが。 ただ、そういったルーズな感覚が、ダイレクトに金に絡むところに発生すると面白いことになるんだなみたいなことを考えながらせっせと歪みを埋める作業に勤しんでいました。

    良かったところ1:早く稼働させたこと

    まず第一のポイントとしては とにかく早く稼働させるのを優先した という点は悪くなかったですね。

    • 一番優位性のある部分だけとりあえず開発してぶっつけ本番で稼働させながら改善した。
    • フロントランニングされるミスを侵したが、結局はその損失はすぐに取り返せるくらい初期の歪みは大きくスピード感は大事だった。
    • 送金部分は手動で行うなど実力が足りない中でなんとか妥協して頑張った。
    • あまり重要度の高くない部分は、それなりに処理した。取引履歴はとりあえず手動で Google Sheets に突っ込むなど。
    • コードをシンプルにした。mempoolの監視を行なっていないがそこは妥協した(後述)

    ちなみに、Google Sheets、急いで bot を作る時にはかなりおすすめです。 なにせ簡単に手動でデータをいじれますから笑。 データの間違いを手で直す、削除する、単位を変える、フォーマットを変更する、なんでもやりたい放題です。 グラフ化が柔軟に綺麗にできますし色々な端末からでも気軽に開けます。 自分の場合は最初は手動で記録して、後からbotで追記できるように自動化しました。

    悪かったところ:準備が足りない

    今年亡くなった元男子サッカー日本代表監督のイビチャ・オシム監督の言葉でめっちゃ好きなのがあって、彼はこんなことを言ってるんですね。

    • 「ライオンに追われたウサギが逃げ出す時に、肉離れをしますか?要は準備が足らないのです」

    Image from Gyazo https://www.jfa.jp/football_museum/news/00029516/

    準備が足りないからフロントランニングされるし、ベストなタイミングでbotを稼働できないんですねえ。 実力がないからこそ準備が必要なんですが、今も本番が始まるまでやる気が出ないのが困ったものです…。

    そもそも、もしこの程度の貧弱コードでも、UNI がエアドロップされた時に用意できていたら それだけで一瞬で億ってたんじゃないのかな?って気がするんで、イマジネーションと準備が足りてないんですよね。

    良かったところ2:競合が少ない市場にいれた

    とにかく当時はアマチュアみたいな人が多かったと思うので良かったです。アマチュア同士の戦いですね。

    世の中には本当にすごい方がいて、例えば昨日Advent Calenderに投稿されていたRosさん @Ros_1224 なんかはわたしとは次元が違うなーといつも思っています。 例えばわたしが全国高校野球選手権大会を目指して頑張っている高校生だとしたら、Rosさんはメジャーリーガーです。

    これ冗談ではなく大問題です。なぜなら、DEXトレーダーのレギュレーションは一つしかないからです。 高校生専用リーグなんてものはないわけです。メジャーリーガーたちがウヨウヨしている中でなんとか立ち回る必要があるんです。

    なので、戦場選びについて考えるのはめっちゃ大事だと思います。

    例えば今のDEXトレードの花形はAtomic Arbitrageだと思いますが、これは普通の人はまじでやめた方がいいです。 メジャーリーガーたちが集う戦場です。

    Atomic Arbitrage は設備投資などは別として、リスクが少ない二択(TXが成功すれば利益確定、TXが失敗すればガス代を微損)でトレードを出来る構造なので みんながそれに気付いた今となっては凡人には厳しすぎる戦場です。

    mempool の監視と対戦相手

    上級者の人は、先ほどの私のコードを見て、いやそれ雑魚wって感じになると思います。mempoolを監視していない からです。 mempoolというのは未承認トランザクションの集まりのことで、要はまだ未確定の注文たちです。

    私のコードではレートが変化した次のブロックでスワップするのを狙っていますが、本来は投げ売りが発生したのと同じブロックにTXを入れるのがベストです。 なんですが、私は当時入力量の最適化の方法がわかっていなかったので出来なかったんですよね。(準備が足りない)

    しかしちゃんと二流にも収益が回ってくるのがバブルのすごいところです。

    DEXの取引は全てオープンなので、対戦相手がある程度分かるのが面白いところなんですが、 当時自分には一人対戦相手がいたようです。 この対戦相手は恐らくちゃんとmempoolの監視をして取引をしているようでした。 なので相手方が収益では上回っていたように思います。

    ただし、自分の方も相手の次を取って十分利益が出ていましたし、相手が取らない鞘の発生も検知できていました。

    なぜ鞘が取られないのか?

    なぜ比較的に技術優位であると思われる相手に大きな鞘が取られないのか調べたところ、面白いことがわかりました。

    どうも エアドロップ通貨を売る人たちの中で割と多くの人間が、Uniswapを直接使っていない のです。 じゃあどうするのかというと、アグリゲータと呼ばれる別の DeFi サービスから売っているらしいことがわかりました。 アグリゲータというのは要は仲介業者です。 つまり、売る人が Uniswapのコントラクトを直接呼んでいない ということです。

    つまりこういうことです。

    Image from Gyazo

    こうなると、mempoolの監視から入るのは難しくなります。アグリゲータへの注文の解析は難易度が高いからです。 恐らくEVMをシミュレーションしてプールの状態の変化を検知したりする必要があるんだと思います。

    当時はEVMシミュレーションまでやっているようなbotは少なかったような状況だと思います。 対戦相手はUniswapのコントラクト呼び出ししか監視して見ていなかったようです。 そのため、逆にアグリゲータ経由のプールの状態変化が見逃され、単純な手法でもかなりの鞘が取れていたようでした。

    半端に知識がある自分からすると、通貨を売るならUniswapに直接行って売るよねと思い込んでいました。 (最初に自分が立てた仮説においてもそうなっていましたよね)

    でも実際は人々はもっとずぼらだったのです! あまり仮説を絞りすぎずに色々と試してみることも大事みたいですね。

    収益機会を作ってくれる相手の行動をよく想像して観察するということは大事なんだなあと気付かされたところです。

    補足

    ちなみに今はもっとずっと競争が高まっているので同じことをやっても全く儲からないです。念の為。

    終わりに:コスパ大事

    上記経験を振り返って思うのは、情報格差があるとか、何らかの参入障壁がある市場を見つけることが出来た場合、 注ぎ込む人的リソースに対してのコスパが圧倒的に良い ということです。実力以上の利益が出せます。

    今botをやろうとしている方の情報発信を見ると、高頻度マーケットメイキングをやってみるみたいなのが非常に多いような気がするのですが、 そこに全振りするのではなくて、新規性があるかもしれない場所に少し目を置いておくのもいいんじゃないかなと思います。 なにせコスパがいいので!(実際そういう場所の方が技術的にも楽しい気がします)

    そんなUniswapの立ち上がりみたいな美味しい機会いつ来るかも分からないし、そうそうないでしょって思うかもしれませんが、最近でもありましたよね。あれが。Uniswapの後に超ビッグウェーブが。 (さあ、皆さんクイズです!)

    そう、NFTですね。

    筆者はNFTのことは下のツイートのように当初完全に馬鹿にしていたため完全に乗り遅れました。 もうこのツイートの頃は CryptoPunks のフロア価格が20ETHとかになってるんだから気付けよ。アホかって感じなんですが…

    ちなみに、この時の筆者の思い込みを信玄さん (@shingen_crypto) が優しく正してくれています。

    Image from Gyazo

    信玄さんほどの人が言うならってことでその後軌道修正できましたが、当初のNFT全否定の姿勢はかなり後悔しています。

    何が言いたいかというと、心理的な障壁や先入観というものは本当に恐ろしいということです。

    クリプト界隈では、この先もゲームチェンジャー的存在がいくつも生まれてくる可能性が高い と思うので、フラットな気持ちでお互い上手いこと頑張りましょう!

    新しい技術への向き合い方

    ちなみに、新しい技術へ目をかけておくと言っても Discord に張り付いて常に最新情報に触れるとかそういう必要はないと思います。

    例えば、Uniswapは現在 V3 と V2 が使われています。ということは、V1があったわけですね。 しかし、Uniswap が盛り上がり始めたのは、V2が出てからです。 自分が Uniswap を触り始めた時は、V1にも少し流動性が残っているくらいの時期で、GUIからV1を指定して取引することは一応できるけどほぼやらないみたいな状況でした。 (V1はプールと直接やり取りするような感じで、マルチホップができるルータとかがない感じでした)。

    要は、Uniswapは、V1の頃から追っかけたりしなくても、V2からで十分間に合ったわけです。

    本当に革新的な技術っていうのは、もう遅いかな?って思うくらいのところが始まりだったりするので、 まあなんか名前聞くなあってくらいの頃に気合を入れて調べてみればいいんじゃないかなと思います。 常に先端を追いかけていると気力が燃え尽きて結局本末転倒なことになりかねません。

    © 紫藤かもめ. All rights reserved.