Blocknative with Flashbotsで mempoolと戯れる #2

Yuya Sugano
25 min readDec 24, 2022

Blocknative’s mempool APIやSDKを利用することでmempoolおよびpending-simulationのトランザクション情報へアクセスし、プロフィッタブルなアービトラージの機会をオンチェーン確定情報の前に補足することが可能です。前回はそのようなアービトラージの機会とみなせる、流動性プールのバランスの変化をpending-simulationのトランザクションから検知できるスクリプトを記述しました。

今回はBlocknative’s mempool APIおよびflashbots private relayを利用したアービトラージのスマートコントラクトとスクリプトを作成してみます。ただしコードはEthereumメインネットへはデプロイしておらず、実環境での動作は確認していません。その点はご留意ください。スマートコントラクトやスクリプトの動作を確認していないのであくまで疑似的なものとしてお考えください。

flashbots with blocknative

Disclaimer

This article is not either an investment advice or a recommendation or solicitation to buy or sell any investment and should not be used in the evaluation of the merits of making any investment decision. It should not be relied upon for accounting, legal or tax advice or investment recommendations. The contents reflected herein are subject to change without being updated. This article was written for educational and entertaining purpose only.

目次

  • フラッシュローンとは
  • アービトラージボットのテスト
  • スマートコントラクトの修正(疑似コード)
  • ボットスクリプトの修正(疑似コード)

フラッシュローンとは

フラッシュローンはDeFiのプロトコルに実装されているDeFiに特殊な形態のローンのことです。フラッシュローンを既に知っている方はこの章を飛ばして頂いて構いません。今回はUniswap V2系のAMMに一般的に実装されているフラッシュスワップを利用しています。フラッシュローンについては以下で簡単に記載しています。[1]

通常の金融世界のローンとはお金などの貸し付けのことであり、ユーザは担保を元にお金を借り受けることができます。その返済には利子が伴うことがあります。流動性選好の観点とお金の貯蔵性からマネーを借りる場合には、利子が発生することは避けられません(減価する貨幣などお金の機能を変化させているものは除く)。フラッシュローンとは担保なしで対象資産(暗号資産やトークンなど)を借り受け、その債務の処理と返済を同じ取引(Ethereumなどブロックチェーン上の単一のトランザクション)内で解消することができるDeFiプロトコルの機能のことです。このとき利子は発生せず、プロトコルによって手数料が発生する場合があります。

以下はAaveによるフラッシュローンの説明で、フラッシュローンは現実世界に比喩できるようなものがないと記載されています。そう形容されるほどこれまで存在しなかったタイプのローン取引となっています。[2]

Flash Loans are special uncollateralised loans that allow the borrowing of an asset, as long as the borrowed amount (and a fee) is returned before the end of the transaction. There is no real world analogy to Flash Loans.

フラッシュローンは担保なしでマネーを融通することができる点、またトランザクション内で借りたマネーの操作や返済を一挙に行える点が特徴で、以下のような処理・アプリケーションへの応用が考えられています。DeFiサマーと呼ばれた2020年以降、フラッシュローンは多数のハッキングで応用されており、オラクル等を通じて故意に価格差を発生させてアービトラージを成功させるようなケースが散見されました。[3]

  • Arbitrage(アービトラージ)
  • Refinance loan(ローンの借り換え)
  • Swap collaterals(担保のスワップ)

フラッシュローンを利用したアービトラージは2020年以降さかんに議論され、公開されているボットのリポジトリも多数あるためプロフィットを得られるような競合性の高いボットを作成することは難しくなっています。アービトラージボットの改良については一般的に以下の様なポイントが知られています。前回カバーしたmempoolのpending-simulationのトランザクションの情報の利用は、以下のポイントの中でもアービトラージの機会を早期に発見するための施策として有効です。

  • トランザクションが到達するノードまでのレイテンシを低くする
  • アービトラージの機会の発見の高速化、もしくは他のアクターより事前にアービトラージの機会を補足できるようにすること
  • アセットのスワップの高速化(※フラッシュローンを利用している場合には単一のトランザクションで解消するため当てはまらない)
  • より高いガス価格を設定することでトランザクションを優先させる
    (EthereumではEIP-1559が実装されていることに留意)
  • 監視されている頻度の低いトークンペアやネットワーク(例えばFTM、Polygon)の採用によるニッチマーケットの開拓
  • 他のオペレーション(リクイデーション、コラテラル)やBalancer、Curveなど利用したアービトラージとの組み合わせ

アービトラージボットのテスト

