見出し画像

Solanaのウォレット内から、特定のupdate_authorityを持つNFTをリストアップする

処理概要とポイント

  • NFTのシリーズ (SSCとかPortalsとかDegen Apeとか) のNFTを保持しているかをチェックする。

  • NFTはミントアカウントは各NFTごとに1個あり、バラバラなので、ミントアカウントの Pubkey がわかっても判定できない。

  • ミントアカウントの Pubkey をシードにして導出されるPDAであるメタデータアカウントに含まれる update_authority がシリーズ内で同じになるので、update_authority を取得して判定する。

  • solana-py を使った Python3 実装 (3.9 で動作確認)

トークンアカウントの公開鍵の一括取得

get_token_accounts_by_owner

アカウントのデータ(AccountInfo)の一括取得

get_multiple_accounts

メタデータアカウントのアドレス取得

find_program_address

処理概要図

コード

  • WALLET_PUBKEY と TARGET_UPDATE_AUTHORITY を目的のものにする

  • アカウントのデータ解析用に borsh_construct 利用

  • requests を使った画像URL取得は遅い (不要なら集めない)

from solana.rpc.api import Client
from solana.rpc.types import TokenAccountOpts
from solana.publickey import PublicKey
from spl.token.constants import TOKEN_PROGRAM_ID
from borsh_construct import U8, U32, U64, CStruct, String
import base64
import math
import time
import requests
from typing import List

# 参考にさせていただきましたm(__)m: https://zenn.dev/regonn/articles/solana-nft-01


# 多くの AccountInfo を取得する処理になるのでまとめて取得
def get_multiple_accounts(clnt: Client, pubkeys: List[PublicKey]):
    # Client.get_multiple_accounts は一回に max 100 件のため分割処理する
    MAX_PUBKEYS = 100

    account_infos = []
    for i in range(math.ceil(len(pubkeys)/MAX_PUBKEYS)):
        # RPCの制限にかからないように1秒に5回程度に減速
        time.sleep(0.2)

        start = i*MAX_PUBKEYS
        end = min(start + MAX_PUBKEYS, len(pubkeys))
        res = clnt.get_multiple_accounts(pubkeys[start:end])
        account_infos.extend(res["result"]["value"])
    return account_infos


