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 アカウント → ミントアカウント → 所有者、の逆引きも可能