EthereumのABIからPythonコードを自動生成するCLIツールを作った
Zenn初投稿です。
育児が少し落ち着き、スキマで時間取れるようになってきたことで、なんか作りたい欲が高まってきたので作りました。
タイトル通り、以前から作りたかったABIからPythonコードを自動生成するCLIツールを作りました。
Ethereumに限らず、Polygonなど他のEVM系のチェーンでも使えると思います。
名前は py-contract-codegen
です。
ちょっと長いのですが、良い名前が思いつかずでとりあえずこれでいきます。
リポジトリはこちらです。
なぜ作ったか
仕事などでEthereumのコントラクトを叩いてゴニョゴニョすることがあるのですが、その都度手作業でABIをEtherscanから取ってきて、ソースコードを書いてみたいなことが面倒で、こういうツールがあったら良いなと思っていました。
調べると、TypeChainというTypescriptでは素晴らしいツールがあったのですが、私はPythonで触ることが多くてPython版が見当たらなかったので作りました。
主な技術スタック
-
uv
-> Pythonパッケージの管理、ビルド、PyPIへのリリース -
Typer
-> コマンドライン管理ツール -
Jinja2
-> Pythonのコードをテンプレートから作成するのに使用しています。 -
web3.py
-> 生成したソースコードの実行とABI周りの処理はweb3.pyやeth_abiなどを使用しています。
最近よく紹介されてると思うので、特に書きませんがuvめちゃ良いです。
Rust製だけあって速いしシンプルなのでとても使いやすいです。
TyperはFastAPIの作者が作っていてとても使いやすいです。コマンドラインオプションとか綺麗に出力されて良いです。
py-contract-codegenで何ができるのか
ABIファイルを入力として受け取り、対応するPythonコードを生成します。
主な特徴は以下の通りです:
- ABIからPythonコードを自動生成
- Etherscan APIから自動でABI取得してPythonコードを自動生成
- 引数と返り値にtype hintを設定
Pythonコードはコントラクトの関数とイベントを取得できるように生成しています。
できる限り型を付けたいと思って作成していますが、Pythonなのでtype hintです。
インストール方法
pipやuvを使ってインストールできます
pip install py-contract-codegen
uv add py-contract-codegen --dev
使い方
コマンドラインオプション
╭─ Options ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ --abi-path PATH Path to ABI file. If not provided, `--abi-stdin` or `--contract-address` must be set [default: None] │
│ --abi-stdin --no-abi-stdin ABI content from stdin [default: no-abi-stdin] │
│ --contract-address TEXT Auto fetch abi from etherscan and generate code. Please set environment variable `ETHERSCAN_API_KEY` [default: None] │
│ --out-file PATH Path to save file name the generated code. If not provided, prints to stdout [default: None] │
│ --class-name TEXT Contract Class Name to save the generated code. If not provided, use `GeneratedContract` [default: GeneratedContract] │
│ --target-lib [web3_v7|web3_v6] Target library and version [default: web3_v7] │
│ --network [mainnet|sepolia] Ethereum network for fetching ABI [default: mainnet] │
│ --help Show this message and exit. │
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
使用例
ABIファイルを読み込んでコード生成
py-contract-codegen gen --abi-path contract.abi --out-file generated_contract.py
標準入力からコード生成
cat contract.abi | py-contract-codegen gen --abi-stdin --out-file generated_contract.py
コントラクトアドレスからEtehrscan APIでABIを取得してコード生成
py-contract-codegen gen --contract-address 0x123... --out-file generated_contract.py
これがやりたくて作ったようなものです。
この機能を使用する場合、環境変数に ETHERSCAN_API_KEY
を設定してください。
API Keyは無料で発行できますので、こちらを参考に。
注意点としては、EtherscanのAPIを使うので、Etherscanでコントラクトがverifyされている必要があります。
testnetのsepoliaを使う場合などは --network sepolia
を追加してください
py-contract-codegen gen --contract-address 0x123... --out-file generated_contract.py --network sepolia
生成されたソースコード例
実際にいくつか作成したものをリポジトリにあげています。
ruffでformat済なので実際は少しですが、違うと思います。
例えばUniswap V3はこのようにして実行しています。
py-contract-codegen gen --contract-address 0x1F98431c8aD98523631AE4a59f267346ea31F984 --out-file っgsrc/py_contract_code/gen/generated/contract/uniswap_v3.py --class-name UniswapV3Contract
# Autogenerated file.
from typing import Any, Iterable
from hexbytes import HexBytes
from web3 import Web3
from web3.contract.contract import ContractFunction
from web3.types import ENS, Address, BlockIdentifier, ChecksumAddress, EventData
ABI = [
{"inputs": [], "stateMutability": "nonpayable", "type": "constructor"},
{
"anonymous": False,
"inputs": [
{
"indexed": True,
"internalType": "uint24",
"name": "fee",
"type": "uint24",
},
{
"indexed": True,
"internalType": "int24",
"name": "tickSpacing",
"type": "int24",
},
],
"name": "FeeAmountEnabled",
"type": "event",
},
{
"anonymous": False,
"inputs": [
{
"indexed": True,
"internalType": "address",
"name": "oldOwner",
"type": "address",
},
{
"indexed": True,
"internalType": "address",
"name": "newOwner",
"type": "address",
},
],
"name": "OwnerChanged",
"type": "event",
},
{
"anonymous": False,
"inputs": [
{
"indexed": True,
"internalType": "address",
"name": "token0",
"type": "address",
},
{
"indexed": True,
"internalType": "address",
"name": "token1",
"type": "address",
},
{
"indexed": True,
"internalType": "uint24",
"name": "fee",
"type": "uint24",
},
{
"indexed": False,
"internalType": "int24",
"name": "tickSpacing",
"type": "int24",
},
{
"indexed": False,
"internalType": "address",
"name": "pool",
"type": "address",
},
],
"name": "PoolCreated",
"type": "event",
},
{
"inputs": [
{"internalType": "address", "name": "tokenA", "type": "address"},
{"internalType": "address", "name": "tokenB", "type": "address"},
{"internalType": "uint24", "name": "fee", "type": "uint24"},
],
"name": "createPool",
"outputs": [{"internalType": "address", "name": "pool", "type": "address"}],
"stateMutability": "nonpayable",
"type": "function",
},
{
"inputs": [
{"internalType": "uint24", "name": "fee", "type": "uint24"},
{"internalType": "int24", "name": "tickSpacing", "type": "int24"},
],
"name": "enableFeeAmount",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function",
},
{
"inputs": [{"internalType": "uint24", "name": "", "type": "uint24"}],
"name": "feeAmountTickSpacing",
"outputs": [{"internalType": "int24", "name": "", "type": "int24"}],
"stateMutability": "view",
"type": "function",
},
{
"inputs": [
{"internalType": "address", "name": "", "type": "address"},
{"internalType": "address", "name": "", "type": "address"},
{"internalType": "uint24", "name": "", "type": "uint24"},
],
"name": "getPool",
"outputs": [{"internalType": "address", "name": "", "type": "address"}],
"stateMutability": "view",
"type": "function",
},
{
"inputs": [],
"name": "owner",
"outputs": [{"internalType": "address", "name": "", "type": "address"}],
"stateMutability": "view",
"type": "function",
},
{
"inputs": [],
"name": "parameters",
"outputs": [
{"internalType": "address", "name": "factory", "type": "address"},
{"internalType": "address", "name": "token0", "type": "address"},
{"internalType": "address", "name": "token1", "type": "address"},
{"internalType": "uint24", "name": "fee", "type": "uint24"},
{"internalType": "int24", "name": "tickSpacing", "type": "int24"},
],
"stateMutability": "view",
"type": "function",
},
{
"inputs": [{"internalType": "address", "name": "_owner", "type": "address"}],
"name": "setOwner",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function",
},
]
class UniswapV3Contract:
def __init__(
self, contract_address: Address | ChecksumAddress | ENS, web3: Web3
) -> None:
self.contract_address = contract_address
self.web3 = web3
self.contract = web3.eth.contract(address=self.contract_address, abi=ABI)
def createPool(
self, tokenA: ChecksumAddress, tokenB: ChecksumAddress, fee: int
) -> ContractFunction:
return self.contract.functions.createPool(tokenA, tokenB, fee)
def enableFeeAmount(self, fee: int, tickSpacing: int) -> ContractFunction:
return self.contract.functions.enableFeeAmount(fee, tickSpacing)
def feeAmountTickSpacing(self, input_1: int) -> int:
return self.contract.functions.feeAmountTickSpacing(input_1).call()
def getPool(
self, input_1: ChecksumAddress, input_2: ChecksumAddress, input_3: int
) -> str:
return self.contract.functions.getPool(input_1, input_2, input_3).call()
def owner(self) -> str:
return self.contract.functions.owner().call()
def parameters(self) -> tuple[str, str, str, int, int]:
return self.contract.functions.parameters().call()
def setOwner(self, _owner: ChecksumAddress) -> ContractFunction:
return self.contract.functions.setOwner(_owner)
def get_event_FeeAmountEnabled(
self,
argument_filters: dict[str, Any] | None = None,
from_block: BlockIdentifier | None = None,
to_block: BlockIdentifier | None = None,
block_hash: HexBytes | None = None,
) -> Iterable[EventData]:
return self.contract.events.FeeAmountEnabled().get_logs( # type: ignore[attr-defined]
argument_filters=argument_filters,
from_block=from_block,
to_block=to_block,
block_hash=block_hash,
)
def get_event_OwnerChanged(
self,
argument_filters: dict[str, Any] | None = None,
from_block: BlockIdentifier | None = None,
to_block: BlockIdentifier | None = None,
block_hash: HexBytes | None = None,
) -> Iterable[EventData]:
return self.contract.events.OwnerChanged().get_logs( # type: ignore[attr-defined]
argument_filters=argument_filters,
from_block=from_block,
to_block=to_block,
block_hash=block_hash,
)
def get_event_PoolCreated(
self,
argument_filters: dict[str, Any] | None = None,
from_block: BlockIdentifier | None = None,
to_block: BlockIdentifier | None = None,
block_hash: HexBytes | None = None,
) -> Iterable[EventData]:
return self.contract.events.PoolCreated().get_logs( # type: ignore[attr-defined]
argument_filters=argument_filters,
from_block=from_block,
to_block=to_block,
block_hash=block_hash,
)
まとめ
ABIからPythonコードを自動生成するCLI py-contract-codegen
を紹介しました。
ぜひ使ってみてください。
もしかしたらバグがあったりするかもしれないので、そのときはIssueやPull Requestなどいただけると嬉しいです。
Discussion