web3.pyを利用してNFTを発行する

# 概要

web3.pyを利用して、Pythonからローカルのネットワークにデプロイしたコントラクトを実行する手順です。

本記事は下記の記事で利用した、スマートコントラクトやソースコードの一部を利用している続編となります。

https://qiita.com/sey323/items/6701b34d66a2e8c5ea17

本記事のリポジトリはこちらです。

https://github.com/sey323/python-dapps-sample

# 事前準備

## NFTをローカルのテストネットにデプロイ

実行する対象のコントラクトとして、下記の記事で作成した`SampleNFT`のコントラクトを利用する。このコントラクトをローカルのテストネットにデプロイする。

https://qiita.com/sey323/items/6701b34d66a2e8c5ea17


### テストネットの起動

はじめに、上記の記事の手順で環境構築とNFTの作成を行い、下記のコマンドでローカルにテストネットを起動する。今回の環境でテストネットは`127.0.0.1:8545`のポートを利用する。


```sh:
$ anvil -m $MNEMONIC
                             _   _
                            (_) | |
      __ _   _ __   __   __  _  | |
     / _` | | '_ \  \ \ / / | | | |
    | (_| | | | | |  \ V /  | | | |
     \__,_| |_| |_|   \_/   |_| |_|

    0.1.0 (e0afc7c 2023-04-22T00:12:06.184818000Z)
    https://github.com/foundry-rs/foundry

Available Accounts
==================

(0) "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" (10000 ETH)
(1) "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" (10000 ETH)
~ 省略 ~
(9) "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC" (10000 ETH)

Private Keys
==================

(0) 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
(1) 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d

~ 省略 ~
(9) 0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6

Wallet
==================
Mnemonic:          ${.envで$MNEMONICに指定したシークレットフレーズ}
Derivation path:   m/44'/60'/0'/0/


Base Fee
==================

1000000000

Gas Limit
==================

30000000

Genesis Timestamp
==================

1682166468

Listening on 127.0.0.1:8545
```

テストネット起動後、コントラクトの実行の際にトランザクションに署名するアカウントの、PrivateKeyを控えておく。

上記の実行例の場合は、「Available Accounts」の一覧のうち、先頭の`0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266`をコントラクトを実行するアカウントとする。

### コントラクトのデプロイ

テストネット起動後、`SampleNFT`をデプロイする。デプロイしたコントラクトアドレスも先ほどと同様に控えておく。

下記の実行例の場合は、`0xbe9da202a5cb55afbcf3f45af9b4aec1cb1e7bf7`がコントラクトアドレスとなる。

```sh:
$ source .env
$ forge script Deploy --broadcast --rpc-url $FOUNDRY
[⠆] Compiling...
No files changed, compilation skipped
Script ran successfully.

## Setting up (1) EVMs.

==========================

Chain 31337

Estimated gas price: 4.55551936 gwei

Estimated total gas used for script: 2000068

Estimated amount required: 0.00911134849531648 ETH

==========================

###
Finding wallets for all the necessary addresses...
##
Sending transactions [0 - 0].
⠁ [00:00:00] [#################################################] 1/1 txes (0.0s)
Transactions saved to: /Users/sey323/Workspace/programs/tmp/qiita-foundry-nft-sample/contract/broadcast/Deploy.s.sol/31337/run-latest.json

##
Waiting for receipts.
⠉ [00:00:00] [#############################################] 1/1 receipts (0.0s)
##### anvil-hardhat
✅ Hash: 0x46ada473f86ae6ba4ad43bc3de9f942963a04ad27a06273badebcf59b6894de8
Contract Address: 0xbe9da202a5cb55afbcf3f45af9b4aec1cb1e7bf7 # こちらを記録しておく
Block: 4
Paid: 0.005664249642788136 ETH (1538514 gas * 3.681636724 gwei)


Transactions saved to: /Users/sey323/Workspace/programs/tmp/qiita-foundry-nft-sample/contract/broadcast/Deploy.s.sol/31337/run-latest.json



==========================

ONCHAIN EXECUTION COMPLETE & SUCCESSFUL.
Total Paid: 0.005664249642788136 ETH (1538514 gas * avg 3.681636724 gwei)

Transactions saved to: /Users/sey323/Workspace/programs/tmp/qiita-foundry-nft-sample/contract/broadcast/Deploy.s.sol/31337/run-latest.json
```

:::note
下記の内容を確認して次の手順に進む
- [ ] テストネットが起動中であること
- [ ] ネストネットが`127.0.0.1:8545`で起動していること
- [ ] コントラクトを実行するアカウントのプライベートキーを控えていること
- [ ] コントラクトアドレスを控えていること
:::

:::note warn
注意
`anvil -m $MNEMONIC`コマンドで起動したテストネットを停止するとプライベートキーやデプロイしたスマートコントラクトの情報がリセットされる。
一度停止して再起動した場合は、再度プライベートキーの取得とコントラクトのデプロイを実行すること。
:::


## スマートコントラクトのABIを取得

web3.pyでスマートコントラクトを実行する場合、ABI形式のjsonファイルを取得する必要がある。

ABIとは「Contract Application Binary Interface」の略称で、Ethereum上で動くコントラクトに対するInterfaceの役割を持つ。ABIの仕様についてはSolidityのドキュメントを参照。

https://docs.soliditylang.org/en/v0.5.8/abi-spec.html

また、ABIについては下記の記事が非常にわかりやすかったので、こちらも合わせて参照。

https://y-nakajo.hatenablog.com/entry/2019/05/14/182854

ABIの取得はforgeコマンドを利用して行う。forgeコマンドではソースコードのbuildする際に、オプションを指定することで、ABI形式のjsonファイルを同時に生成することができる。

```sh:
forge build --force --extra-output-files abi
```

ビルドの実行が完了後、デフォルトの設定の場合は`./out/SampleNFT.sol/SampleNFT.abi.json`にabiファイルが出力される。


<details><summary>SampleNFT.abi.jsonの全体像</summary>

```json:out/SampleNFT.sol/SampleNFT.abi.json
[
  {
    "inputs": [],
    "stateMutability": "nonpayable",
    "type": "constructor"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "owner",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "approved",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "Approval",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "owner",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "operator",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "bool",
        "name": "approved",
        "type": "bool"
      }
    ],
    "name": "ApprovalForAll",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "from",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "to",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "Transfer",
    "type": "event"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "to",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "approve",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "owner",
        "type": "address"
      }
    ],
    "name": "balanceOf",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "getApproved",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "getSentence",
    "outputs": [
      {
        "internalType": "string",
        "name": "",
        "type": "string"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "owner",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "operator",
        "type": "address"
      }
    ],
    "name": "isApprovedForAll",
    "outputs": [
      {
        "internalType": "bool",
        "name": "",
        "type": "bool"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "ownerAddress",
        "type": "address"
      },
      {
        "internalType": "string",
        "name": "sentence",
        "type": "string"
      }
    ],
    "name": "mintNft",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "name",
    "outputs": [
      {
        "internalType": "string",
        "name": "",
        "type": "string"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "ownerOf",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "from",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "to",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "safeTransferFrom",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "from",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "to",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      },
      {
        "internalType": "bytes",
        "name": "data",
        "type": "bytes"
      }
    ],
    "name": "safeTransferFrom",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "operator",
        "type": "address"
      },
      {
        "internalType": "bool",
        "name": "approved",
        "type": "bool"
      }
    ],
    "name": "setApprovalForAll",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "bytes4",
        "name": "interfaceId",
        "type": "bytes4"
      }
    ],
    "name": "supportsInterface",
    "outputs": [
      {
        "internalType": "bool",
        "name": "",
        "type": "bool"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "symbol",
    "outputs": [
      {
        "internalType": "string",
        "name": "",
        "type": "string"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "index",
        "type": "uint256"
      }
    ],
    "name": "tokenByIndex",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "owner",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "index",
        "type": "uint256"
      }
    ],
    "name": "tokenOfOwnerByIndex",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "tokenURI",
    "outputs": [
      {
        "internalType": "string",
        "name": "",
        "type": "string"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "totalSupply",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "from",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "to",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "transferFrom",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  }
]
```
</details>


# 実装

PythonからSmapleNFTのコントラクトに対してNFTの発行(`mintNft`)と、発行したNFTの情報取得(`getSentence`)を行うスクリプトを作成する。

:::note

`mintNft`と`getSentence`をCLI上で実行した例は[こちら](https://qiita.com/sey323/items/6701b34d66a2e8c5ea17#4-%E5%AE%9F%E8%A1%8C
)を参照
:::

## 必要ライブラリのインストール

web3.pyをインストールする。


```sh
pip install web3
```

web3.pyの詳細な利用方法などについては、下記の公式ドキュメントを参照。

https://web3py.readthedocs.io/en/stable/quickstart.html


## SampleNFTを実行するクラスの作成

はじめにコントラクトを実行するラッパークラスを作成する。

今回実行するコントラクトの関数は、NFTをmintする`nftMint`と、mintしたNFTに刻んだ文字列を取得する`getSentence`である。これらを実行するコードをそのまま書くとトランザクションの発行や署名処理などで冗長になってしまうため、`SampleNFTContract`クラスでラップすることで呼び出し側の負荷を減らしてある。

```python:contract.py
import json
from web3 import Web3
from eth_account import Account