一般的なアービトラージのスマートコントラクトとボットスクリプトを検証してからコントラクトを改良してきます。今回こちらのレポジトリをフォークして使いました。Uniswap V2系のフラッシュスワップのスマートコントラクトとTypeScriptで書かれたスクリプトを使用した汎用的なボットとなっているようです。基本的な動作は以下で説明します。英中の説明は元のリポジトリにもあります。[4]

まず前提としてUniswap V2で使用されるフラッシュスワップの仕組みが利用されます。フラッシュスワップは通常のAMMスワップのファンクションに実装されている機能の一部でスワップ先のトークンをスワップ元のトークン引き渡しの前に受け取ることができる機能です。

フラッシュスワップはフラッシュローンの一部として知られています。[4]

  • pair0pair1 を異なるDEX上の同じ流動性ペアとします、同じペア間で価格差が発生したときにアービトラージの機会があります
  • アービトラージを開始するために FlashBot コントラクトを呼びます
  • コントラクトはスワップ先の通貨建ての価格を計算します、 pair0 でのスワップ先のトークン価格は低いものと想定されます

スワップが完了するとベーストークンだけが残り、スワップ先のトークンは残らないようになっています。コントラクトは pair0 でスワップ先のトークンをフラッシュスワップで借り入れます。コントラクトは借りたスワップ先トークンを最終的には pair0 で対象AMMへ返済する必要があります。

次に借りたトークンを別のAMMの流動性プールである pair1 で売却します。売却はスワップ開始に使用するトークンで、 pair1 で売却したトークンを pair0 でのスワップ先トークンの返済に充てます。残りはスワップ開始のベーストークンとなります。

フラッシュスワップでは、スワップ先トークンの返済はベーストークン建てで行うことが可能です。これはフラッシュスワップの仕様の一部で、FlashBotコントラクトは返済完了後のベーストークンのプロフィットを最終的に得ることができる、というスマートコントラクトになっています。

デフォルトで https://bsc-dataseed1.defibit.io/ のBSCメインネットのエンドポイントが利用されていました。ChainstackのBSCエンドポイントへと置き換えています。 dotenv でChainstackのBSCエンドポイントを hardhat.config.ts に反映させてからコントラクトをBSCメインネットへデプロイしました。Ethereumメインネットへデプロイしたい場合には、設定ファイルにエンドポイントなど必要な設定を適宜追加してください。

$ npm install --save-dev dotenv
$ npx hardhat --network bsc run scripts/deploy.ts

yarn run bot でアービトラージボットを稼働させることができます。プロフィッタブルなアービトラージの機会が見つからなかったため、 apeswap panther などのファクトリーアドレスを追加して再度試しましたが結果は同じでした。ボット・スクリプト自体は正常に動作しているようです。

yarn run bot

スマートコントラクトの改良

上記のボットはBSCメインネットがデフォルト設定が入っており、BSCメインネットでテストしましたがEthereumメインネットへもデプロイが可能です。Ethereumメインネットで使用されることを前提として、前回調査したpending-simulationトランザクションの情報を活用するようなボットへと改良します。対象のリポジトリをフォークしてコード修正しました。

変更内容はBlocknativeのMEV Bot Guideを参考にしています。[5]

まずは新規の構造体を追加します。これはpending-simulationのトランザクションで流動性プールにバランス変化を検知した際の変化分を調整してプロフィットを計算するための変数のための構造体として使用します。 adjustmentPool は変化分を調整する流動性プールのアドレス、 adjustmentToken が対象ペアのうちバランス操作を行う起点のトークンのアドレス、ペアの各トークンのバランス変化をそれぞれ adjustment0 adjustment1 として定義します。

struct Adjustments {
address adjustmentPool; // target adjusted pairAddress
address adjustmentToken; // target token address for adjustment
uint256 adjustment0;
uint256 adjustment1;
}

ボットのプロフィット計算は res = await flashBot.getProfit(pair0, pair1) として計算しているので、まずスマートコントラクトの getProfit 関数へ新規の Adjustments 構造体を使用して流動性プールのバランス変化を反映させる処理を加えます。 getProfit 関数の引数にそのまま変数を加えて、構造体の adj 変数を getOrderedReserves 関数へ追加で渡します。

getOrderedReverves 関数はスワップ先トークン建てでの価格が低いトークン、価格が高いトークンを判別してそれらのプールアドレスと、 ordered Reserves 構造体の変数を返します。価格の計算やリザーブ量へpending-simulationによって明らかとなったトークンアドレスのバランス量を反映させます。 adjustment0 側は流動性プールのうち常にトークンリザーブが減少するトークンとなります。

