はじめてのDEXアビトラbot ~C級botterからGOXまで~
仮想通貨botter Advent Calendar 2023 シリーズ2、2日目の記事です。
こんばんは。今年はほとんどbotに触れてないへっぽこ野郎です。毎日のお昼ご飯を少し豪華にしたいと思い仮想通貨を始めました。ハムスター飼育に一家言を持ってます。
これからお話する内容はちょうど1年前の出来事です。当時はまだbotが稼働していたので記事にはしませんでした。初めての手動アビトラを経てPythonを使ったbotを製作、のちにコントラクトbotにも挑戦して一生懸命小銭を拾ってたんですが、ひょんなことから自身のbotにお金を幽閉した経験を順に書き綴ろうと思います。
本記事は初心者向けです。これからDEX botを作ってみたい方向けに当時の私が参考にした資料のリンクや製作コードも載せてます。bot製作開始時のスキルは以下の通りです。プログラムからトランザクションが飛ばせる、スマコンのコードがまあまあ読めるくらいのスキルでした。
web3.pyを使ったDFKクエストbotを作ったことがある
web3.pyを使った草トークン上場戦botを作ったことがある
CryptoZombiesでスマートコントラクトを作ったことがある
1.初めてのアビトラ
2022年10月中旬、某discord鯖にてBCG運営が作ったチェーンに手動でも取れる鞘があると話題になりました。ステーブルトークン同士をスワップするだけで数十ドルの利益を得た経験は、私がアビトラに魅力を感じるのに十分でした。
寝ている間も鞘を取りたいなあ・・・そこで何故かあんな場末の鯖にいたqashさんにヒントを貰いながらアビトラbotを作ることにしました。当該チェーンでのqashさん視点のお話はこの記事に記載されていますので併せて読んでいただければと思います。
2.Pythonを使ったステーブル転がしbot
atomic arbは技術的にも資金的にもqashさんの存在的にも絶対無理!だったのでステーブル転がしbotを作ることにしました。ステーブル転がしとはステーブルトークン(USDCやUSDT)に価格差が発生した際にUSDC↔USDTとスワップして合計枚数を増やす戦略です。atomic arbでは取り切れない鞘を拾えますし技術力低めでも大丈夫だろうと考えてました。
atomic arbにはもちろん速度負けするので、せめて利益を最大化するための入力枚数を計算したいと思いウンウン唸っていたことを覚えています。最終的には二分探索を使って計算したのですが、だいぶ話が脱線するので巻末付録に置いておきますね。
3.Solidityを使ったコントラクトbot
同鯖で一緒にbotを作り始めた、さとさんと熾烈なバトルを繰り広げていました。両者ともパブリックRPC、Pythonを使っており勝ったり負けたりの繰り返し・・・。どちらかというと自分が負けることのほうが多く、速度を考える必要が出てきてしまい…どうにかしなきゃとこれまたウンウン唸っていました。
当該チェーンはガスが安く20万ガスで0.0027円程度のコストで済みました。ブロック生成は2秒間隔なので毎ブロックTx飛ばしても104.5円/日です。「pendingの読み方は分からないし自前ノードのランニングコストは逆立ちしても払えない・・・。待てよ?コントラクトbot作って毎ブロックTx飛ばせば、同一ブロックで鞘取れるんじゃないか・・・?さすがに100円/日は儲かるやろ!」ということでコントラクトbotを作ることにしました。
DEXはUniswapV2フォークだったので参考資料は容易に見つかりました。入力枚数の計算は同様に二分探索で実装したのですが計算コスト高すぎて400万ガス消費してました。ガス代だけで2090円/日です。破産します。さすがに使えないということで別の計算方法を模索することに・・・。ここで結構時間を使っちゃいました。今となっては入力枚数はとりあえず固定額にして早急にデプロイしたほうが良かったなと思います。旬は短いんです。
コントラクトbotにおける入力枚数の計算方法は仮想ペア(造語です)なるものを作ることにしました。巻末付録に考え方を置いておきますね。結局コントラクトbotが完成したのは11/17でした。
4.ステーブル転がしの覇者
コントラクトbotにより当該チェーンのステーブル転がしは支配しました。支配しても2800円/日くらいですが、それでも8.4万円/月でC級botterです!この報酬のために魔界チェーンにUSDTとUSDCをそれぞれ2500枚程置いていました。価格差が一方に偏ると原資がすぐ無くなりブリッジ回数が増えます。ブリッジフィーも1ドル/回と痛いのでそれなりの枚数を用意してました・・・相当リスキーですよね。やっぱりFlashswapを使ったatomic arbは原資いらずで魅力的です。
ブリッジフィー負けしないように利益率を考慮したり1Txで複数パスの鞘を取れるようにしたりとbotに手を加えまくりました。これは当たり前なんですが本番環境で安定稼働しているソフトウェアに手を入れる行為は相当慎重になるべきであり十二分な動作確認を行う必要があります。そんなことお構いなしに脳汁ドバドバだった自分は「別のチェーンに展開すればB級botterも夢じゃない!新しいPCの購入資金にできるな!」などと皮算用をしておりました。その矢先に事件は起こりました。
5.22ドル/ブロックの速さでGOX
2022年11月25日 21時頃でした。改良したコントラクトbotをデプロイしてから1時間程経過していたと思います。ウォレットを見ると2400ドル近くあったUSDCが1枚もありません。かといってUSDTの枚数は全く増えていません。その瞬間、手持ちのUSDT全てを別のウォレットへ送金しました。
恐る恐るエクスプローラを開きコントラクトbotのアドレスを検索しました。botの所有トークン欄には2411.388USDCの文字。「ああ、やったわ。」と項垂れたことを覚えています。利益率の考慮や複数パスの鞘取りといった改造にバグがあり、それらが絡み合って「スワップに失敗してもrevertせずユーザウォレットから送られてきたトークンは絶対返さない」コントラクトになってました。普通はトークンを引き出すための関数を用意しておくべきなのですが、
コントラクトbotにトークンを渡すのはスワップを試行するときのみとしてコントラクトにはトークンを保管しない
そもそも失敗するようなスワップは試行しない
仮にスワップ失敗してもrouterコントラクトがrevertしてくれるから安心
という「コントラクトにトークン残るなんてありえない!起こらないこと考えるよりさっさとデプロイや!絶対ヨシ!」みたいな慢心があり用意していませんでした。何かあっても緊急脱出用の関数さえあればどうにかなるのに・・・勉強代として2400ドル+税金は高すぎます。
利益率の考慮により失敗するスワップを試行するようになり、その失敗を回避するためにtry-catchで無理やり次のパスを調査するという場当たり的な対応。くわえて緊急脱出用の関数も用意していないという。正直あまりにも低レベルなミスの連発で物凄く恥ずかしいです。
この時もqashさんにはお世話になり、何とか救えないかと知恵を絞ってもらいました。その節は本当にありがとうございました。
6.終焉
コントラクトbotは修正後2月まで稼働してました。最後はガスプライスが64倍に設定されたり、人がいなくなって鞘が無くなったりで退場を余儀なくされました。約3ヶ月の稼働で利益は1400ドル+ゲームトークンの裁量トレードで1000ドル=2400ドル、損失のGOX分をトータルすると税金分マイナスという何ともホロ苦い結果となりました。
現在の当該チェーンはガスプライスは1024倍、ブロックあたり1Tx、ブリッジとして使われていたMultichainの崩壊などなど触る者皆傷つけるような惨状となっております。こんな場所に一時期最大7000ドル置いていたんです。怖いですよね。
DEXアビトラはTxさえ通れば必ず利益が出ます。なぜなら利益が出ないTxは無かったことにできるから。そんな中、Txが通る度に損をした自分のようなbotterがいることと、コントラクトbotでバグったら酷いことになることを覚えておいていただけると幸いです。ちなみに2023年11月27日現在、ブラックフライデーの波に乗りPCを新調しました。
【付録】
二分探索を使った入力枚数計算
入力枚数と利益をグラフにプロットすると上に凸な関数ということが分かります。USDC→USDTみたいな単一ペアなら微分すれば極大点を求められるのですが、経由するペアが多いと計算が複雑過ぎて解けませんでした。そこで二分探索を使って入力枚数を計算することにしました。以下は当時の検証に使ったコードと出力です。あくまでも近似値なのですが当時も今も妥協の塊である自分はこれで満足していました。
ただコレ、1回のループで2回スワップシミュレーションします。なのでオンチェーンで実装するとガス代がすんごいです。オフチェーンでも遅すぎるので実用性はないです。
# USDT->ETH->USDCのスワップを想定
# USDTをamountIn枚売ってUSDCをamountOut枚購入する
#第1 プールの枚数
balanceA = 30 # USDTの枚数
balanceB = 40 # ETHの枚数
#第2 プールの枚数
balanceC = 40 # ETHの枚数
balanceD = 50 # USDCの枚数
# amountInからamountOutを計算して利益を返す
def func(amountIn):
amountOut = amountIn * 997 * balanceB / (balanceA * 1000 + amountIn * 997)
amountOut = amountOut * 997 * balanceD / (balanceC * 1000 + amountOut * 997)
return amountOut - amountIn
def search(func, x_min, x_max, eps=1e-4, end=1e-3):
num_calc = 0
while (abs(x_min - x_max) > end):
x0 = (x_min + x_max) / 2
# 中心差分
df = (func(x0 + eps) - func(x0 - eps))
if df > 0: # 利益が増加するなら下限を更新
x_min = x0
else: # 利益が減少するなら上限を更新
x_max = x0
num_calc += 1
print(f'{num_calc:02} {x0:.5} {func(x0):.8}')
return x0
amountIn = search(func, 1, 100)
print(f'amountIn: {amountIn}, profit: {func(amountIn)}')
01 50.5 -31.274029
02 25.75 -10.002363
03 13.375 -1.6365838
04 7.1875 0.87431322
05 4.0938 1.2393436
06 5.6406 1.1587435
07 4.8672 1.2274879
08 4.4805 1.2409489
09 4.2871 1.242086
10 4.3838 1.2419951
11 4.3354 1.2421609
12 4.3113 1.2421536
13 4.3234 1.2421648
14 4.3294 1.2421647
15 4.3264 1.2421652
16 4.3249 1.2421651
17 4.3256 1.2421652
amountIn: 4.325630187988281, profit: 1.2421651707759134
仮想ペアを使った入力枚数計算
複数ペアを経由するスワップでは微分が複雑で発狂しちゃいます。3ペア経由なんて絶対に計算したくありません。「単一ペアなら理論値をビシッと計算できるのに・・・。それなら複数のペアを順に結合して仮想ペア作れば良いんじゃないか?」ということで計算してみましょう。
UniswapV2の入力枚数InAと出力枚数OutBの関係式は以下の通りです。ただし(1-r)がスワップ手数料レートとします。スワップ手数料が0.3%の場合は、r=997/1000です。A、Bはトークンペアコントラクトが所有するトークンA、Bの枚数です。
トークンA→B→Cという経路でアビトラしたいとします。まずは各ペアの入力枚数と出力枚数の関係式を見てみましょう。ABペアの出力枚数がBCペアの入力枚数になります。なおBCペアのトークンBの枚数をB'としておりますので注意願います。
入力枚数InAと出力枚数OutCの関係式を作りたいのでOutBがいなくなれば都合が良さそうです。両式を合体させましょう。途中式は入力するのが面倒くさいので省きますが丁寧に計算すれば解けるはずです。
分母と分子の形をよく見ると最初の式に似てますよね。仮想ペアACのトークン枚数をA'、C'とするとどちらも既知の値から計算できることが分かります。
お疲れ様でした。A→B→Cという2回のスワップを1回で実現する仮想ペアを作れるようになりました。仮想ペアを使って微分すれば最適な入力枚数が計算できるようになりましたね。
「A→B→C→Dみたいな3ペア経由する場合はどうなるの?」ってわけですが、頭から順に結合していけばOKです。A→B→CをA→Cにしてから、A→C→DをA→Dにしましょう。仮想ペアのトークン枚数を使って新たな仮想ペアを作っていくわけです。
function merge_pool(
uint index
) private view returns (uint reserveA, uint reserveB) {
address[] memory path = pop_path(index);
for (uint i = 0; i < path.length - 1; i++){
(uint reserveC, uint reserveD) = DexLibrary.getReserves(FACTORY, path[i], path[i + 1]);
if (i == 0) {
reserveA = reserveC;
reserveB = reserveD;
} else {
(reserveA, reserveB) = merge_pool_single(
reserveA, reserveB,
reserveC, reserveD
);
}
}
}
微分して極大点を探すお話は別の参考記事があるためそちらを参照願います。スマートコントラクトで実装したい方はここが参考になります。