16-Thumbnail_Image.png

NFT会員サイト作成TIPS - 保有NFT一覧を高速に取得する方法

NFT保有者限定サイトを作成するにあたり、ウォレットがどの tokenId を保持しているかを判定したい場面があります。例えばユーティリティの申し込みや判定において、特定のプレミアムがついた NFT のみ許可する一覧取得が必要になります。この記事ではどのトークンを保持しているかを高速に出力する実装方法について解説します。
公開日2023.01.11
更新日2023.01.11

はじめに

この記事はウォレットが保有する任意のコントラクトの NFT の一覧を高速に取得する方法を解説します。 トークンの一覧を取得するにあたり、呼び出し方法によっては時間がかかってしまい、適正な Dapp の体験を提供できない場合があります。
本記事では、通常の Web サイトに期待されるのと同レベルのレスポンスで NFT の保有を判定する実装方法を紹介します。

ゴール

本記事では 【「任意のウォレット」が「任意の NFT(ERC721)」で取得している全ての tokenId】 を ethers.js を用いて実現します。
16_001.png

対象読者

  • NFT 保有者向け会員サイトを作りたい開発者・起業家
  • NFT 保有者向け会員サイトを作っている開発者・起業家
  • 独自コントラクトを用いて開発している方
対象ではない読者
  • ERC1155 で実装を考えている方
  • Opensea の共通コントラクトを利用している方

注意点

  • 本記事は ERC721 のみを対象にしており、ERC1155 は対象にしておりません。あらかじめご了承ください。
  • この記事は「トークン一覧」を取得することを高速化することを目的としています。 トークンを持っているかいないかだけを判定したい場合 は、別途記事を作成しお伝えします。(記事作成予定)

この記事の成果物

下記のソースコードが成果物です。
本記事では成果物の考え方について説明していきます。

本題

本題の前半では、やってしまいがちな実装方法について説明します。またその実装方法がなぜ適正でないかを解説します。 後半では、よりベターかつ高速な取得方法について解説します。結論を急ぎたい方は、「OK 例」という項目から読み進めてください。

NG 例

取得が低速になってしまうパターン

ERC721 には ownerOf という関数が定義されています。tokenId をリクエストし、その tokenId を現在保有する Wallet を返す、という関数です。
この ownerOf を利用してユーザーのウォレットが含まれる tokenId を見つける実装をする場合、下記のようになります。
const getOwnedTokenIds = (holderAddress) =>
  [...new Array(tokenCounts)]
    .map(async (i) => ({
      tokenId: i,
      owner: await erc721.ownerOf(i).catch(() => constants.AddressZero),
    }))
    .filter((i) => holderAddress.toLowerCase() === i.owner?.toLowerCase())
    .map((i) => BigNumber.from(i.tokenId));
上記コードの解説
  • tokenId ごとに ownerOf を問い合わせ、tokenId と wallet の組み合わせを配列に格納。これを tokenId の最大数までやる。
  • 配列に格納された上記の組み合わせから、ユーザーのウォレットの tokenId のみを抽出する
何が問題か
一見これは正しそうですが、問題があります。tokenId の数だけブロックチェーンにリクエストする必要があります。10,000 件の tokenId を発行している場合、10,000 件のリクエストが必要です。その分リクエストに時間がかかってしまいます。結果、問い合わせに時間がかかってしまい、アプリケーションの体験をかなり損ねてしまいます。 件数によってはメモリから溢れてしまう場合もあります。
また、alchemy や infra などのノードサービスのリクエスト上限数を突破してしまうリスクも高まります。リクエスト上限数を突破した場合、アプリケーション自体が使えなくなってしまうため、運用に耐えないものとなります。
発行 tokenId 数が少なかったり、アクセス数が少ない場合は問題が表面化しませんが、いずれにせよスケールしない実装となります。
💡 10,000 トークン近くになると使い物にならなくなることを確認済み
なお参考までに、1,000 件では遅いものの取得は可能でしたが、10,000 件程度近くの場合、上記プログラムを next.js の api に載せた際、メモリから溢れてしまい API として機能しませんでした。
💡 thirdweb の getOwnedTokenIds について
NFT の Pre-build プラットフォームとして有名な thirdweb の sdk にはgetOwnedTokenIdsと呼ばれるメソッドがあります。 この実装は概ね上記で解説した実装となっています。一見便利なメソッドに見えますが、23/01/10 現在、このメソッドでは上記の実装をしており、レスポンス速度に問題を抱えています。

