UniswapV3(SwapRouter02)でのmulticall実装@Baseをpythonで。

DEXを触っているとMulticallという関数に出会うことがよくあるので、この理解は必至と思いながらDocsを読むも、いまいち先へ進めない。
今回も柴犬さん(https://x.com/leidream1)に99%頼る形となりました。有益すぎる情報の提供、感謝いたします。

やることは、Base Mainnetにおいて
・WETHからUSDCへのスワップ
・WETHからUSDTへのスワップ
という2つの関数呼び出しをまとめてmulticall関数で呼び出す、ということです。

まず参考にさせていただいたのはこちらです(柴犬さんのものです)。

ただ、自分なりに実装するも上手く動かなかったので、さらにご協力いただいています(後述)。

また、コピペでは意味が無いので、この取り組みで分かったことなどもまとめています。


まずは完成したコード

こちらが、Base Mainnetにて
・0.001WETH→USDCへのスワップ
・0.001WETH→USDTへのスワップ
をまとめてmulticall関数から呼び出すコードです。

#base_multicall.py

from web3 import Web3
import json
from datetime import datetime

my_wallet_address = 'xxx'
private_key = 'yyy'
WETH = '0x4200000000000000000000000000000000000006'
USDC = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'
USDT = '0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2'

# コントラクトのアドレス
swaprouter_address = '0x2626664c2603336E57B271c5C0b26F421741e481'

# InfuraやAlchemyなどのプロバイダーのURLを設定
provider_url = 'https://mainnet.base.org'
web3 = Web3(Web3.HTTPProvider(provider_url))

#approve(WETH)
with open('base_WETH_ABI.json') as f:
   wethABI = json.load(f)
wethContract= web3.eth.contract(address=WETH, abi=wethABI)

now = int(datetime.now().timestamp()) 
nonce = web3.eth.get_transaction_count(my_wallet_address) 
inf = 2**256 - 1
funcApprove = wethContract.functions.approve(swaprouter_address, inf)
txApprove = funcApprove.build_transaction({
   'value': 0,
   'gas': 2000000,
   'gasPrice': web3.eth.gas_price,
   'nonce': nonce
   })
signedTxApprove = web3.eth.account.sign_transaction(txApprove, private_key)
txHashApprove = web3.eth.send_raw_transaction(signedTxApprove.raw_transaction)
print('Send Approve transaction')
print(web3.to_hex(txHashApprove))
resultApprove = web3.eth.wait_for_transaction_receipt(txHashApprove)
statusApprove = resultApprove['status']
if statusApprove == 1:
   print('Approve Succeeded')
else:
   print('Approve Failed')



#path(WETH→USDC)
fee = 500  # ETH/USDCペアには0.05%を使用
byte_weth = web3.to_bytes(hexstr=WETH)
byte_fee = fee.to_bytes(3, 'big')
byte_usdc = web3.to_bytes(hexstr=USDC)
path1 = byte_weth + byte_fee + byte_usdc
print("path1 = ")
print(path1)

#path(WETH→USDT)
byte_usdt = web3.to_bytes(hexstr=USDT)
path2 = byte_weth + byte_fee + byte_usdt
print("path2 = ")
print(path2)


# Uniswap Router ABI & コントラクトインスタンス作成
with open('SwapRouter02_ABI.json') as f:
   routerABI = json.load(f)
routerContract = web3.eth.contract(address=swaprouter_address, abi=routerABI)


#multicall dataの作成
params1 = {
   'path': path1,
   'recipient': my_wallet_address,
   'amountIn': web3.to_wei(0.001, 'ether'), #0.001WETH
   'amountOutMinimum': 0
}
swap1 = routerContract.encode_abi("exactInput", [params1])

params2 = {
   'path': path2,
   'recipient': my_wallet_address,
   'amountIn': web3.to_wei(0.001, 'ether'), #0.001WETH
   'amountOutMinimum': 0
}
swap2 = routerContract.encode_abi("exactInput", [params2])

multicall_data = [
    swap1,
    swap2
]


#create multicall tx
multicall_function = routerContract.functions.multicall(multicall_data)
tx = multicall_function.build_transaction({
    'from': my_wallet_address,
    'gas': 200000,
    'nonce': web3.eth.get_transaction_count(my_wallet_address),
    'gasPrice': web3.eth.gas_price
})

#sign to tx
signedTx = web3.eth.account.sign_transaction(tx, private_key)

#send tx
txHash = web3.eth.send_raw_transaction(signedTx.raw_transaction)

print('Send multicall transaction')
print(web3.to_hex(txHash))
result = web3.eth.wait_for_transaction_receipt(txHash)
status = result['status']
if status == 1:
   print('Multicall Succeeded')
else:
   print('Multicall Failed')

このファイルと同じディレクトリに
・SwapRouter02_ABI.json
(0x2626664c2603336E57B271c5C0b26F421741e481のABI)
・base_WETH_ABI.json
(0x4200000000000000000000000000000000000006のABI)
が必要です。

approveの部分はSwapRouter02にWETHを使用する許可を与えている部分なので、今回のテーマとは関係なく、不要な人は不要かもしれません。

Multicall Failedとなる場合は、gasを400000にしてみてください。

path

まずはここで躓きました。結局核心には迫れませんでしたが、後学のためにたどり着いたことは書いておきます。

使用する関数はこれ。

なので、exactInput関数についてDocsで調べます。

ここには、struct ISwapRouter.ExactInputParamsという構造体の形で引数を与えることが分かりますのでISwapRouterのページでExactInputParams構造体の仕様を確認します。

が、結局たどり着くのはブロックエクスプローラで確認できた情報と同じ情報のみで、この「path」の作り方には触れられていません。

今度は、ChatGPTに「uniswapのSwapRouter02のexactInput関数について教えてください。」と質問してみました。回答は以下です。

UniswapのSwapRouter02コントラクトのexactInput関数は、トークンAからトークンBへのスワップを行うための機能を提供します。exactInput関数を使用すると、指定したトークン量(入力量)に対して、指定したパスに従ってスワップを実行し、可能な限り多くの出力トークンを取得できます。

exactInput関数のシグネチャ

以下はexactInput関数のシグネチャです。

function exactInput(
    IV3SwapRouter.ExactInputParams calldata params
) external payable returns (uint256 amountOut);

引数(ExactInputParams構造体)

exactInput関数は、ExactInputParamsという構造体を1つの引数として取ります。この構造体には以下のフィールドが含まれています。

struct ExactInputParams {
    bytes path;             // スワップのパス(トークンアドレスと手数料が含まれるバイト列)
    address recipient;      // スワップしたトークンの受取先アドレス
    uint256 amountIn;       // スワップする入力トークンの量
    uint256 amountOutMinimum; // 最小出力トークン量(スリッページ保護のため)
}

フィールド詳細

  1. path: スワップの経路を示すバイト列で、トークンアドレスとプールの手数料率が順に並んでいます。例えば、TokenA -> TokenB -> TokenCのスワップを行う場合、pathには TokenAのアドレス、TokenA-TokenBプールの手数料、TokenBのアドレス、TokenB-TokenCプールの手数料、TokenCのアドレスが含まれます。

    • 各トークンアドレスは20バイト、各手数料は3バイトで表されます。

  2. recipient: スワップで得た出力トークンの受取先アドレスです。このアドレスにスワップ結果が送られます。

  3. amountIn: スワップする入力トークンの正確な量です。この量のトークンを提供し、指定したpathに従ってスワップが行われます。

  4. amountOutMinimum: スワップで受け取りたい出力トークンの最小量です。この値を下回る場合、トランザクションがリバートされます。スリッページを避けるために設定します。

戻り値

  • amountOut: スワップに成功した場合に得られる実際の出力トークン量です。トランザクションの成功時には、amountOutがamountOutMinimum以上の値となります。

使用例

以下は、Python(web3.py)を使ってexactInput関数を呼び出す例です。実際のコードはUniswapのAPIやトークンの設定に合わせて調整する必要があります。

from web3 import Web3
from eth_abi import encode

# Web3のセットアップ
w3 = Web3(Web3.HTTPProvider("https://mainnet.infura.io/v3/YOUR_INFURA_PROJECT_ID"))

# UniswapのSwapRouter02アドレスとABIを設定
swap_router_address = "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45"
swap_router_abi = [...]  # SwapRouter02のABIをここに入力

# トークンアドレスと手数料
token_in = "0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"  # 例: USDC
token_out = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"  # 例: WETH
fee = 3000  # 0.3%の手数料

# 経路をエンコード (tokenIn, fee, tokenOut)
path = encode(["address", "uint24", "address"], [token_in, fee, token_out])

# スワップのパラメータ
params = {
    "path": path,
    "recipient": "0xYourWalletAddress",
    "amountIn": Web3.toWei(100, 'ether'),  # 例: 100 USDC
    "amountOutMinimum": Web3.toWei(0.03, 'ether')  # 最小出力量 (例)
}

# コントラクトのセットアップ
swap_router = w3.eth.contract(address=swap_router_address, abi=swap_router_abi)

# トランザクションの作成
tx = swap_router.functions.exactInput(params).buildTransaction({
    'from': '0xYourWalletAddress',
    'gas': 200000,
    'gasPrice': w3.eth.gas_price,
    'nonce': w3.eth.get_transaction_count('0xYourWalletAddress'),
})

# トランザクションの署名と送信
signed_tx = w3.eth.account.sign_transaction(tx, private_key="YOUR_PRIVATE_KEY")
tx_hash = w3.eth.send_raw_transaction(signed_tx.rawTransaction)

print(f"Transaction hash: {tx_hash.hex()}")

まとめ

  • exactInput関数は、指定した入力トークンの量を使用してスワップを実行し、可能な限り多くの出力トークンを得るために使用します。

  • pathを使ってスワップの経路(複数のトークン)を指定できます。

  • スリッページ保護としてamountOutMinimumを設定し、出力が最低限確保されるようにします。

この関数は、Uniswapの経路に従って複数のトークンをスワップするのに非常に便利です。

ここまでがChatGPTの回答です。

どこからこの情報を持ってきたのか分かりませんが、

# 経路をエンコード (tokenIn, fee, tokenOut)
path = encode(["address", "uint24", "address"], [token_in, fee, token_out])

のようにpathについて処理していますが、
このpathの作り方に従うと、Multicall Failedとなります。

柴犬さんのコードでは、入力トークンコントラクトアドレス、fee、出力トークンコントラクトアドレスをバイト列に変換して連結したものをpathとして扱っているので、

ChatGPTに、byte列を連結したものとencodeを使って導いたものでは結果が異なりますよ?と問いかけてみました。すると、(以下、ChatGPTの回答)

両者のpath(dataとencode_path)が異なる理由は、Pythonでのバイト列操作とABIエンコードの違いが影響している可能性があります。encode関数を使用する方法(encode_path)のほうが、Uniswap V3のexactInput関数で想定されているフォーマットに沿ってエンコードされるため、こちらを使うのが正しい方法です。

詳細説明

  1. 手動のバイト列結合(data)

    • w3.to_bytesでアドレスをバイト列に変換し、fee.to_bytes(3, 'big')で手数料を3バイトに変換して結合しています。

    • これはUniswap V3のpathの形式には近いものの、ABIエンコーディングの厳密な仕様に完全には一致しない場合があります。

  2. ABIエンコード(encode_path)

    • encode関数を使って、["address", "uint24", "address"] の形式でエンコードしています。

    • これはUniswapのコントラクトが期待するABI仕様に沿っているため、コントラクト呼び出しでの互換性が保証されます。

結論

UniswapのexactInput関数などにpathを渡す場合には、encode(["address", "uint24", "address"], [USDC, fee, WETH])を使って生成したencode_pathを使用してください。

ここまでがChatGPTの回答

だがしかし、実際にencodeを使用したpathではMulticallは通りませんでした。なんだかんだ質問したのですが、ChatGPTはencode推し。
再度、exactInputについて聞いてみると、様相が変わりました(そこまでのやりとりで私がpathに執着していたからかもしれません)。

私の質問、
exactInputの使い方が説明されているwebページを教えてください。
(以下、ChatGPTの回答)

Uniswap V3: マルチホップスワップのガイド

UniswapのexactInput関数は、指定した入力トークン量を用いて、複数のトークンを経由するスワップ(マルチホップスワップ)を実行するための機能です。この関数を使用することで、ユーザーは一連のトークン交換を一度のトランザクションで行うことが可能となります。

exactInput関数の主なパラメータ:

  • path: スワップの経路を示すバイト列で、各トークンのアドレスと手数料を連結したものです。

  • recipient: スワップ後のトークンを受け取るアドレスです。

  • amountIn: スワップに使用する入力トークンの正確な量です。

  • amountOutMinimum: スワップ後に受け取るべき最小の出力トークン量を指定します。

pathの構築方法:

pathは、スワップするトークンのアドレスと各ペア間の手数料を連結したバイト列で表現されます。例えば、USDCからWETHへのスワップを行う場合、pathは以下のように構築されます。

  1. USDCのアドレス(20バイト)

  2. USDC/WETHペアの手数料(3バイト)

  3. WETHのアドレス(20バイト)

これらを連結して、pathを形成します。Pythonを使用してpathを構築する際、各要素を適切にバイト列に変換し、連結する必要があります。

注意点:

  • pathの構築時に、各トークンのアドレスは20バイト、手数料は3バイトでエンコードされる必要があります。

  • pathの形式が正しくない場合、exactInput関数の呼び出し時にエラーが発生する可能性があります。

詳細な実装方法やサンプルコードについては、Uniswapの公式ドキュメントを参照してください。

ここまでがChatGPTの回答

今度はpathについて詳しく説明してくれました。
これによると、”連結する”とあります。

やはり連結する方法が正しいのでは?と再度ChatGPTに聞いてみました。
(以下、ChatGPTの回答)

おっしゃる通り、構築方法に従ってdataが正しく形成されていれば、それをexactInput関数に渡しても問題なく動作するはずです。

Uniswapのpathは、20バイトのトークンアドレスと3バイトの手数料を連結することで構築されます。dataの生成がこのルールに基づいているのであれば、構築方法としては正しいです。これをexactInput関数に渡してスワップを実行する際にも、エラーが発生することなく動作することが期待されます。

ただし、以下の点を確認してみてください:

  1. トークンアドレスの形式: トークンアドレスは必ず20バイトでエンコードされていますか?アドレスのエンコード方法に問題がないかを確認します。

  2. 手数料の形式: 手数料は3バイトでエンコードされていますか?例えば500(0.05%)は0x0001f4となります。エンコード後に3バイトであることを確認してください。

  3. dataのバイト列長: dataの全体のバイト長が適切であることを確認してください。例えば、単純なUSDC → WETHのスワップであれば、dataの長さは43バイト(20 + 3 + 20)となります。

結論

構築したdataがUniswapのpathの仕様に従っている場合、exactInput関数にdataをpathとして渡すことは正しい方法です。

回答ここまで。

結局連結で正しいんかい、と・・・

ChatGPTはインターネットにある情報を持ってきているはずなので、どこにそれが書いてあるのかを聞いてみました。

質問
pathは、スワップのルートを定義するために使用され、エンコード形式で記述する必要があります。各トークンのアドレス(20バイト)と手数料(3バイト)を連結してエンコードしたバイト配列が必要です。 これはどこに書いてあるんですか?(以下、ChatGPTの回答)

UniswapのexactInput関数でのpathの構築方法については、公式ドキュメントでは具体的なエンコード手順が直接的に記載されていないことが多いため、SDKのサンプルコードやガイドを参考にするのが一般的です。ただし、Uniswap V3のpath構築に関する情報は以下のページに記載されていることが多いです:

  1. Uniswap V3 SDK - Routing
    このページには、pathがどのように設定されるかについてのガイドがあり、pathが「複数のトークン間のスワップルートを指定するために使用される」ことが説明されています。ここでの例を通じて、pathに複数のトークンと手数料を指定する方法についての理解を深められます。

  2. SwapRouter - exactInput (Smart Contract Reference)
    SwapRouterコントラクトのドキュメントのexactInput関数には、pathの説明として「encoded path to route the trade」という説明があり、エンコード形式であることが確認できます。

  3. Uniswap GitHub - Uniswap V3 SDK
    Uniswapの公式GitHubリポジトリのSDKのコードには、pathをエンコードするコードが含まれているため、こちらも参考になります。特に@uniswap/sdkのencodeRouteToPath関数(TypeScript)が、トークンアドレスと手数料を連結してバイト形式にエンコードする方法を示しています。

ここまでが回答

ここまでやって感じたのは、最終的にはSolidityで書かれたコントラクトコードを理解するしかない、ということです。

pathを含む構造体であるExactInputParamsはISwapRouterのものであり、ISwapRouterもまた多数の機能を継承しているので、これらを追いかけるしか正確な回答にはたどり着けないのだろうな、と思いました。まだまだ先は遠いですが。

fee

これもDocsを探しても出てこなかったです。
コード内では0.05%→500となっていますが、この変換についての記載は見つけられませんでした。そこで、またChatGPTにこれについて聞きました。(以下、回答です。)

Uniswapのドキュメント内で明示的に「0.01% = 100、0.05% = 500、0.3% = 3000、1% = 10000」と手数料レベル(Fee Tier)を数値で指定しているページはありません。ただし、この変換の理解は、Uniswap V3の手数料階層がそれぞれのプールの特定のFee Tierとしてエンコードされている事実から推測されます。

具体的には、Uniswap V3のプールは「Fee Tier」として0.01%、0.05%、0.3%、1%の手数料を持ち、これらはコード内で100、500、3000、10000としてエンコードされています。この仕様は以下のような理由で暗黙的に理解されることが多いです:

  1. 技術的な数値表現の通例:スマートコントラクトなどのプログラムでは、パーセンテージを整数で表現することが一般的です。Uniswapでは、手数料を「Basis Points(bps)」、つまり100分の1%の単位で表現するため、0.3%は3000bps、1%は10000bpsに相当します。

  2. Uniswapの開発リソースやSDKのドキュメント:UniswapのGitHubリポジトリや公式SDKのコード例では、これらの手数料レベルを100、500、3000、10000で指定している部分が見られます。公式のSDKやリファレンス実装を参照すると、これらのFee Tier値がどのように設定されているかが確認できます。

もし明確な記載が必要であれば、Uniswapの開発者向けリファレンスや、具体的なプールコントラクトのコードを確認するか、Uniswap V3のGitHubリポジトリ内でFee Tierの扱いを調べることをお勧めします。

回答ここまで

つまり、コードを読め、ということですね。
これまでDocsで仕様を確認してコードを書く、という流れでやってきましたが、Solidityを書いたり、gitHubのコードを読む、という作業も今後やっていく必要性を感じました。

multicall

これもDocsからは詳細は読み取れません。

これもChatGPTに頼ります。

質問
SwapRouter02のmulticall関数で、token1→token2へのスワップ(exactInput)とtoken1→token3へのスワップ(exactInput)を実行したいのですが、pythonでコードを書いてください。
以下、ChatGPTの回答です。

Uniswap V3のSwapRouter02のmulticall関数を使って、1つのトランザクション内でtoken1をtoken2およびtoken3にスワップする方法を示します。以下はPythonのコード例です。

このコードでは、まず2つのスワップのexactInput関数をエンコードして、Uniswap V3のmulticall関数に渡す形にしています。

from web3 import Web3
from eth_abi import encode_abi
import json
from datetime import datetime
import os
from dotenv import load_dotenv

load_dotenv()

# 環境変数の読み込み
RPC_URL = os.getenv("INFURA_URL")
PRIVATE_KEY = os.getenv("PRIVATE_KEY")
USER_ADDRESS = os.getenv("ADDRESS")

# Web3のセットアップ
web3 = Web3(Web3.HTTPProvider(RPC_URL))

# Uniswap V3 Router02のコントラクトアドレスとABI
router_address = Web3.to_checksum_address("0x2626664c2603336E57B271c5C0b26F421741e481")  # Uniswap V3: SwapRouter02のアドレス
with open("SwapRouter02_ABI.json") as f:
    router_abi = json.load(f)
router_contract = web3.eth.contract(address=router_address, abi=router_abi)

# スワップするトークンのアドレス
token1 = Web3.to_checksum_address("0xToken1AddressHere")
token2 = Web3.to_checksum_address("0xToken2AddressHere")
token3 = Web3.to_checksum_address("0xToken3AddressHere")

# 各スワップのFee Tier
fee = 3000  # 0.3% fee tier

# スワップする量と最小受け取り量
amount_in = web3.to_wei(1, "ether")  # 1 Token1をスワップ
amount_out_min = 0  # テストのため最小受け取り量を0に設定(実際には適切な値を設定)

# スワップ1: token1 -> token2
path1 = encode_abi(["address", "uint24", "address"], [token1, fee, token2])
params1 = {
    "path": path1,
    "recipient": USER_ADDRESS,
    "amountIn": amount_in,
    "amountOutMinimum": amount_out_min,
}
encoded_swap1 = router_contract.encodeABI(fn_name="exactInput", args=[params1])

# スワップ2: token1 -> token3
path2 = encode_abi(["address", "uint24", "address"], [token1, fee, token3])
params2 = {
    "path": path2,
    "recipient": USER_ADDRESS,
    "amountIn": amount_in,
    "amountOutMinimum": amount_out_min,
}
encoded_swap2 = router_contract.encodeABI(fn_name="exactInput", args=[params2])

# multicallデータの作成
multicall_data = [encoded_swap1, encoded_swap2]

# ガス料金と期限の設定
deadline = int(datetime.now().timestamp()) + 600  # 10分後に期限切れ
gas_price = web3.eth.gas_price

# トランザクションの構築
tx = router_contract.functions.multicall(multicall_data).build_transaction({
    "from": USER_ADDRESS,
    "gas": 200000,  # 適切なガスリミットを設定してください
    "gasPrice": gas_price,
    "nonce": web3.eth.get_transaction_count(USER_ADDRESS),
})

# トランザクションの署名と送信
signed_tx = web3.eth.account.sign_transaction(tx, private_key=PRIVATE_KEY)
tx_hash = web3.eth.send_raw_transaction(signed_tx.rawTransaction)

print(f"Transaction sent! Hash: {web3.to_hex(tx_hash)}")

# トランザクション結果の確認
tx_receipt = web3.eth.wait_for_transaction_receipt(tx_hash)
if tx_receipt["status"] == 1:
    print("Transaction succeeded")
else:
    print("Transaction failed")

説明

  • path1とpath2:exactInputで使用する各スワップのルートを定義します。各pathは、token1とスワップ先トークンのアドレスおよびFee Tier(例:0.3% = 3000)で構成されています。

  • multicall_data:2つのスワップを1つのトランザクションで行うために、exactInputのエンコードデータをまとめます。

  • multicall:multicall関数を使って複数のスワップを同時に実行します。

注意点

  • 各トークンのアドレスやFee TierはUniswapのプール設定に応じて変更する必要があります。

  • amountOutMinimumは実際のトランザクションで適切な値を設定してください。

ここまでChatGPTの回答

以下の部分がヒントになっています。

encoded_swap1 = router_contract.encodeABI(fn_name="exactInput", args=[params1])

ただ、私の環境では、
・encodeABIではなくencode_abiを使用
・fn_name=を削除
・args=を削除
という変更を加えなければ動きませんでした。

この辺りは柴犬さんとも話したのですが、使っているweb3.pyのバージョンにも依存するのかもしれません。

以下、Xでのやりとりも載せておきます。

まとめ

今回の課題は、一人では解決することはできず、また、コードの根拠にたどり着くこともできませんでした。
一方で、ChatGPTはどこからか答えらしきものは拾ってきてくれるのでとても有用であることが分かりました。
そして、Solidityでスマートコントラクトを書く、および、コントラクトを構成するコードやgitHubのコードをたくさん読む、ということが今後の課題だと認識しました。

いいなと思ったら応援しよう!