def enum_nft_series(wallet: PublicKey, target_update_authority: PublicKey):
    # METAPLEXのプログラム
    METAPLEX_METADATA_PROGRAM_ID = PublicKey('metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s')

    # アカウントのデータレイアウト(取りたい先頭部分だけ)
    # https://docs.rs/spl-token/latest/spl_token/state/struct.Account.html
    TokenAccountLayout = CStruct("mint" / U8[32], "owner" / U8[32], "amount" / U64)
    # https://docs.rs/spl-token/latest/spl_token/state/struct.Mint.html
    MintAccountLayout = CStruct("coption" / U32, "mint_authority" / U8[32], "supply" / U64, "decimals" / U8)
    # https://github.com/metaplex-foundation/python-api/blob/main/metaplex/metadata.py
    MetaplexMetadataAccountLayout = CStruct("key" / U8, "source" / U8[32], "mint" / U8[32],
                                            "name" / String, "symbol" / String, "url" / String)

    clnt = Client("https://api.mainnet-beta.solana.com", timeout=60)

    # by_owner でウォレットに紐づくトークンアカウントを取得
    opts = TokenAccountOpts(encoding="base58",
                            program_id=TOKEN_PROGRAM_ID)
    res = clnt.get_token_accounts_by_owner(wallet, opts=opts)
    token_account_pubkeys = []
    for a in res["result"]["value"]:
        token_account_pubkeys.append(PublicKey(a["pubkey"]))

    # トークンアカウントの pubkey でソートしておく(順番が一定になるように)
    token_account_pubkeys.sort(key=lambda pk: str(pk))

    # トークンアカウントの AccountInfo を一括取得し、データから mint, amount を取得
    account_infos = get_multiple_accounts(clnt, token_account_pubkeys)
    token_account_datas = []
    mint_account_pubkeys = []
    for (pubkey, account_info) in zip(token_account_pubkeys, account_infos):
        # データをパース
        data_base64 = account_info["data"][0]
        data_bytes = base64.b64decode(data_base64)
        token_account_data = TokenAccountLayout.parse(data_bytes)

        mint = PublicKey(token_account_data.mint)
        amount = token_account_data.amount

        token_account_datas.append({"pubkey": pubkey, "mint": mint, "amount": amount})
        mint_account_pubkeys.append(mint)

    # ミントアカウントの AccountInfo を一括取得し、データから mint_authority, supply, decimals を取得
    account_infos = get_multiple_accounts(clnt, mint_account_pubkeys)
    mint_account_datas = []
    for (pubkey, account_info) in zip(mint_account_pubkeys, account_infos):
        # データをパース
        data_base64 = account_info["data"][0]
        data_bytes = base64.b64decode(data_base64)
        mint_account_data = MintAccountLayout.parse(data_bytes)

        # mint_authorityは設定されていない場合は無視
        mint_authority = PublicKey(mint_account_data.mint_authority) if mint_account_data.coption == 1 else None
        supply = mint_account_data.supply
        decimals = mint_account_data.decimals

        mint_account_datas.append({"pubkey": pubkey, "mint_authority": mint_authority, "supply": supply, "decimals": decimals})

    # ミントアカウント(mint_authorityではない)の pubkey から NFTのメタデータアカウントの pubkey へ変換 (METAPLEX仕様)
    metadata_account_pubkeys = []
    for mint_account_pubkey in mint_account_pubkeys:
        # メタデータのアカウントはミントアカウントをキーにしたPDA
        metadata_account, bump = PublicKey.find_program_address([
            b'metadata',
            bytes(METAPLEX_METADATA_PROGRAM_ID),
            bytes(mint_account_pubkey)],
            METAPLEX_METADATA_PROGRAM_ID)
        metadata_account_pubkeys.append(metadata_account)

    # メタデータアカウントの AccountInfo を一括取得し、データから update_authority, name, url を取得
    account_infos = get_multiple_accounts(clnt, metadata_account_pubkeys)
    metadata_account_datas = []
    for (pubkey, account_info) in zip(metadata_account_pubkeys, account_infos):
        # METAPLEXのNFTではないミントアカウントに対しても処理しているのでアカウントが存在しない場合あり
        # アカウントのオーナーがMETAPLEXではないものは無視
        if account_info is None or account_info["owner"] != str(METAPLEX_METADATA_PROGRAM_ID):
            metadata_account_datas.append(None)
            continue

        # データをパース
        data_base64 = account_info["data"][0]
        data_bytes = base64.b64decode(data_base64)

        # 先頭1byteが「4」でないものも無視(雑...)
        if len(data_bytes) > 0 and data_bytes[0] != 4:
            metadata_account_datas.append(None)
            continue

        metadata_account_data = MetaplexMetadataAccountLayout.parse(data_bytes)
        update_authority = PublicKey(metadata_account_data.source)
        # 余分なヌル文字を消す
        name = metadata_account_data.name.replace('\0', '')
        url = metadata_account_data.url.replace('\0', '')

        metadata_account_datas.append({"pubkey": pubkey, "update_authority": update_authority, "name": name, "url": url})

    # 情報集約
    for (token_account, mint_account, metadata_account) in zip(token_account_datas, mint_account_datas, metadata_account_datas):
        # 目的の update_authority を持ち、残高が 0 ではないものを表示
        # トークンアカウントが残っているだけの場合があるので残高の条件も入れている
        if metadata_account is not None \
                and metadata_account["update_authority"] == target_update_authority \
                and token_account["amount"] > 0:

            # 画像URL取得 (遅いので全件やるのは避けたほうがよい)
            json_url = metadata_account["url"]
            res = requests.get(json_url)
            image_url = res.json()["image"]

            print(metadata_account["name"])
            print("\tJSON url:", json_url)
            print("\timage url:", image_url)
            print("\ttoken account:", token_account)
            print("\tmint account:", mint_account)
            print("\tmetadata account", metadata_account)


if __name__ == '__main__':
    # 検索したいウォレットと目的の update_authority
    # https://nfteyez.global/accounts/94qM9awvQiW35vmS5m86sHeJp1JZAQWkW7w3vYwHZeor
    WALLET_PUBKEY = PublicKey("94qM9awvQiW35vmS5m86sHeJp1JZAQWkW7w3vYwHZeor")
    # Degen Ape: DC2mkgwhy56w3viNtHDjJQmc7SGu2QX785bS4aexojwX
    TARGET_UPDATE_AUTHORITY = PublicKey("DC2mkgwhy56w3viNtHDjJQmc7SGu2QX785bS4aexojwX")

    enum_nft_series(WALLET_PUBKEY, TARGET_UPDATE_AUTHORITY)

実行結果例

nfteyez.global でみつけたウォレット

ここから有名な Degen Ape を探す。

スクリプト実行結果