OK 例:解決策

ownerOf を活用した方法だと、前述のような問題に遭遇することを説明しました。
ではどんな対策があるか。ここでは Event を活用する方法を説明します。

前提知識:Event ログについて

コントラクトの Event ログから、転送履歴を取得することにより高速な tokenId 取得を実現します。
Event はコントラクトの状態変更をログとして保存し、かつアプリケーションに通知するための機能として EVM で用意されている機構です。Event はログとして保存されており、Event のパラメータは index を効かせることができるようになっています。 index が効いているパラメータで検索すると、その条件で素早く絞り込んで検索することができます。RDB に慣れている方はイメージがしやすいと思います。
ERC721 では、下記のような Event が定義されています。
event Transfer(address indexed _from, address indexed _to, uint256 indexed _tokenId);
_tokenId の保有者を変更する際に event として発行することを規格として定めています。転送元( _from )から転送先( _to )にどのトークン( _tokenId )を転送するかを記録しています。
そして、この転送元( _from )と転送先( _to )に index を貼るよう定義しています。 indexed というのが index を行うための modifier となっています。それにより転送元( _from )あるいは転送先( _to )で検索をかけた時に、当該の条件に当てはまる EventLog を素早く取得できるようになっています。

結論:Event ログを利用した取得方法

では早速取得を試してみましょう。
結論が下記コードです。コード文中にコメントを記述していますが、解説は、コードの後となります。
なお今回の解説にあたり、下記 githubIssue を参考にさせていただきました。
import { ethers } from "ethers";

const getCollection = async (holderAddress: string): Promise<number[]> => {
  // アドレスが同値かどうかを判定する関数
  const isAddressesEqual = (address1: string, address2: string) => {
    return address1.toLowerCase() === address2.toLowerCase();
  };

  // 設定値: ABI(Transferイベントのみ抽出)
  const sampleAbi = [
    {
      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",
    },
  ];

  // ① Provider インスタンスを作成する。
  const provider = new ethers.providers.JsonRpcProvider(
    process.env.RPC_URL as string,
    process.env.NETWORK as string
  );

  // ②  Contract インスタンスを作成する。
  const token = new ethers.Contract(
    process.env.NFT_CONTRACT_ADDRESS as string,
    sampleAbi,
    provider
  );

  // ③ 調べたいホルダーのウォレットの過去のtokenIdの送信イベントログすべて取得する。
  const sentLogs = await token.queryFilter(
    token.filters.Transfer(holderAddress, null)
  );

  // ④ 調べたいホルダーのウォレットの過去のtokenIdの受信イベントログすべて取得する。
  const receivedLogs = await token.queryFilter(
    token.filters.Transfer(null, holderAddress)
  );

  // ⑤ ③と④のログを結合し、EventLogを時間が古いものから順に時系列で並べる。
  const logs = sentLogs
    .concat(receivedLogs)
    .sort(
      (a, b) =>
        a.blockNumber - b.blockNumber || a.transactionIndex - b.transactionIndex
    );

  // ⑥ ⑤のログを操作して、調べたいウォレットが最終的に持っている最新のtokenIdを取得する
  const owned = new Set<number>();

  for (const log of logs) {
    if (log.args) {
      const { from, to, tokenId } = log.args;

      if (isAddressesEqual(to, holderAddress)) {
        owned.add(Number(tokenId));
      } else if (isAddressesEqual(from, holderAddress)) {
        owned.delete(Number(tokenId));
      }
    }
  }

  // ⑦ Setをarrayに戻して返却
  return Array.from(owned);
};