class WalletAccount:
    def __init__(self, private_key: str) -> None:
        _account = Account.from_key(private_key)
        self.private_key = private_key
        self.address = _account.address
        print(f"アカウントのウォレットアドレス: {self.address}")


class SampleNFTContract:
    def __init__(
        self,
        account: WalletAccount,
        address: str,
        abi_path: str,
        http_provider: str = "http://127.0.0.1:8545",
    ):
        self.contract_owner = account
        self.network = Web3(Web3.HTTPProvider(http_provider))

        if not self.network.is_connected():
            print("Ethereum Networkとの接続に失敗しました。終了します。")
            exit(-1)
        self.contract = self.network.eth.contract(
            address=Web3.to_checksum_address(address), abi=self._load_abi(abi_path)
        )

        name = self.contract.functions.name().call()
        print(f"スマートコントラクトの初期化に成功しました. コントラクト名: {name}")

    @staticmethod
    def _load_abi(api_json_path: str):
        with open(api_json_path, "r") as j:
            return json.load(j)

    def _execute(self, tx):
        """トランザクション実行の共通処理"""
        signed_tx = self.network.eth.account.sign_transaction(
            tx, self.contract_owner.private_key
        )
        # トランザクションの送信
        tx_hash = self.network.eth.send_raw_transaction(signed_tx.rawTransaction)

        return self.network.eth.wait_for_transaction_receipt(tx_hash)

    def mint_nft(self, address: str, sentence: str):
        """NFTを発行する

        Args:
            address (str): 所有者のウォレットアドレス
            sentence (str): 文章
        """
        tx = self.contract.functions.mintNft(
            Web3.to_checksum_address(address), sentence
        ).build_transaction(
            {
                "nonce": self.network.eth.get_transaction_count(
                    self.contract_owner.address,
                ),
                "from": self.contract_owner.address,
            }
        )
        return self._execute(tx)

    def get_sentence(self, tokenId: int) -> str:
        """トークンIDからを文章を取得する

        Args:
            tokenId (int): 対象のトークンID

        Returns:
            str: NFTに記録された文字列
        """
        return self.contract.functions.getSentence(tokenId).call()