if (pool0 == adj.adjustmentPool) {
if (adj.adjustmentToken == IUniswapV2Pair(pool0).token0()) {
pool0Reserve0 -= adj.adjustment0;
pool0Reserve1 += adj.adjustment1;
} else {
pool0Reserve1 -= adj.adjustment0;
pool0Reserve0 += adj.adjustment1;
} else if (pool1 == adj.adjustmentPool) {
if (adj.adjustmentToken == IUniswapV2Pair(pool1).token0()) {
pool1Reserve0 -= adj.adjustment0;
pool1Reserve1 += adj.adjustment1;
} else {
pool1Reserve1 -= adj.adjustment0;
pool1Reserve0 += adj.adjustment1;
}
}

スマートコントラクトの変更を反映したフォークリポジトリはこちらです。次にスマートコントラクトとやり取りをするボットスクリプトの修正にとりかかります。繰り返しですが、本コードはEthereumメインネットへデプロイしてテストしていないためあくまで疑似的なものとしてお取り扱いください。

ボットスクリプトの修正

前回の記事で作成したpending-simulationのトランザクションから流動性プールのバランス変化を取得するスクリプトを修正します。 ethers.js および FlashbotsBundle Provider ethers.js providerのライブラリが必要です。必要なウォレットやflashbotsProviderのインスタンスを初期化しておきます。スマートコントラクトも ethers.js を使用してインスタンスを作成しておきます。

// Read amm-arbitrageur FlashBot contract with ethers.js, create an instance
// https://docs.ethers.io/v5/, pseudo-code
import { Contract, providers, Wallet } from "ethers";
const FlashBot = new Contract(address, abi, signerOrProvider);

// Need ethers FlashbotsBundleProvider to create a bundle transaction
import { FlashbotsBundleProvider } from "@flashbots/ethers-provider-bundle";
const arbitrageSigningWallet = new Wallet(PRIVATE_KEY);
const flashbotsRelaySigningWallet = new Wallet(FLASHBOTS_RELAY_SIGNING_KEY);

const ETHEREUM_RPC_URL = process.env.ETHEREUM_RPC_URL || "http://127.0.0.1:8545"
const provider = new providers.StaticJsonRpcProvider(ETHEREUM_RPC_URL);
const flashbotsProvider = await FlashbotsBundleProvider.create(provider, flashbotsRelaySigningWallet);

// To construct the signed signature hash, need the rlp module
const { encode } = require('rlp');

流動性プールのバランス変化を検知したら getProfit 関数を呼んでバランス変化を調整したプロフィットを計算します。計算結果にはガスコストが含まれていません。グロスの95%をマイナーへのトランザクション手数料としてMEV Relayersへ支払うこととしています。残りの5%が利益として手元に残る計算です。コードはこちらの記事を参考にしました。[5]

profitHex = await FlashBot.getProfit(pairAddress, otherPairAddress, pairAddress, tokenAddress0, adjustment0, adjustment1);
const gross = ethers.utils.formatEther(profitHex.toString(10).split(',')[0]);
const gasLimit = 240000;
const gasFee = Math.floor(ethers.utils.parseEther(gross)*.95/gasLimit);
const gasCost = gasLimit*gasFee;
const net = ethers.utils.parseEther(gross) - gasCost;

MEV-Relayersへ送信するFlashbotsバンドルを作成していきます。Blocknativeのmempoolのペイロードをそのまま再利用して署名付きトランザクションハッシュを構築可能です。 transaction.typeからEthereumの従来のトランザクション(Type-0)かEIP-1559(Type-2)か判別し、JavascriptのRLPモジュールを使ってエンコードします。次にバンドル内の次のトランザクションとしてフラッシュスワップのアービトラージのトランザクションを作成しました。

if (transaction.type == 2) {
params = [
'0x01',
transaction.nonce === 0 ? '0x' : ethers.utils.hexlify(transaction.nonce),
ethers.utils.parseEther(ethers.utils.formatEther(transaction.maxPriorityFeePerGas))._hex,
ethers.utils.parseEther(ethers.utils.formatEther(transaction.maxFeePerGas))._hex,
ethers.utils.hexlify(transaction.gas),
transaction.to,
transaction.value === '0' ? '0x' : ethers.utils.hexlify(transaction.value),
transaction.input,
[],
transaction.v === '0x0' ? '0x' : transaction.v,
transaction.r,
transaction.s
];
s1 = '0x02'+encode(params).toString('hex');
} else {
params = [
transaction.nonce === 0 ? '0x' : ethers.utils.hexlify(transaction.nonce),
ethers.utils.parseEther(ethers.utils.formatEther(transaction.gasPrice))._hex,
ethers.utils.hexlify(transaction.gas),
transaction.to,
transaction.value === '0' ? '0x' : ethers.utils.hexlify(transaction.value),
transaction.input,
transaction.v,
transaction.r,
transaction.s
];
s1 = '0x'+encode(params).toString('hex');
}

// use populateTransaction; https://docs.ethers.io/v5/api/contract/contract/
// contract.populateTransaction.METHOD_NAME(...args [ , overrides ] )
const s2 = await FlashBot.populateTransaction.flashArbitrage(
pairAddress,
otherPairAddress,
pairAddress,
tokenAddress0,
ethers.utils.parseEther('0')._hex,
ethers.utils.parseEhter('0')._hex
);

s2.gasPrice = ethers.utils.hexlify(gasFee);
s2.gasLimit = ethers.utils.hexlify(500000);
s2.nonce = await arbitrageSigningWallet.getTransactionCount(); // need a wallet instance
// https://docs.ethers.io/v5/api/signer/#Wallet

providerのライブラリは、署名済みトランザクションとトランザクションリクエストおよび署名トランザクションの組み合わせを受け入れ、MEV-Relayersに送信する前に、推定、ナンス計算、署名などを行います。blockNumberはpendingBlockNumberに1を加算して次ブロックとしています。Flashbotsバンドルの署名トランザクションを組み立てましょう

const signedTransactions = await flashbotsProvider.signBundle([
{
signedTransaction: s1
},
{
signer: wallet,
transaction: s2
}
]);

const blockNumber = transaction.pendingBlockNumber + 1;

作成したトランザクションバンドルをBlock buildersへ送りますが、その前に simulation を行いシミュレーションの結果から再度ネットプロフィットを計算します。Flashbotsバンドルはいくつかの理由によって取り込まれないことがありますが、simulate()関数で eth_callBundle を呼ぶことによってトランザクションのガス使用量や coinbase トランスファーを確認できます。[6]

ここでは simulation.results[1].gasUsed*gasFeeとしてフラッシュスワップによるガス手数料を減算してなおアービトラージによるプロフィットが発生するかを検証し、値が正(0以上)であればFlashbotsバンドルをBlock Buildersへ送出する処理となっています。失敗したバンドルはetherscanなどでは確認できません。必要に応じて simulation結果を参照する必要があります。[7]

const simulation = await flashbotsProvider.simulate(signedTransactions, blockNumber);
if ('error' in simulation) {
    console.log(`Simulation Error: ${simulation.error.message}`);
} else {
if (simulation.firstRevert !== undefined) {
    console.log(simulation.firstRevert.revert);
} else {
    const net2 = ethers.utils.parseEther(gross) - simulation.results[1].gasUsed*gasFee;
    console.log(`Net: ${ethers.utils.formatEther(net2)} | Pair address: ${pairAddress} | TxHash: ${transaction.hash}`);
    console.log(simulation);
if (net2 > 0) { // if this is still profitable
    console.log(`Coinbase diff: ${simulation.coinbaseDiff}`);
    const submittedBundle = await flashbotsProvider.sendRawBundle(signedTransactions, blockNumber);
    const bundleResponse = await submittedBundle.wait();
    console.log(bundleResponse);
}
    }
}

ボットスクリプトの修正は以上です。Blocknativeの参考記事においては、コードは意図した通りに動作するものの、他ボットとの競合できないという理由から思うようなプロフィットは得られなかったと説明されています。

暗黒森林内でより競合性の高いボットを作成するには、ガス効率性を高める、もしくは他のアービトラージ戦略を検討するなどが必要となってくるでしょう。フラッシュスワップは通常のスワップに比べて非効率的であるためとも記載されています。

Blocknativeを使用することで、mempoolにおけるBadger DAOのオラクル価格更新や、Aaveのフラッシュローンの発生などのイベントへサブスクライブし高度で洗練されたMEVによるアービトラージ戦略を組み立てることが、プロフィッタブルで競合性の高いボット作成への近道だと思います。

前回とあわせて本記事が少しでも暗黒森林の探索に役立ちましたら幸いです。

--

--

Yuya Sugano

Cloud Architect and Blockchain Enthusiast, techflare.blog, Vinyl DJ, Backpacker. ブロックチェーン・クラウド(AWS/Azure)関連の記事をパブリッシュ。バックパッカーとしてユーラシア大陸を陸路横断するなど旅が趣味。