// 実行
getCollection(process.env.YOUR_WALLET_ADDRESS as string).then((result) => {
  console.log("result", result);
});

コード解説

①   provider を作成する

// ① providerを作成する。
const provider = new ethers.providers.JsonRpcProvider(
  process.env.RPC_URL as string,
  process.env.NETWORK as string
)
Provider はブロックチェーンと接続するためのクラスです。 Provider には様々なクラスがありますが、
ここでは JsonRpcProvider を利用します。第 1 引数に RPC サーバーの URL を入れ、第 2 引数にチェーン名あるいはネットワーク ID を入れます。

② Contract インスタンスを作成する。

// ②  Contract インスタンスを作成する。
const contract = new ethers.Contract(
  process.env.NFT_CONTRACT_ADDRESS as string,
  sampleAbi,
  provider
);
Contractはコントラクトを操作するためのクラスです。 第 1 引数に保有を確認したい NFT のコントラクトのアドレス、第 2 引数に ABI、第 3 引数に ① で作成した provider を入れます。
なお ABI にはコントラクトの ABI すべてを入れてもいいですし、必要最小限の ABI でも OK です。 ここでは TransferEvent しか利用しないので下記を定義しています。
const sampleAbi = [
  {
    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",
  },
];

③ 調べたいホルダーの 過去の送信履歴をすべて取得する

// ③ 調べたいホルダーのウォレットの過去のtokenIdの送信イベントログすべて取得する。
const sentLogs = await contract.queryFilter(
  contract.filters.Transfer(holderAddress, null)
);
コントラクトの EventLog から、ホルダーが送信した全ての EventLog を抽出します。
queryFilter は、コントラクトの過去全てのイベントの中から特定の条件で検索をかけるための関数です。 第一引数に渡している contract.filters.Transfer(holderAddress, null) はその条件を出力する関数です。 出力はシンプルなオブジェクトとなっています。
// 第1引数に送信元のアドレスを指定、第2引数は指定しないした検索条件
console.log(contract.filters.Transfer(holderAddress, null));
transfer {
 address: '0x...',
 topics: [
   '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef',
   '0x000000000000000000000000c23f9279acb04fedd827a36d086a454e81fa5adc'
 ]
}

④ 調べたいホルダーの 過去の受信履歴をすべて取得する

// ④ 調べたいホルダーのウォレットの過去のtokenIdの受信イベントログすべて取得する。
const receivedLogs = await contract.queryFilter(
  contract.filters.Transfer(null, holderAddress)
);
今度は ③ と逆のログを出力します。 コントラクトの EventLog から、ホルダーがどこかに送信した全ての EventLog を抽出します。
第一引数に渡している contract.filters.Transfer(null, holderAddress) は下記のようなオブジェクトになっています。
// 第1引数に送信元のアドレスを指定、第2引数は指定しないした検索条件
console.log(contract.filters.Transfer(null, holderAddress));
transfer {
  address: '0x...',
  topics: [
    '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef',
    null,
    '0x000000000000000000000000c23f9279acb04fedd827a36d086a454e81fa5adc'
  ]
}

⑤ ③ と ④ のログを結合し、送受信履歴を時系列で古い順にソート

// ⑤ ③と④のログを結合し、EventLogを時間が古いものから順に時系列で並べる。
const logs = sentLogs
  .concat(receivedLogs)
  .sort(
    (a, b) =>
      a.blockNumber - b.blockNumber || a.transactionIndex - b.transactionIndex
  );
③ と ④ のログを結合し、blockNumber が古い順にソートします。blockNumber が同じ場合は transactionIndex が古いものを先にします。EventLog が時系列で古い順にソートされます。
これにて、logs にホルダーの任意の送受信の取引履歴が、時系列で古い順に格納されます。

⑥ 現在保有している分だけを抽出

// ⑥ ⑤のログを操作して、調べたいウォレットが最終的に持っている最新のtokenIdを取得する
const owned = new Set<number>();

