👓

EthereumのABIからPythonコードを自動生成するCLIツールを作った

2024/08/29に公開

Zenn初投稿です。
育児が少し落ち着き、スキマで時間取れるようになってきたことで、なんか作りたい欲が高まってきたので作りました。

タイトル通り、以前から作りたかったABIからPythonコードを自動生成するCLIツールを作りました。
Ethereumに限らず、Polygonなど他のEVM系のチェーンでも使えると思います。

名前は py-contract-codegen です。
ちょっと長いのですが、良い名前が思いつかずでとりあえずこれでいきます。
リポジトリはこちらです。

https://github.com/naoki-maeda/py-contract-codegen

なぜ作ったか

仕事などでEthereumのコントラクトを叩いてゴニョゴニョすることがあるのですが、その都度手作業でABIをEtherscanから取ってきて、ソースコードを書いてみたいなことが面倒で、こういうツールがあったら良いなと思っていました。

調べると、TypeChainというTypescriptでは素晴らしいツールがあったのですが、私はPythonで触ることが多くてPython版が見当たらなかったので作りました。

https://github.com/dethcrypto/TypeChain

主な技術スタック

  • 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は無料で発行できますので、こちらを参考に。
https://docs.etherscan.io/getting-started/viewing-api-usage-statistics

注意点としては、EtherscanのAPIを使うので、Etherscanでコントラクトがverifyされている必要があります。

testnetのsepoliaを使う場合などは --network sepolia を追加してください

py-contract-codegen gen --contract-address 0x123... --out-file generated_contract.py --network sepolia

生成されたソースコード例

実際にいくつか作成したものをリポジトリにあげています。
ruffでformat済なので実際は少しですが、違うと思います。

https://github.com/naoki-maeda/py-contract-codegen/tree/main/src/py_contract_codegen/generated/contract

例えばUniswap V3はこのようにして実行しています。

https://etherscan.io/address/0x1F98431c8aD98523631AE4a59f267346ea31F984

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