Degen Ape #2340 
	JSON url: https://arweave.net/m3eZzMvnp4tPeYg1vHdiTfXIczEuVZJZJ21VxD0rqNk
	image url: https://arweave.net/xHdwTrZs9x_AXZ_KVgH35eFzNkgpcwXAyM6re6vlC2Q
	token account: {'pubkey': 5pW6oFWXrCdjeVcf3A9GsCPirzcJdH2BFc5RdUFguakS, 'mint': 9fB9PubtbFj5LtpASvEmEa7fBkT3fnkVKLXjESQAGLVb, 'amount': 1}
	mint account: {'pubkey': 9fB9PubtbFj5LtpASvEmEa7fBkT3fnkVKLXjESQAGLVb, 'mint_authority': 7g76CmP3hn1hQZMMdEqjP5nTsiaAt7Ng2mwfm4xE2THw, 'supply': 1, 'decimals': 0}
	metadata account {'pubkey': AAbf7s2FAKPQzD2bR9RPAJ9VY5rVHpgat8473ivfsAZG, 'update_authority': DC2mkgwhy56w3viNtHDjJQmc7SGu2QX785bS4aexojwX, 'name': 'Degen Ape #2340 ', 'url': 'https://arweave.net/m3eZzMvnp4tPeYg1vHdiTfXIczEuVZJZJ21VxD0rqNk'}
Degen Ape #8088 
	JSON url: https://arweave.net/Fgt-6OK9_BFFqDm16DDPDdBlEohxvdBVreLdmffCQws
	image url: https://arweave.net/bp_lMJjMRYtlmZRpbhLQbiVH7vX_L9Rve-C-2AsAGJQ
	token account: {'pubkey': 9qpEN2SEhdhr4JApnqhcAASrA2ZwTbAyFkShoEVv15e1, 'mint': HNKDFDHgt3xPZXdvzpppSXnjY2ZV3GaNgymdBRsJZXyP, 'amount': 1}
	mint account: {'pubkey': HNKDFDHgt3xPZXdvzpppSXnjY2ZV3GaNgymdBRsJZXyP, 'mint_authority': 8Hz5fmN3FY4cv1kLYcVcJV63ZwEpe6nHziGmNhNq3BeC, 'supply': 1, 'decimals': 0}
	metadata account {'pubkey': GMEAh7VoGdAAoNudLKGvoByXBF5jWeT3BrsHmyVxXFuw, 'update_authority': DC2mkgwhy56w3viNtHDjJQmc7SGu2QX785bS4aexojwX, 'name': 'Degen Ape #8088 ', 'url': 'https://arweave.net/Fgt-6OK9_BFFqDm16DDPDdBlEohxvdBVreLdmffCQws'}
Degen Ape - Steve Aoki
	JSON url: https://arweave.net/CW7Z5P8vUynzkM18w9I7s2ZdoMvkYZTLm7YokDf516s
	image url: https://arweave.net/FtYHuOqkDtofC1oQnXXXmRhcxFHzBoNtVm5Apb1oJoU?ext=png
	token account: {'pubkey': nvc6HJVpQgE6Q5SihRbmj51EGbrBxYtKuBkQ4BdVe5B, 'mint': 5egGKXUXjoc65f9hLS69ypk4XofpgHXh1GLW74CFbDuc, 'amount': 1}
	mint account: {'pubkey': 5egGKXUXjoc65f9hLS69ypk4XofpgHXh1GLW74CFbDuc, 'mint_authority': 3gVDFkdLojSsjEniZySoJxMgN1CN7Y5PupPQXtbf29bS, 'supply': 1, 'decimals': 0}
	metadata account {'pubkey': D9oNn8gn9watLVY3FcETpsvywxcpShsxUYpzhR6vVawZ, 'update_authority': DC2mkgwhy56w3viNtHDjJQmc7SGu2QX785bS4aexojwX, 'name': 'Degen Ape - Steve Aoki', 'url': 'https://arweave.net/CW7Z5P8vUynzkM18w9I7s2ZdoMvkYZTLm7YokDf516s'}

こちらを参考にさせていただきました m(_ _)m

メモ

OptionとCOptionのシリアライズの違い (そう見える)

COptionの場合は固定長、Optionは None の場合は 1 byte。COptionの None か None でないかの管理データは 4byte。

Option<T> (None) → [0]
Option<T> (Some) → [1, Tのbytes]
COption<T> (None) → [0, 0, 0, 0, Tのbytes] (Tのbytesのデータは不定)
COption<T> (Some) → [1, 0, 0, 0, Tのbytes]

Javascript (@solana/web3.js)

Connection.getTokenAccountsByOwner
Connection.getParsedTokenAccountsByOwner

Connection.getMultipleAccountsInfo

PublicKey.findProgramAddress

Metadata の扱いなど (JSのライブラリがMetaplexから提供されている)

NFTのミントアカウントから所有者を特定する

getTokenLargestAccounts
トークンの保有者上位を表示 → NFTなら結果的に所有者1人が1位になる

Metadata 内にもミントアカウントの pubkey が含まれているので、Metadata アカウント → ミントアカウント → 所有者、の逆引きも可能


この記事が気に入ったらサポートをしてみませんか?