for (const log of logs) {
  if (log.args) {
    const { from, to, tokenId } = log.args;

    if (isAddressesEqual(to, holderAddress)) {
      owned.add(Number(tokenId));
    } else if (isAddressesEqual(from, holderAddress)) {
      owned.delete(Number(tokenId));
    }
  }
}
最後の仕上げです。 ⑤ で作成した送受信履歴のログを、1 つずつ確認していきます。
古いものから、受信している Event ログがあれば変数 owned に add。 もし送信していれば変数 owned からその id を取り除く、といった処理を行います。
16_002.png
古い順から処理するので、このループが終わった時点で最新の保有データが抽出されるという仕組みです。
これにてホルダーが保有するトークンを tokenId に抽出することができました。

⑦ Set を array に戻して返却

データの重複を防ぐために Set を用いていましたが、 抽出されたトークン一覧を扱いやすいように array に変換し、返却します。
これにてメソッド呼び出し側は、ホルダーの保有するトークン一覧を取得できます。
[1, 10, 30];

この実装の懸念

ただしこの仕組みであっても、tokenId の数が巨大である場合懸念があります。 その場合はリアルタイムでの取得は諦め、サーバーと DB を組んで定期的にバッチを回してキャッシュする機構を設けたり the graph等のキャッシュシステムを用いて運用する方法も考えられます。

まとめ

NFT を持っているか持っていないかを判定するのは簡単ですが、何のトークンを保有しているのか tokenId を列挙する場合はパフォーマンスに配慮する必要があります。ベストではありませんがベターな選択として紹介しました。
また、よりベターな方法があれば、ご教示いただけると嬉しいです。
もしご存知の方、twitterの DM いただけますと幸いです 🙏

宣伝

最後に宣伝です。
hanzochang では、NFT 保有者会員サイトを作成したい、新規事業担当者様・スタートアップ事業者様・起業家様向けに、NFT を用いた会員サイトを実装しています。

NFT 会員サイトの発注をしたい

エンジニアリングがわからなく不安な方や、すぐに検証を始めたい実現したい起業家様などに向けて、立ち上げサポートを行なっておりますので、お気軽にお問合せください。
本記事だけではわからないことや、実現したいアイディアに対し、技術視点でのブレインストーミングや企画発案、PoC 実装、プロダクションリリース等が可能です。
東京在住ですので東京のオフィスへのご訪問も柔軟に対応可能です。

オンラインで学びたい

有償になりますが 1on1 でのメンタリングも行っております。
個人の方でもご相談可能ですので下記フォームよりお問合せください。Web3 以前に、React 等のモダンフロントエンドがわからない人向けにもレクチャーいたします。レクチャーの実績もございますのでお気軽にお問合せください。

筆者について

私半澤は、NFT/Web3 アプリケーション構築 を中心に活動するフリーランスエンジニアです。開発案件はもちろんのこと、自分自身で web3 案件を請負してきた経験を活かし、web3 の知識が浸透していないチームでのディレクション方法や PjM の支援を行なっています。
広告系キャンペーンサイトの企画や、ウェブアプリケーションの開発と運用を経験してきた知見を活かし、上流工程も対応可能です。
プロフィールやスキルセット、経歴は下記に掲載しております。ご相談の際に参考にしていただければと存じます。
picture
hanzochang
代表取締役 半澤勇大
慶應義塾大学卒業後、AOI Pro.にてWebプランナーとして勤務。ナショナルクライアントのキャンペーンサイトの企画・演出を担当。その後開発会社に創業メンバーとして参加。Fintech案件や大手企業のDXプロジェクトに関わり、その後個人事業主として独立。2023年にWeb3に特化した開発会社として法人化。

2017年ごろより匿名アカウントでCryptoの調査等を行い、ブロックチェーンメディアやSNSでビットコイン論文等の図解等を発信。
hanzochangはフリーランスのエンジニアです
スキルセットや特徴は下記よりご確認いただけます
fixed
© 2023 hanzochang