```

コントラクトの実行にはトランザクション(今回の場合はNFTのmint)の実行に署名するウォレットアカウントが必要になる。ウォレットアカウントは`WalletAccount`として抽象化し、`SampleNFTContract`クラスに引数として与える。

個人の環境で実行する場合は、`PRIVATE_KEY`と`CONTRACT_ADDRESS`を、事前準備で取得したプライベートキーと、SampleNFTをデプロイした時に取得したコントラクトアドレスにそれぞれ変更する必要がある。

```python:main.py
from contract import SampleNFTContract, WalletAccount

PRIVATE_KEY = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" # 個人の環境に合わせて修正の必要あり
CONTRACT_ADDRESS = "0xbe9da202a5cb55afbcf3f45af9b4aec1cb1e7bf7" # 個人の環境に合わせて修正の必要あり
ABI_PATH = "./out/SampleNFT.sol/SampleNFT.abi.json"
HTTP_PROVIDER = "http://127.0.0.1:8545"

account = WalletAccount(private_key=PRIVATE_KEY)

snc = SampleNFTContract(
    account=account, # コントラクトの署名に利用するアカウント
    address=CONTRACT_ADDRESS,
    abi_path=ABI_PATH,
    http_provider=HTTP_PROVIDER,
)
```

## NFTのMint

NFTをmintする際は`SampleNFTContract`クラスの`mint_nft`を実行する。元のコントラクトの実行と同様に、第1引数にnftの所有者、第２引数にnftに刻む文字列を与える。

```python:main.py
~~省略~~
print(f"トランザクションの詳細: {snc.mint_nft(account.address, 'mint by python')}")
```

## NFTに刻んだ文章を取得

tokenIdに紐づくNFTに刻んだ文章を表示する。こちらも元のコントラクトと同様に、引数にNFTのトークンIDを指定する。

```python:main.py
~~省略~~
print(f"NFTに記録された文字列: {snc.get_sentence(tokenId=0)}")
```

## 実行

作成したスクリプトを実行する。

```sh:
$ python main.py
アカウントのウォレットアドレス: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
スマートコントラクトの初期化に成功しました. コントラクト名: SampleNFT
トランザクションの詳細: AttributeDict({'transactionHash': HexBytes('0xf40b83a9a710ca75604e46cdbbe63d8cdc8605582c7a9ca75ec3d5873cd75a58'), 'transactionIndex': 0, 'blockHash': HexBytes('0x345e4e93eb2dec0b1770fa2752038791df50dd59e0591601320cdcf25a23a8ab'), 'blockNumber': 11, 'from': '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', 'to': '0xbe9da202a5cb55afbcf3f45af9b4aec1cb1e7bf7', 'cumulativeGasUsed': 169400, 'gasUsed': 169400, 'contractAddress': None, 'logs': [AttributeDict({'address': '0xbe9da202a5cb55afbcf3f45af9b4aec1cb1e7bf7', 'topics': [HexBytes('0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'), HexBytes('0x0000000000000000000000000000000000000000000000000000000000000000'), HexBytes('0x00000000000000000000000044d2143174905b9a26b5dc2a4c67b651a9841e16'), HexBytes('0x0000000000000000000000000000000000000000000000000000000000000007')], 'data': HexBytes('0x'), 'blockHash': HexBytes('0x345e4e93eb2dec0b1770fa2752038791df50dd59e0591601320cdcf25a23a8ab'), 'blockNumber': 11, 'transactionHash': HexBytes('0xf40b83a9a710ca75604e46cdbbe63d8cdc8605582c7a9ca75ec3d5873cd75a58'), 'transactionIndex': 0, 'logIndex': 0, 'transactionLogIndex': '0x0', 'removed': False})], 'status': 1, 'logsBloom': HexBytes('0x00000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000001000000000000000000000000000000020000000000000000028000000000000000000000000000000000000200000000000020000000000000000000800000000000000000000000010000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000008000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000100000020000000000000000000000000000000000000000000000000000000000040000000'), 'type': 2, 'effectiveGasPrice': 1833397921})
NFTに記録された文字列: mint from python
```

トランザクションが発行され、NFTに記録した文字列「mint by python」がコンソールに表示されていることを確認できた。