新着:HeliusがLight Protocolを買収
Solanaの圧縮
ブログ/基礎

Solanaの圧縮について知っておくべきすべて

Developer Experience EngineerXの0xIchigoLinkedInの0xIchigoGitHubの0xIchigo
読了時間:32分

この記事では何を解説しますか?

今すぐ100万個のNFTを150米ドル未満でミントできると言ったら、信じられますか?あり得ないと思うでしょう。ブロックチェーンによっては、それほど多くのNFTをミントするのに100万ドル以上かかるはずです。そうではないでしょうか?

ステート圧縮は、Merkle treeとSolanaの台帳を活用してストレージコストを大幅に削減しながら、Solanaのベースレイヤーが持つセキュリティと分散性を継承する新しいプリミティブです。この記事では、Solanaの圧縮について包括的かつ詳細に解説します。よくある誤解から圧縮NFTの転送まで、あらゆる内容を取り上げます。ステート圧縮に加え、圧縮NFTを取得、ミント、転送する方法を学びたい場合、始めるために必要な記事はこれだけです。

この記事では、すでにこちらの記事を読んでいることを前提としています。 暗号技術ツール入門 — ハッシュ関数とMerkle treeの解説 この記事ではMerkle treeに関する知識を前提とするため、先にお読みいただくことが重要です。また、本記事では並行Merkle treeについて補足し、そのサイズ設定と作成方法をさらに詳しく解説します。

この記事では、Bubblegum SDKとUmiの両方を使用し、並行Merkle treeの作成、圧縮NFTのミントと転送に関するさまざまなアプローチを紹介します。さまざまなコードベースでどちらのツールにも触れる可能性があるため、両方に慣れておくことが役立ちます。Bubblegum SDKは、ワークフローを通じて基盤となる仕組みをより明確に理解できるため、特に学習を促進する目的で取り上げています。一方、Umiはこれらのプロセスを効率化する、より簡潔なワークフローを提供します。

よくある誤解

ステート圧縮と圧縮NFTの複雑な仕組みを掘り下げる前に、いくつか明確にしておく必要があります。

Solanaの圧縮は従来の圧縮と同じである

これは誤りです。従来、圧縮はファイルやデータのサイズを削減するために使われます。主な目的は、元のファイルより少ないビット数でデータを保存または送信することです。圧縮アルゴリズムは、大きく2種類に分けられます。

  • 可逆圧縮:圧縮されたデータから元のデータを復元できます
  • 非可逆圧縮:「重要度の低い」情報を削除してファイルサイズを縮小します

圧縮NFTは、何らかの可逆圧縮または非可逆圧縮アルゴリズムによってデータを小さくしたNFTではありません。NFTに関連付けられたアート、音楽、メタデータの品質や寸法を下げるものでもありません。Solanaにおけるこの概念は、まったく異なる意味を持ちます。これは、基盤となるブロックチェーン台帳がNFT関連の情報を保存する方法を最適化するものです。アカウントの観点では、複数のアカウント(この場合はNFT)をステートに保存される単一のMerkle rootへ集約することで、台帳に圧縮しています。このプロセスにより、検証可能性を維持しながらストレージコストを大幅に削減できます。

圧縮データをオフチェーンに保存するのは危険であり、脆弱性につながる

これは誤りです。データをハッシュ化し、そのMerkle rootをオンチェーンに保存すれば、データをオフチェーンに安全に保存できます。厳密には、圧縮NFTはオフチェーンに保存されているわけではありません。台帳から再導出できるものはオンチェーンと見なされるため、データは引き続きオンチェーンにあります。違いは、アカウントにはバリデータがメモリ内に保持する経済的インセンティブがある一方、台帳にはアーカイブノード経由でアクセスする必要があることです。ステート圧縮はこの2つを統合し、アカウント内のステートを介して台帳データを検証できるようにします。その際も、Solana自体のセキュリティと分散性は維持されます。台帳とは何か、なぜ安全なのかについては、別のセクションで解説します。

ツリーの保存に使用しているインデクサーやRPCプロバイダーが停止すると、並行Merkle treeを失う可能性がある

ツリーが失われることはありません。台帳にアクセスできる人なら誰でも、ツリーの履歴を再実行することでツリー全体を再構築できます。

並行Merkle treeは更新を並列処理できる

よくある誤解の1つは、「並行」という言葉が、オンチェーンMerkle treeに対する複数の更新を並列実行できることを意味するというものです。並行Merkle treeでは同じブロック内で複数のリーフを置換できますが、これらの更新はバリデータによって順番に処理されます。バリデータがオンチェーンの並行Merkle treeに影響する一連のトランザクションを受け取ると、同じスロット内で処理できます。ただし、スロットごとのデータが並行して生成されるわけではありません。次のセクション「ステート圧縮とは?」で詳しく解説します。

ツリーとコレクションは同じものである

並行Merkle treeとコレクションは同じものではありません。1つのコレクションで、任意の数の並行Merkle treeを使用できます。NFTのグループ化は、その保存方法とは独立している点に注意することが重要です。NFTはアカウント内に存在することも、台帳へ圧縮されることもあり、1つまたは複数の任意の数のツリーにまたがることができます。ただし、複雑さを軽減するため、1つの並行Merkle treeは1つのコレクションだけに使用することを推奨します。

ステート圧縮とは?

ステート圧縮は、台帳データの暗号学的ハッシュを作成してアカウントに保存することで、ストレージを最適化します。このアプローチは、台帳が本来備えているセキュリティと不変性を活用しながら、台帳内に保存されたデータを検証するための堅牢なフレームワークを提供します。

これは、Solana上に構築されるアプリケーションにとって費用対効果の高いソリューションです。開発者は、高価なアカウントベースのストレージの代わりに、台帳のストレージ領域を使用できるようになります。つまり、ステート圧縮はデータの整合性を保証するだけでなく、Solana上のリソース割り当てにおいても費用対効果の高いソリューションとなります。

Solanaのステート圧縮を支えるのは、並行Merkle treeです。並行Merkle treeは、証明を高速に先送りできるように、複数のトランザクションを立て続けに処理することに最適化されています。これは、更新のたびにツリーの証明が無効になる従来のMerkle treeとは異なります。並行Merkle treeは、ルートハッシュとその導出に必要な証明とともに、直近の変更に関する安全な変更履歴を保存します。この変更履歴は、ツリー専用のアカウントにオンチェーンで保存されます。各並行Merkle treeには最大バッファサイズがあります。この値は、Merkle rootが有効なままツリーに加えられる変更の最大数を表します。これは、算出された一連の証明が更新を必要とするまで、どの程度「古い」状態でいられるかを示すものと考えてください。

そのため、同じスロット内でオンチェーンMerkle treeを更新する複数のリクエストをバリデータが受け取った場合、バリデータはツリーの変更履歴を信頼できる情報源として使用できます。これにより、最大バッファサイズを上限として、Merkle treeに並行して変更を加えられます。これはオンチェーンに保存されるデータ量を直接削減するものではありませんが、複数の更新を同時に処理可能にすることで効率を高めます。つまり、高スループット環境でも、Merkle treeが提供する「包含証明」の整合性を維持できます。ここで包含証明とは、特定のデータ要素が、まとめてハッシュ化されてMerkle rootとなったデータ集合の一部であることを証明する機能を意味します。

ステート圧縮と並行Merkle treeを巧みに組み合わせることで、Solana上に構築されるアプリケーションに非常に費用対効果の高いソリューションを提供できます。これらの技術の影響を十分に理解するには、Solanaのステートと台帳の違いを説明する必要があります。

ステートと台帳

台帳は、Solanaのジェネシスブロック以降に発生した、クライアントが署名したすべてのトランザクションを記録する履歴です。追記専用のデータ構造であるため、一度追加されたトランザクションを変更または削除することはできません。台帳に追加されるトランザクションは、バリデータによって検証されます。耐障害性を確保するため、台帳はネットワーク上の複数のノードに保存されます。ただし、将来のブロックを検証するのに古いブロックは必要ないため、ストレージ使用量を削減する目的で、バリデータが保持する台帳のコピーには新しいブロックだけが含まれる場合があります。

ステートは、Solana上のすべてのアカウントとプログラムの現在のスナップショットを表します。ステートは変更可能であり、トランザクションが処理されると変化します。ステートは、トークン残高、プログラム、アカウントを照会できる、高度に最適化されたデータベースと考えてください。

この2つを簡単に区別する方法を紹介します。AliceとBobがそれぞれ100 SOLの残高を持っているとします。AliceはBobに10 SOLを送るトランザクションを実行します。検証後、そのトランザクションはブロックに追加され、そのブロックが台帳に追記されます。これにより台帳には、AliceがBobに10 SOLを送ったという変更不能な記録が残ります。同時に、ステートではAliceとBobのアカウントがそれぞれ90 SOLと110 SOLに更新されます。

両者の主な違いは、次のようにまとめられます。

  • 台帳は変更不能かつ追記専用ですが、ステートは変更可能で常に変化します
  • 台帳はすべてのトランザクションの履歴記録ですが、ステートはすべてのアカウントとプログラムの現在の状態を反映します
  • 台帳は検証に使用されますが、ステートはトランザクションの実行とプログラムの稼働に使用されます

台帳は変更不能な履歴記録として機能し、すべてのトランザクションを検証・追跡可能にします。一方、ステートは台帳の動的なスナップショットとして機能し、転送やプログラム実行などのリアルタイム処理に応じて変化します。重要なのは、どちらもチェーン自体のコンセンサスに従うという点です。ステートと台帳はともにSolanaの基盤を形成し、分散型の信頼を維持しながら効率的な動作を可能にします。

圧縮NFTとは?

圧縮NFT(cNFT)は、ステート圧縮と並行Merkle treeを使用してストレージコストを削減します。圧縮NFTでは、各NFTを一般的なSolanaアカウントに保存する代わりに、そのメタデータを台帳へ保存します。これにより、台帳のセキュリティと不変性を継承しながら、ストレージコストを削減できます。

圧縮NFTも、非圧縮のNFTとまったく同じメタデータスキーマに従います。そのため、NFTとcNFTは同じ方法で定義されます。

NFTとcNFTの主な違いは次のとおりです。

  • 圧縮NFTは通常のNFTに変換できますが、通常のNFTを圧縮NFTに変換することはできません
  • 圧縮NFTはSolanaネイティブトークンではありません。トークンアカウント、ミントアカウント、メタデータを持ちません。ただし、安定した識別子(アセットID)はあります。解凍後も、NFTは同じ識別子を保持します。つまり、圧縮状態のNFTはネイティブトークンではありませんが、必要に応じてネイティブトークンにできます
  • 1つの並行Merkle treeアカウントに数百万個のNFTを格納できます
  • 1つのコレクションを複数のツリーアカウントにまたがって構成できます
  • NFTに対するすべての変更は、Bubblegumプログラムを通じて行われます
  • 圧縮NFTに関する情報を読み取るには、DAS API呼び出しを推奨します

興味深いことに、圧縮NFTの情報を取得するにはDAS APIを使用する必要があります。なぜでしょうか?そして、そもそもDAS APIとは何でしょうか?

DAS APIによる圧縮NFTメタデータの読み取り

cNFTのメタデータは従来のアカウントではなく台帳に保存されるため、インデクサーの支援が必要です。関連するトランザクションを再実行して圧縮NFTの現在の状態を導出することもできますが、Heliusなどのプロバイダーがこの処理を代行します。開発者は、アセット情報を取得するためのオープンソース仕様およびシステムであるDigital Asset Standard(DAS)APIを使用できます。DAS APIは、圧縮NFTと従来型の非圧縮NFTの両方をサポートします。そのため、どちらのNFTタイプにも同じエンドポイントを使用できます。

現在、Heliusは次のDAS APIメソッドをサポートしています。

  • getAsset - IDを指定して特定のアセットを取得します
  • getAssetBatch - IDを指定して複数のアセットを取得します
  • getAssetProof - IDを指定して圧縮アセットのMerkle proofを取得します
  • getAssetProofBatch - IDを指定して複数のアセットの証明を取得します
  • getAssetsByOwner - 指定したアドレスが所有するアセットの一覧を取得します
  • getAssetsByAuthority - 特定の権限を持つアセットの一覧を取得します
  • getAssetsByCreator - 指定したアドレスによって作成されたアセットの一覧を取得します
  • getAssetsByGroup - グループキーと値を指定してアセットの一覧を取得します
  • searchAssets - さまざまなパラメーターを使用してアセットを検索します
  • getSignaturesForAsset - 圧縮アセットに関連するトランザクション署名の一覧を取得します
  • ページネーション - 一度に1,000件を超えるレコードを取得するため、ページベースおよびキーセットベースのページネーションをサポートします

各メソッドの詳細については、Helius DAS APIドキュメントをご覧ください。例として、あるアドレスが所有するすべてのアセットの一覧を取得する場合、getAssetsByOwnerを使用して次のPOSTリクエストを送信できます。

コード
const url = `https://mainnet.helius-rpc.com/?api-key=`

const getAssetsByOwner = async () => {
  const response = await fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      jsonrpc: '2.0',
      id: 'my-id',
      method: 'getAssetsByOwner',
      params: {
        ownerAddress: '86xCnPeV69n6t3DnyGvkKobf9FdN2H9oiVDdaMpo2MMY',
        page: 1, // Starts at 1
        limit: 1000
      },
    }),
  });
  const { result } = await response.json();
  console.log("Assets by Owner: ", result.items);
};
getAssetsByOwner();

圧縮アセットは簡単に取得できますが、独自のアセットを作成したい場合はどうすればよいでしょうか?ミントを始める前に、これらのアセットを格納する並行Merkle treeの構築に必要なサイズとコストを算出することが重要です。

並行Merkle treeの作成サイズとコスト

サイズの計算

オンチェーンで並行Merkle treeを作成する際には、ツリーのサイズ、ツリーの作成コスト、そしてMerkle rootを有効に保ったままツリーに加えられる並行変更の数を決める3つの重要な指標があります。

  • 最大深度
  • 最大バッファサイズ
  • キャノピー深度

最大深度とは、任意のリーフからツリーのルートに到達するまでの最大ホップ数です。各リーフは他の1つのリーフとのみ接続され、ペア単位でハッシュ化されるリーフペアを形成します。ツリーが収容できるリーフノードの最大数は、次の式で計算できます。numberOfNodes = 2 ^ maxDepth。ツリーの深度は作成時に設定する必要があるため、この式を使用して、データの保存に必要な最小の最大深度を求める必要があります。たとえば、ツリーに約100個の圧縮NFTを格納する場合、2^7 = 128かつ2^6 = 64であるため、7のmaxDepthで十分です。最大深度は、オンチェーンで並行Merkle treeを構築する際の重要なコスト決定要因です。これらのコストはツリー作成時に前払いで発生し、maxDepthの値が大きくなるほど増加します。

最大バッファサイズとは、Merkle rootが有効なままツリーに加えられる変更の最大数です。並行Merkle treeでは、変更履歴バッファのサイズはmaxBufferSizeの値を使用してツリー作成時に設定されます。そのため、同じスロット内でツリーに対する複数の変更リクエストをバリデータが受信した場合、変更履歴を使用することで、ルートを有効に保ったまま最大maxBufferSize件の変更を許可できます。

新しい並行Merkle treeアカウントの作成に使用できるmaxDepthとmaxBufferSizeの組み合わせは、特定のものに限られる点が重要です。@solana/spl-account-compressionパッケージは、すべての有効な組み合わせを数値配列として格納した配列である定数ALL_DEPTH_SIZE_PAIRSをエクスポートします。最小値は3のmaxDepthと8のmaxBufferSizeで、最大値は30のmaxDepthと2048のmaxBufferSizeです。

キャノピー深度とは、アカウントに保存されるMerkle treeの一部を指します。トランザクションの制限により、ネットワーク経由で送信される証明だけでは不足するため、キャッシュされたこれらの証明で補完します。NFTを転送するときのようにリーフのデータを変更する場合、リーフの元の所有権を検証するために完全なパスを使用する必要があります。ツリーの最大深度が大きいほど、検証に必要な証明ノードも増えます。キャノピーを使用すると証明サイズを縮小でき、ツリーの検証にmaxDepthと同じサイズの証明を使用せずに済みます。

キャノピー深度は、最大深度から希望する証明サイズを引くことで計算できます。たとえば、最大深度が14で、証明サイズを4にしたい場合、キャノピー深度は10になります。つまり、更新トランザクションごとに送信する証明ノードは4つだけです。キャノピー深度も、オンチェーンで並行Merkle treeを構築する際の重要なコスト決定要因です。これらのコストはツリー作成時に前払いで発生し、canopyDepthの値が大きくなるほど増加します。canopyDepthを小さくすると初期コストは下がりますが、低いcanopyDepthはコンポーザビリティを制限する可能性があります。これは、各更新トランザクションでより大きな証明サイズが必要になり、トランザクションサイズの上限による制約を受けるためです。たとえば、canopyDepthが低いツリーを圧縮NFTに使用している場合、NFTマーケットプレイスでは、そのコレクションに対して単純な転送しかサポートできない可能性があります。一般に、コンポーザビリティを最大化するにはmaxDepth - canopyDepthを10以下にする必要があります。これは、Tensor cNFTの最大証明長に関するTensorの仕様に記載されています。

コストの計算

並行Merkle treeのサイズとコストを求める方法はいくつかあります。最も簡単なのは、Compressed NFT Calculatorを使用し、そのツリーに格納する圧縮NFTの数を入力する方法です。

このサイトでは、保存するアセット数に必要な最適なツリー深度の詳細と、コンポーザビリティに応じた各種コストオプションが表示されます。たとえば図では、1,000万個の圧縮NFTを格納する高いコンポーザビリティを持つツリーを作成しても、コストはわずか~7.67 SOLであることが示されています。1,000万個のNFTをミントするトランザクションコスト~50 SOLを含めると、総コストは約~57.67 SOLになります。

開発者は、@solana/spl-account-compressionパッケージを使用して、指定したツリーサイズに必要な領域と、その領域をオンチェーンでツリーに割り当てるコストを計算することもできます。これは次のスクリプトで実行できます。

コード
import {
    Connection,
    LAMPORTS_PER_SOL
} from "@solana/web3.js";

import {
    getConcurrentMerkleTreeAccountSize,
    ALL_DEPTH_SIZE_PAIRS
} from "@solana/spl-account-compression";

const connection = new Connection();

const calculateCosts = async (maxProofSize: number) => {
    await Promise.all(ALL_DEPTH_SIZE_PAIRS.map(async (pair) => {
        const canopy = pair.maxDepth - maxProofSize;
        const size = getConcurrentMerkleTreeAccountSize(pair.maxDepth, pair.maxBufferSize, canopy);
        const numberOfNfts = Math.pow(2, pair.maxDepth);
        const rent = (await connection.getMinimumBalanceForRentExemption(size)) / LAMPORTS_PER_SOL;

        console.log(`maxDepth: ${pair.maxDepth}, maxBufferSize: ${pair.maxBufferSize}, canopy: ${canopy}, numberOfNfts: ${numberOfNfts}, rent: ${rent}`);
    }));
}

await calculateCosts();

ここでは、必要なモジュールを@solana/web3.jsと@solana/spl-account-compressionからインポートしています。Helius APIキーを使用して確立できる、メインネットへの接続が必要です。関数calculateCostsは、maxDepth、maxBufferSize、canopy、このツリーに格納できるNFTの数、さらにSOL建てのrentのコストをコンソールへ出力します。そのため、希望する証明サイズを指定してcalculateCostsを呼び出すと、利用可能なすべてのツリー構成をコンソールで確認できます。

ログの一部に「Unable to fetch minimum balance for rent exemption」と出力される場合があります。これは、指定したmaxProofSizeを持つアカウントが大きすぎて作成できず、そのアカウントをrent exemptにするための最低残高を取得できないためです。

並行 Merkle ツリーの作成

並行 Merkle ツリーを作成する際は、次の 2 つのアカウントを作成する必要があります。

  • 並行 Merkle ツリーアカウント
  • 並行 Merkle ツリー設定アカウント

ツリーアカウントには、データ検証に使用する Merkle ツリーが保持されます。前のセクションで説明した、目的の最大深度、最大バッファサイズ、canopy 深度を使用して作成します。このアカウントは、Solana が作成および保守する Account Compression プログラムによって所有されます。圧縮 NFT の真正性を検証するために使用されます。

ツリー設定アカウントは、並行 Merkle ツリーアカウントのアドレスから導出される PDA です。ツリーの作成者やミントされた圧縮 NFT の数など、追加の設定を保存するために使用されます。

Metaplex では、関連付けられたツリー設定アカウントを持つ並行 Merkle ツリーを「Bubblegum ツリー」と呼びます。

完全なコード

コード
import {
    Connection,
    Keypair,
    PublicKey,
    Transaction,
    sendAndConfirmTransaction,
} from "@solana/web3.js";

import {
    ValidDepthSizePair,
    createAllocTreeIx,
    SPL_NOOP_PROGRAM_ID,
    SPL_ACCOUNT_COMPRESSION_PROGRAM_ID
} from "@solana/spl-account-compression";

import {
    PROGRAM_ID,
    createCreateTreeInstruction
  } from "@metaplex-foundation/mpl-bubblegum";

const createTree = async (
    connection: Connection,
    payer: Keypair,
    treeKeypair: Keypair,
    maxDepthSizePair: ValidDepthSizePair,
    canopyDepth: number = 0,
) => {
    const allocTreeInstruction = await createAllocTreeIx(
        connection,
        treeKeypair.publicKey,
        payer.publicKey,
        maxDepthSizePair,
        canopyDepth,
    );

    const [treeAuthority, ] = PublicKey.findProgramAddressSync(
        [treeKeypair.publicKey.toBuffer()],
        PROGRAM_ID,
    );

    const createTreeInstruction = createCreateTreeInstruction(
        {
            payer: payer.publicKey,
            treeCreator: payer.publicKey,
            treeAuthority,
            merkleTree: treeKeypair.publicKey,
            compressionProgram: SPL_ACCOUNT_COMPRESSION_PROGRAM_ID,
            logWrapper: SPL_NOOP_PROGRAM_ID,
        },
        {
            maxBufferSize: maxDepthSizePair.maxBufferSize,
            maxDepth: maxDepthSizePair.maxDepth,
            public: false,
        },
        PROGRAM_ID,
    );

    try {
        const transaction = new Transaction().add(allocTreeInstruction).add(createTreeInstruction);
        transaction.feePayer = payer.publicKey;

        const transactionSignature = await sendAndConfirmTransaction(
            connection,
            transaction,
            [treeKeypair, payer],
            {
                commitment: "confirmed",
                skipPreflight: true,
            },
        );

        console.log(`Successfully created a Merkle tree with txt sig: ${transactionSignature}`);
    } catch (error: any) {
        console.error(`Failed to create a Merkle tree with error: ${error}`);
    }
}

コードの詳細

これは、Solana 上で並行 Merkle ツリーを作成するためのサンプル関数です。このサンプル関数 createTree を呼び出すには、次のパラメータを渡す必要があります。

  • connection - 完全なノードの JSON RPC エンドポイントへの接続。型は Connection です
  • payer - トランザクションの支払いを行うアカウント。型は Keypair です
  • treeKeypair - ツリーのキーペアアドレス。型は Keypair です
  • maxDepthSizePair - 有効な maxDepth と maxBufferSize のペア。型は ValidDepthSizePair です
  • canopyDepth - ツリーの canopy 深度。型は number で、デフォルト値は 0 に設定されています
コード
import {
    Connection,
    Keypair,
    PublicKey,
    Transaction,
    sendAndConfirmTransaction,
} from "@solana/web3.js";

import {
    ValidDepthSizePair,
    createAllocTreeIx,
    SPL_NOOP_PROGRAM_ID,
    SPL_ACCOUNT_COMPRESSION_PROGRAM_ID
} from "@solana/spl-account-compression";

import {
    PROGRAM_ID,
    createCreateTreeInstruction
  } from "@metaplex-foundation/mpl-bubblegum";

まず、必要なモジュールとともに @solana/web3.js、@solana/spl-account-compression、@metaplex-foundation/mpl-bubblegum をインポートします。

コード
const createTree = async (
    connection: Connection,
    payer: Keypair,
    treeKeypair: Keypair,
    maxDepthSizePair: ValidDepthSizePair,
    canopyDepth: number = 0,
) => {
	// Rest of the code
}

ここでは、前述のパラメータを使用して関数 createTree を定義します。

コード
const allocTreeInstruction = await createAllocTreeIx(
		connection,
    treeKeypair.publicKey,
    payer.publicKey,
    maxDepthSizePair,
    canopyDepth,
);

createAllocTreeIx は、並行 Merkle ツリーアカウントの作成に使用するヘルパー関数です。SPL Account Compression パッケージでは、並行 Merkle ツリーアカウントの初期化にこのメソッドを使用することを推奨しています。これは、これらのアカウントが非常に大きくなる傾向があり、CPI で割り当て可能な上限を超える場合があるためです。ここでは、オンチェーンでツリーのアカウントを割り当てる命令を作成します。また、ツリーをオンチェーンに保存するために必要な領域とコストも計算されるため、後で考慮する必要はありません。

コード
const [treeAuthority, ] = PublicKey.findProgramAddressSync(
    [treeKeypair.publicKey.toBuffer()],
		PROGRAM_ID,
);

Bubblegum プログラムが所有する権限を持つツリー設定アカウントを導出する必要があります。ツリーを作成する命令 createCreateTreeInstruction では、引数として treeAuthority を渡す必要があるためです。ここでは、ツリーの公開鍵と Bubblegum プログラム ID を使用し、findProgramAddressSync メソッドで PDA を導出します。権限と bump の両方が返されるため、treeAuthority を分割代入する必要があります。この関数には不要なため、bump は省略しています。必要に応じて bump を保存するには、分割代入を [treeAuthority, bump] に変更してください。

コード
const createTreeInstruction = createCreateTreeInstruction(
		{
	    payer: payer.publicKey,
      treeCreator: payer.publicKey,
      treeAuthority,
      merkleTree: treeKeypair.publicKey,
      compressionProgram: SPL_ACCOUNT_COMPRESSION_PROGRAM_ID,
      logWrapper: SPL_NOOP_PROGRAM_ID,
    },
    {
      maxBufferSize: maxDepthSizePair.maxBufferSize,
      maxDepth: maxDepthSizePair.maxDepth,
      public: false,
    },
    PROGRAM_ID,
);

Bubblegum SDK の createCreateTreeInstruction を使用して、並行 Merkle ツリーを構築する命令を作成します。これにより、Bubblegum プログラムを所有者とするツリーがオンチェーンに作成されます。createCreateTreeInstruction には 3 つのパラメータがあります。1 つ目は、ツリーの作成者などのプロパティを設定するアカウントを含むオブジェクトです。2 つ目のオブジェクトは、最大深度と最大バッファサイズに関するものです。また、boolean 型の public パラメータも含まれます。public を true に設定すると、誰でもツリーから圧縮 NFT をミントできます。それ以外の場合、ツリーから圧縮 NFT をミントできるのは、ツリーの作成者またはツリーのデリゲートだけです。委任されたアカウントは、圧縮 NFT の転送やバーンなど、ツリー所有者に代わって操作を実行できます。なお、次のように @metaplex-foundation/mpl-bubblegum パッケージの createSetTreeDelegateInstruction を使用してツリーのデリゲートを割り当てられます。

コード
const changeTreeDelegateTransaction = createSetTreeDelegateInstruction({
		merkleTree: treeKeypair.publicKey
		newTreeDelegate: ,
		treeAuthority,
		treeCreator: treeCreator.publicKey // which in our script would be payer.publicKey
});

Bubblegum プログラムのプログラム ID も渡します。それでは、コードの残りの部分に戻ります。

コード
try {
		const transaction = new Transaction().add(allocTreeInstruction).add(createTreeInstruction);
    transaction.feePayer = payer.publicKey;

    const transactionSignature = await sendAndConfirmTransaction(
	    connection,
	    transaction,
	    [treeKeypair, payer],
	    {
		    commitment: "confirmed",
		    skipPreflight: true,
	    },
    );

    console.log(`Successfully created a Merkle tree with txt sig: ${transactionSignature}`);
} catch (error: any) {
		console.error(`Failed to create a Merkle tree with error: ${error}`);
}

作成した 2 つの命令をトランザクションに追加して送信します。treeKeypair と payer の両方がトランザクションに署名するようにします。成功したトランザクション署名はコンソールに記録されます。この処理を try-catch ブロックで囲み、何らかの理由でエラーが発生した場合は console.error を介してコンソールに記録されるようにします。

Umi を使用した並行 Merkle ツリーの作成

Bubblegum SDK、Solana の Account Compression プログラム、Solana の web3.js パッケージを使用する方法は、経験の浅い開発者には非常に複雑で、毎回のセットアップにも手間がかかります。幸い、Bubblegum SDK にはすべてを処理する createTree 操作が用意されており、Umi と組み合わせて便利に使用できます。コードは次のとおりです。

コード
import { createUmi } from "@metaplex-foundation/umi-bundle-defaults";
import { generateSigner } from '@metaplex-foundation/umi'
import { createTree } from '@metaplex-foundation/mpl-bubblegum'

const umi = createUmi();

const merkleTree = generateSigner(umi);

const builder = await createTree(umi, {
  merkleTree,
  maxDepth: 14,
  maxBufferSize: 64,
});

await builder.sendAndConfirm(umi);

Umi は、Solana プログラム用の JavaScript クライアントを構築して使用するためのモジュラーフレームワークです。特定の実装に制約されることなく、他のライブラリが利用できる一連のコアインターフェースを備えた、依存関係のないライブラリを提供します。Umi は Metaplex によって提供されており、ドキュメントはこちらで確認できます。

Umi インスタンスを使用して署名者を生成し、Merkle ツリーを作成して、構築したトランザクションを送信および確定します。デフォルトでは、ツリーの作成者は Umi のアイデンティティに設定され、public パラメータは false に設定されます。これらのパラメータはカスタマイズでき、任意のツリー作成者と、公開値として true を渡すこともできます。これは、オンチェーンの並行 Merkle ツリーをより迅速に作成する方法です。

Bubblegum は canopy サイズに依存しない点に注意してください。Solana の Account Compression プログラムが、利用可能なアカウント領域に基づいて canopy サイズを決定するためです。プログラムが使用すべき適切な canopy サイズを正確に判別できるよう、十分な領域を割り当てるだけで済みます。

Bubblegum を直接操作した cNFT のミント

コレクションの作成

従来、NFT は Metaplex 標準を使用してコレクションにまとめられます。これは圧縮 NFT と「通常の」NFT の両方に当てはまります。コレクションを作成するには、次の手順を実行します。

  • 新しいトークン「mint」を作成する
  • mint に関連付けられたトークンアカウントを作成する
  • トークンを 1 つミントする
  • コレクションのメタデータをオンチェーンのアカウントに保存する

これは状態圧縮や圧縮 NFT のトピックに直接関係せず、この記事の範囲外ですが、独自のコレクションを作成する際の参考としてスクリプトを用意しています。そのスクリプトはこちらからアクセスできます。

コレクションへの NFT のミント

新しく作成したコレクションでミントを開始するには、次のものが必要です。

  • collectionMint - コレクションの mint アドレス
  • collectionAuthority - コレクションに対する権限を持つアカウント
  • collectionMetadata - コレクションのメタデータアカウント
  • editionAccount - マスターエディションアカウントなど、追加の属性を保持するアカウント

コレクションにミントするための完全なコード

コード
import {
  Keypair,
  PublicKey,
  Connection,
  Transaction,
  sendAndConfirmTransaction,
  TransactionInstruction,
} from "@solana/web3.js";

import {
  SPL_ACCOUNT_COMPRESSION_PROGRAM_ID,
  SPL_NOOP_PROGRAM_ID,
} from "@solana/spl-account-compression";

import {
  PROGRAM_ID as BUBBLEGUM_PROGRAM_ID,
  MetadataArgs,
  createMintToCollectionV1Instruction,
} from "@metaplex-foundation/mpl-bubblegum";

import {
  PROGRAM_ID as TOKEN_METADATA_PROGRAM_ID,
} from "@metaplex-foundation/mpl-token-metadata";

export async function mintCompressedNFT(
  connection: Connection,
  payer: Keypair,
  treeAddress: PublicKey,
  collectionMint: PublicKey,
  collectionMetadata: PublicKey,
  collectionMasterEditionAccount: PublicKey,
  compressedNFTMetadata: MetadataArgs,
  receiverAddress?: PublicKey
) {
  const [treeAuthority, ] = PublicKey.findProgramAddressSync([treeAddress.toBuffer()], BUBBLEGUM_PROGRAM_ID);

  const [bubblegumSigner, ] = PublicKey.findProgramAddressSync(
    [Buffer.from("collection_cpi", "utf8")],
    BUBBLEGUM_PROGRAM_ID
  );

  const mintInstructions: TransactionInstruction[] = [];

  const metadataArgs = Object.assign(compressedNFTMetadata, {
    collection: { key: collectionMint, verified: false },
  });

  mintInstructions.push(
    createMintToCollectionV1Instruction(
      {
        payer: payer.publicKey,

        merkleTree: treeAddress,
        treeAuthority,
        treeDelegate: payer.publicKey,
        leafOwner: receiverAddress || payer.publicKey,
        leafDelegate: payer.publicKey,

        collectionAuthority: payer.publicKey,
        collectionAuthorityRecordPda: BUBBLEGUM_PROGRAM_ID,
        collectionMint: collectionMint,
        collectionMetadata: collectionMetadata,
        editionAccount: collectionMasterEditionAccount,

        compressionProgram: SPL_ACCOUNT_COMPRESSION_PROGRAM_ID,
        logWrapper: SPL_NOOP_PROGRAM_ID,
        bubblegumSigner: bubblegumSigner,
        tokenMetadataProgram: TOKEN_METADATA_PROGRAM_ID,
      },
      {
        metadataArgs,
      }
    )
  );

  try {
    const txt = new Transaction().add(...mintInstructions);

    txt.feePayer = payer.publicKey;

    const transactionSignature = await sendAndConfirmTransaction(connection, txt, [payer], {
      commitment: "confirmed",
      skipPreflight: true,
    });

    console.log(`Successfully minted a cNFT with the txt sig: ${transactionSignature}`);

  } catch (error: any) {
    console.error(`Failed to mint cNFT with error: ${error}`);
  }
}

ミント処理の詳細

コード
import {
  Keypair,
  PublicKey,
  Connection,
  Transaction,
  sendAndConfirmTransaction,
  TransactionInstruction,
} from "@solana/web3.js";

import {
  SPL_ACCOUNT_COMPRESSION_PROGRAM_ID,
  SPL_NOOP_PROGRAM_ID,
} from "@solana/spl-account-compression";

import {
  PROGRAM_ID as BUBBLEGUM_PROGRAM_ID,
  MetadataArgs,
  createMintToCollectionV1Instruction,
} from "@metaplex-foundation/mpl-bubblegum";

import {
  PROGRAM_ID as TOKEN_METADATA_PROGRAM_ID,
} from "@metaplex-foundation/mpl-token-metadata";

まず、必要なモジュールとともに @solana/web3.js、@solana/spl-account-compression、@metaplex-foundation/mpl-bubblegum、@metaplex-foundation/mpl-token-metadata をインポートします。

コード
export async function mintCompressedNFT(
  connection: Connection,
  payer: Keypair,
  treeAddress: PublicKey,
  collectionMint: PublicKey,
  collectionMetadata: PublicKey,
  collectionMasterEditionAccount: PublicKey,
  compressedNFTMetadata: MetadataArgs,
  receiverAddress?: PublicKey
) {
	// Rest of the code
}

多数のパラメータを受け取る mintCompressedNFT を定義します。

  • connection - Solana とのやり取りに使用する接続オブジェクト
  • payer - トランザクション手数料を支払うアカウント
  • treeAddress - 並行 Merkle ツリーのアカウント
  • collectionMint - コレクションの mint アドレス
  • collectionMetadata - コレクションのメタデータアカウント
  • collectionMasterEditionAccount - マスターエディションアカウント
  • compressedNFTMetadata - ミントする cNFT 固有のメタデータ
  • receiverAddress - 新しくミントされた cNFT の送信先となる、省略可能な公開鍵アドレス
コード
const [treeAuthority, ] = PublicKey.findProgramAddressSync([treeAddress.toBuffer()], BUBBLEGUM_PROGRAM_ID);

const [bubblegumSigner, ] = PublicKey.findProgramAddressSync(
    [Buffer.from("collection_cpi", "utf8")],
    BUBBLEGUM_PROGRAM_ID
  );

ここでは、必要な PDA を見つけ、その bump は無視しています。まずツリーの権限用の PDA を導出し、次に圧縮ミントの署名者として機能する PDA を導出します。collection_cpi は Bubblegum プログラムに必要なカスタムプレフィックスであるため、含める必要があります。

コード
const mintInstructions: TransactionInstruction[] = [];

mintInstructions を TransactionInstruction の空の配列に設定します。これにより、必要に応じて複数の cNFT を同時にミントできます。

コード
const metadataArgs = Object.assign(compressedNFTMetadata, {
    collection: { key: collectionMint, verified: false },
});

metadataArgs は、compressedNFTMetadata が正しくフォーマットされていることを保証します。createMintToCollectionV1Instruction を使用して NFT をコレクションにミントする場合、コレクションが自動的に検証されるにもかかわらず、トランザクションを成功させるには verified フィールドを false に設定する必要があります。

コード
mintInstructions.push(
    createMintToCollectionV1Instruction(
      {
        payer: payer.publicKey,

        merkleTree: treeAddress,
        treeAuthority,
        treeDelegate: payer.publicKey,
        leafOwner: receiverAddress || payer.publicKey,
        leafDelegate: payer.publicKey,

        collectionAuthority: payer.publicKey,
        collectionAuthorityRecordPda: BUBBLEGUM_PROGRAM_ID,
        collectionMint: collectionMint,
        collectionMetadata: collectionMetadata,
        editionAccount: collectionMasterEditionAccount,

        compressionProgram: SPL_ACCOUNT_COMPRESSION_PROGRAM_ID,
        logWrapper: SPL_NOOP_PROGRAM_ID,
        bubblegumSigner: bubblegumSigner,
        tokenMetadataProgram: TOKEN_METADATA_PROGRAM_ID,
      },
      {
        metadataArgs,
      }
  	)
);

命令に 1 件の mint を追加します。トランザクションがバイトサイズの上限内に収まる限り、同じトランザクションに複数の mint を追加できます。ここでは、createMintToCollectionV1Instruction を使用して、コレクションから圧縮 NFT をミントします。この命令は 2 つのオブジェクトを受け取ります。1 つは命令の処理に必要なアカウントを含み、もう 1 つは命令データをプログラムに提供します。これらのパラメータの大半は、前のセクションで見たものです。ミント時には任意のデリゲートアドレスを設定できますが、通常は leafOwner と同じにします。ただし、cNFT の転送時にはデリゲートが自動的に解除されます。receiverAddress が指定されていない場合は支払者が cNFT の受取人にもなるため、支払者をデリゲートとして設定します。

コード
try {
    const txt = new Transaction().add(...mintInstructions);

    txt.feePayer = payer.publicKey;

    const transactionSignature = await sendAndConfirmTransaction(connection, txt, [payer], {
      commitment: "confirmed",
      skipPreflight: true,
    });

    console.log(`Successfully minted a cNFT with the txt sig: ${transactionSignature}`);

  } catch (error: any) {
    console.error(`Failed to mint cNFT with error: ${error}`);
  }

次にトランザクションを構築し、payer を feePayer として設定して、トランザクションを送信します。トランザクションの送信と確定でエラーが発生した場合に備え、このロジックを try-catch ブロックで囲みます。エラーが発生した場合は、console.error を使用してコンソールに記録します。

Umi を使用した cNFT のミント

Bubblegum プログラムは、Umi を介して次の 2 つのミント処理を提供します。

  • コレクションに関連付けずに NFT をミントする
  • 指定したコレクションに NFT をミントする

コレクションなしでのミント

Bubblegum の MintV1 命令を使用すると、コレクションなしで Bubblegum ツリーから圧縮 NFT をミントできます。ツリーが公開されている場合は、誰でもこのツリーにミントできます。それ以外の場合、この命令を使用できるのはツリーの作成者またはデリゲートだけです。コレクションなしで圧縮 NFT をミントする方法は次のとおりです。

コード
import { none } from '@metaplex-foundation/umi'
import { mintV1 } from '@metaplex-foundation/mpl-bubblegum'

await mintV1(umi, {
  leafOwner,
  merkleTree,
  metadata: {
    name: 'My Compressed NFT',
    uri: 'https://example.com/my-cnft.json',
    sellerFeeBasisPoints: 500, // 5%
    collection: none(),
    creators: [
      { address: umi.identity.publicKey, verified: false, share: 100 },
    ],
  },
}).sendAndConfirm(umi);

このコードスニペットは、Bubblegum を使用した cNFT のミントに関する Metaplex ドキュメントから引用しています。ここでは、Umi のインスタンスを使用して cNFT をミントしています。mintV1 命令のその他のパラメータは次のとおりです。

  • leafOwner は、ミントする cNFT の所有者です
  • merkleTree は、cNFT のミント元となる並行 Merkle ツリーアカウントのアドレスです
  • metadata は、ミントする cNFT のメタデータを含むオブジェクトです。これには、cNFT の名前、URI、none に設定したコレクション、および作成者が含まれます。コレクションオブジェクトを指定することもできますが、命令でコレクション権限が要求されないため、作成者の verified フィールドを false に設定する必要があります。作成者は verified フィールドを true に設定し、残りのアカウントで作成者を署名者として指定することで、自身を検証することもできます。

mintV1 命令には、関数の入力型が MintV1InstructionAccounts & MintV1InstructionArgs であるため、多数の省略可能なフィールドも含まれます。これらの型は次のように定義されています。

コード
// Accounts
export type MintV1InstructionAccounts = {
  treeConfig?: PublicKey | Pda;
  leafOwner: PublicKey | Pda;
  leafDelegate?: PublicKey | Pda;
  merkleTree: PublicKey | Pda;
  payer?: Signer;
  treeCreatorOrDelegate?: Signer;
  logWrapper?: PublicKey | Pda;
  compressionProgram?: PublicKey | Pda;
  systemProgram?: PublicKey | Pda;
};

MintV1InstructionArgs は型の中に埋もれていて見つけにくい型ですが、要するに metadata フィールドを持つオブジェクトです。この metadata フィールドの型は MetadataArgsArgs で、次のように定義されています。

コード
export type MetadataArgsArgs = {
  /** The name of the asset */
  name: string;
  /** The symbol for the asset */
  symbol?: string;
  /** URI pointing to JSON representing the asset */
  uri: string;
  /** Royalty basis points that goes to creators in secondary sales (0-10000) */
  sellerFeeBasisPoints: number;
  primarySaleHappened?: boolean;
  isMutable?: boolean;
  /** nonce for easy calculation of editions, if present */
  editionNonce?: OptionOrNullable;
  /** Since we cannot easily change Metadata, we add the new DataV2 fields here at the end. */
  tokenStandard?: OptionOrNullable;
  /** Collection */
  collection: OptionOrNullable;
  /** Uses */
  uses?: OptionOrNullable;
  tokenProgramVersion?: TokenProgramVersionArgs;
  creators: Array;
};

mintV1 の完全な関数定義と関連するすべての型は、こちらで確認できます。少なくとも Umi のインスタンスがあれば、必要なメタデータ、リーフ所有者、並行 Merkle ツリーアカウントを渡すことで、コレクションなしの cNFT をミントできます。

コレクションを使用したミント

Bubblegum は、指定したコレクションに cNFT を直接ミントするための便利な方法として mintToCollectionV1 を提供しています。この命令の入力型は MintToCollectionV1InstructionAccounts と MintToCollectionV1InstructionArgs で、最終的には MetadataArgsArgs 型のオブジェクトになります。MintToCollectionV1InstructionAccounts の型定義は次のとおりです。

コード
// Accounts
export type MintToCollectionV1InstructionAccounts = {
  treeConfig?: PublicKey | Pda;
  leafOwner: PublicKey | Pda;
  leafDelegate?: PublicKey | Pda;
  merkleTree: PublicKey | Pda;
  payer?: Signer;
  treeCreatorOrDelegate?: Signer;
  collectionAuthority?: Signer;
  /**
   * If there is no collecton authority record PDA then
   * this must be the Bubblegum program address.
   */

  collectionAuthorityRecordPda?: PublicKey | Pda;
  collectionMint: PublicKey | Pda;
  collectionMetadata?: PublicKey | Pda;
  collectionEdition?: PublicKey | Pda;
  bubblegumSigner?: PublicKey | Pda;
  logWrapper?: PublicKey | Pda;
  compressionProgram?: PublicKey | Pda;
  tokenMetadataProgram?: PublicKey | Pda;
  systemProgram?: PublicKey | Pda;
};

主要なパラメータは、コレクションの mint、コレクション権限、コレクション権限レコードの PDA です。委任されたコレクション権限を使用する場合は、その権限がコレクション NFT の管理を許可されていることを保証するため、デリゲートレコード PDA を指定する必要があります。metadata パラメータには、address フィールドがコレクションの mint パラメータと一致し、verified フィールドが false に設定されたコレクションオブジェクトが必須です。作成者は、トランザクションに署名し、自身を残りのアカウントとして追加することで、自身を検証することもできます。

コレクションを使用して圧縮 NFT をミントする方法は次のとおりです。

コード
import { none } from '@metaplex-foundation/umi'
import { mintToCollectionV1 } from '@metaplex-foundation/mpl-bubblegum'

await mintToCollectionV1(umi, {
  leafOwner,
  merkleTree,
  collectionMint,
  metadata: {
    name: 'My Compressed NFT',
    uri: 'https://example.com/my-cnft.json',
    sellerFeeBasisPoints: 500, // 5%
    collection: { key: collectionMint, verified: false },
    creators: [
      { address: umi.identity.publicKey, verified: false, share: 100 },
    ],
  },
}).sendAndConfirm(umi);

このコードスニペットは、Bubblegum を使用した cNFT のミントに関する Metaplex ドキュメントで確認できます。ここでも Umi のインスタンスを使用して圧縮 NFT をミントします。mintV1 と同様に、leafOwner と merkleTree を渡します。ただし、今回は collectionMint も渡します。metadata フィールドには、キーが collectionMint と一致し、verified フィールドが false に設定された collection オブジェクトを渡します。Umi のアイデンティティがデフォルトのコレクション権限に設定される点に注意してください。省略可能な collectionAuthority フィールドに任意のコレクション権限を設定することで変更できます。

HeliusでcNFTをミントする

Heliusでは、圧縮NFTを手間なくミントできるMint APIを提供しています。Solanaの手数料とMerkleツリーの作成を当社が負担し、オフチェーンメタデータをArweaveにアップロードします。また、トランザクションが正常に送信され、ネットワークで承認されたことも確認するため、ご自身でポーリングする必要はありません。さらに、トランザクションからアセットIDを解析するため、すぐにDAS APIで使用できます。

HeliusがコレクションにNFTをミントするには、コレクション権限をHeliusへ委任する必要があります。クラスターに応じて、次のいずれかのアカウントへ権限を委任してください。

  • Devnet: 2LbAtCJSaHqTnP9M5QSjvAMXk79RNLusFspFN5Ew67TC
  • Mainnet: HnT5KVAywGgQDhmh6Usk4bxRg4RwKxCK4jmECyaDth5R

Helius Mint APIを使用してcNFTをミントする方法は次のとおりです。

コード
const url = `https://mainnet.helius-rpc.com/?api-key=`;

const mintCompressedNft = async () => {
    const response = await fetch(url, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
        },
        body: JSON.stringify({
            jsonrpc: '2.0',
            id: 'helius-test',
            method: 'mintCompressedNft',
            params: {
                name: 'Exodia the Forbidden One',
                symbol: 'ETFO',
                owner: 'DCQnfUH6mHA333mzkU22b4hMvyqcejUBociodq8bB5HF',
                description:
                    'Exodia the Forbidden One is a powerful, legendary creature composed of five parts: ' +
                    'the Right Leg, Left Leg, Right Arm, Left Arm, and the Head. When all five parts are assembled, Exodia becomes an unstoppable force.',
                attributes: [
                    {
                        trait_type: 'Type',
                        value: 'Legendary',
                    },
                    {
                        trait_type: 'Power',
                        value: 'Infinite',
                    },
                    {
                        trait_type: 'Element',
                        value: 'Dark',
                    },
                    {
                        trait_type: 'Rarity',
                        value: 'Mythical',
                    },
                ],
                imageUrl:
                    'https://cdna.artstation.com/p/assets/images/images/052/118/830/large/julie-almoneda-03.jpg?1658992401',
                externalUrl: 'https://www.yugioh-card.com/en/',
                sellerFeeBasisPoints: 6900,
            },
        }),
    });
    const { result } = await response.json();
    console.log('Minted asset: ', result.assetId);
};
mintCompressedNft();

このコードスニペットとリクエストスキーマの詳しい説明は、ドキュメントで確認できます。

uriフィールドを指定しない場合、Heliusがお客様に代わってJSONファイルを作成し、Arweaveへアップロードします。このファイルはv1.0 Metaplex JSON標準に準拠し、Irys(旧Bundlr)経由でアップロードされます。

cNFTの転送

圧縮NFTを転送する一般的な手順は次のとおりです。

  • インデクサーからcNFTのアセットデータを取得する
  • インデクサーからcNFTのプルーフを取得する
  • Solanaから並行Merkleツリーアカウントを取得する
  • アセットプルーフを準備する
  • 転送トランザクションを構築して送信する

UmiとMetaplexを使用すると、このプロセスを大幅に簡略化できます。ただし、このセクションでは内部で何が行われているかを説明します。以下では、web3.jsとMetaplexの両方を使用して圧縮NFTを転送する方法を解説します。

Bubblegumを直接操作して転送する

スクリプトで転送を実行する前に、圧縮NFTに関する情報を取得する必要があります。まず、DAS APIのgetAssetメソッドを使用して、圧縮NFTのメタデータを取得します。ここでは、data_hash、creator_hash、owner、delegate、leaf_idを確認します。

コード
// Example getAsset call:
const url = `https://mainnet.helius-rpc.com/?api-key=`

const getAsset = async () => {
  const response = await fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      jsonrpc: '2.0',
      id: 'my-id',
      method: 'getAsset',
      params: {
        id: ''
      },
    }),
  });
  const { result } = await response.json();
  console.log("Asset: ", result);
};
getAsset();

成功時のレスポンスの一部は次のようになります。

コード
{
  ...
  },
  "compression": {
    "eligible": true,
    "compressed": true,
    "data_hash": "string",
    "creator_hash": "string",
    "asset_hash": "string",
    "tree": "string",
    "seq": 0,
    "leaf_id": 0
  ...
	"ownership": {
    ...
    "delegate": "string",
    "ownership_model": "string",
    "owner": "string",
    ...
  }
}

必要な情報を取得したら、getAssetProofメソッドを使用して、proofとtree_id(ツリーのアドレス)を取得します。呼び出し例は次のとおりです。

コード
const url = `https://mainnet.helius-rpc.com/?api-key=`

const getAssetProof = async () => {
  const response = await fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      jsonrpc: '2.0',
      id: 'my-id',
      method: 'getAssetProof',
      params: {
        id: ''
      },
    }),
  });
  const { result } = await response.json();
  console.log("Assets Proof: ", result);
};
getAssetProof();

成功時のレスポンスは次のようになります。

コード
{
  "root": "string",
  "proof": [
    "string"
  ],
  "node_index": 0,
  "leaf": "string",
  "tree_id": "string"
}

root、proof、tree_idが揃ったので、転送スクリプトに進めます。

完全なコード

コード
import { Connection, Keypair, AccountMeta, PublicKey, Transaction, sendAndConfirmTransaction } from "@solana/web3.js";
import { createTransferInstruction, PROGRAM_ID } from "@metaplex-foundation/mpl-bubblegum";
import {
  ConcurrentMerkleTreeAccount,
  SPL_ACCOUNT_COMPRESSION_PROGRAM_ID,
  SPL_NOOP_PROGRAM_ID,
} from "@solana/spl-account-compression";

const transferCompressedNFT = async (
  connection: Connection,
  payer: Keypair,
  treeAddress: PublicKey,
  proof: string[],
  root: string,
  dataHash: string,
  creatorHash: string,
  leafId: number,
  owner: string,
  newLeafOwner: PublicKey,
  delegate: string
) => {
  const treeAccount = await ConcurrentMerkleTreeAccount.fromAccountAddress(connection, treeAddress);

  const treeAuthority = treeAccount.getAuthority();
  const canopyDepth = treeAccount.getCanopyDepth();

  const proofPath: AccountMeta[] = proof
    .map((node: string) => ({
      pubkey: new PublicKey(node),
      isSigner: false,
      isWritable: false,
    }))
    .slice(0, proof.length - (!!canopyDepth ? canopyDepth : 0));

  const leafOwner = new PublicKey(owner);
  const leafDelegate = new PublicKey(delegate);

  const transferInstruction = createTransferInstruction(
    {
      merkleTree: treeAddress,
      treeAuthority,
      leafOwner,
      leafDelegate,
      newLeafOwner,
      logWrapper: SPL_NOOP_PROGRAM_ID,
      compressionProgram: SPL_ACCOUNT_COMPRESSION_PROGRAM_ID,
      anchorRemainingAccounts: proofPath,
    },
    {
      root: [...new PublicKey(root.trim()).toBytes()],
      dataHash: [...new PublicKey(dataHash.trim()).toBytes()],
      creatorHash: [...new PublicKey(creatorHash.trim()).toBytes()],
      nonce: leafId,
      index: leafId,
    },
    PROGRAM_ID
  );

  try {
    const txt = new Transaction().add(transferInstruction);
    txt.feePayer = payer.publicKey;

    const transactionSignature = await sendAndConfirmTransaction(connection, txt, [payer], {
      commitment: "confirmed",
      skipPreflight: true,
    });

    console.log(`Successfully transfered the cNFT with txt sig: ${transactionSignature}`);
  } catch (error: any) {
    console.error(`Failed to transfer cNFT with error: ${error}`);
  }
};

コードの詳細

コード
import { Connection, Keypair, AccountMeta, PublicKey, Transaction, sendAndConfirmTransaction } from "@solana/web3.js";

import { createTransferInstruction, PROGRAM_ID } from "@metaplex-foundation/mpl-bubblegum";

import {
  ConcurrentMerkleTreeAccount,
  SPL_ACCOUNT_COMPRESSION_PROGRAM_ID,
  SPL_NOOP_PROGRAM_ID,
} from "@solana/spl-account-compression";

必要なモジュールとともに、@solana/web3.js、@metaplex-foundation/mpl-bubblegum、@solana/spl-account-compressionをインポートします。

コード
const transferCompressedNFT = async (
  connection: Connection,
  payer: Keypair,
  treeAddress: PublicKey,
  proof: string[],
  root: string,
  dataHash: string,
  creatorHash: string,
  leafId: number,
  owner: string,
  newLeafOwner: PublicKey,
  delegate: string
) => {
	// Rest of the code
}

プルーフパスの解析、転送命令の構築、実行を行うtransferCompressedNFT関数を定義します。

コード
const treeAccount = await ConcurrentMerkleTreeAccount.fromAccountAddress(connection, treeAddress);

  const treeAuthority = treeAccount.getAuthority();
  const canopyDepth = treeAccount.getCanopyDepth();

ブロックチェーンから並行Merkleツリーのアカウントを取得し、ツリー権限とcanopy depthを抽出します。これらの値は、転送命令の構築に必要です。

コード
const proofPath: AccountMeta[] = proof
    .map((node: string) => ({
      pubkey: new PublicKey(node),
      isSigner: false,
      isWritable: false,
    }))
    .slice(0, proof.length - (!!canopyDepth ? canopyDepth : 0));

簡単に言えば、プルーフアドレスのリストをAccountMeta型の有効な配列として解析します。AccountMetaは、トランザクションの定義に使用されるアカウントメタデータです。これには、アカウントの公開鍵、命令にその公開鍵と一致するトランザクション署名が必要かどうか、公開鍵を読み書き可能なアカウントとして読み込めるかどうかが含まれます。

完全なプルーフを配列の先頭から切り出し、プルーフ値がproof.length - canopyDepth個だけになるようにします。これは、オンチェーンのcanopyにすでにキャッシュされているツリー部分を除外するためです。その後、残った各プルーフ値を有効なAccountMetaとして構成します。プルーフは、転送命令内の「追加アカウント」という形式でオンチェーンに送信されるためです。

コード
const leafOwner = new PublicKey(owner);
const leafDelegate = new PublicKey(delegate);

次に、leafOwnerをownerパラメーターに、leafDelegateをdelegateパラメーターに設定します。

コード
const transferInstruction = createTransferInstruction(
    {
      merkleTree: treeAddress,
      treeAuthority,
      leafOwner,
      leafDelegate,
      newLeafOwner,
      logWrapper: SPL_NOOP_PROGRAM_ID,
      compressionProgram: SPL_ACCOUNT_COMPRESSION_PROGRAM_ID,
      anchorRemainingAccounts: proofPath,
    },
    {
      root: [...new PublicKey(root.trim()).toBytes()],
      dataHash: [...new PublicKey(dataHash.trim()).toBytes()],
      creatorHash: [...new PublicKey(creatorHash.trim()).toBytes()],
      nonce: leafId,
      index: leafId,
    },
    PROGRAM_ID
  );

Bubblegum SDKのcreateTransferInstructionヘルパー関数を使用して、transferInstructionを構築します。root、dataHash、creatorHashはDAS APIから文字列として返されるため、PublicKey型に変換してから、バイト配列へ変換する必要があります。

コード
try {
    const txt = new Transaction().add(transferInstruction);
    txt.feePayer = payer.publicKey;

    const transactionSignature = await sendAndConfirmTransaction(connection, txt, [payer], {
      commitment: "confirmed",
      skipPreflight: true,
    });

    console.log(`Successfully transfered the cNFT with txt sig: ${transactionSignature}`);
  } catch (error: any) {
    console.error(`Failed to transfer cNFT with error: ${error}`);
  }

構築した命令を新しいトランザクションに追加し、Solanaへ送信します。エラーが発生した場合は、console.errorを使用してコンソールに記録します。

並行Merkleツリーに関するエラーが発生する場合、RPCが並行Merkleツリーのプルーフについて古いデータまたは誤ったデータを提供している可能性があります。これは、キャッシュの問題によって時折発生します。解決するには、RPCから提供されたプルーフをクライアント側で検証してみてください。

コード
const merkleTreeProof: MerkleTreeProof = {
    leafIndex: leafId,
    leaf: new PublicKey(leaf).toBuffer(),
    root: new PublicKey(root).toBuffer(),
    proof: proof.map((node: string) => new PublicKey(node).toBuffer()),
};

const currentRoot = treeAccount.getCurrentRoot();
const rpcRoot = new PublicKey(root).toBuffer();

console.log(new PublicKey(currentRoot).toBase58() === new PublicKey(rpcRoot).toBase58());

また、getAssetProof DAS API呼び出しから返されるleaf値も使用する必要があります。実際のプルーフ検証はオンチェーンで実行されるため必須ではありませんが、エラー処理に役立つ場合があります。

これで、もう一度getAssetを呼び出すと、leafDelegateが空の値になり、リーフに新しい所有者が設定されたことを確認できます。

Umiを使用した転送

コード
import { getAssetWithProof, transfer } from '@metaplex-foundation/mpl-bubblegum'

const assetWithProof = await getAssetWithProof(umi, assetId)
await transfer(umi, {
  ...assetWithProof,
  leafOwner: currentLeafOwner,
  newLeafOwner: newLeafOwner.publicKey,
}).sendAndConfirm(umi);

このコードは、圧縮NFTの転送に関するMetaplexのドキュメントから引用しています。

Bubblegumは、非常に簡単に使用できるtransfer命令を提供しています。まず、Umiのインスタンスを受け取ります。次に、プルーフ情報を含むアセット、リーフ所有者、新しいリーフ所有者を格納したオブジェクトを受け取ります。必要なプルーフを含むアセットを取得するには、Bubblegumが提供するgetAssetWithProofメソッドを使用できます。リーフ所有者の代わりにリーフデリゲートも使用できます。必要なのは、転送を承認する権限を持つアカウントだけです。.sendAndConfirm()メソッドで転送を開始するトランザクションを送信し、Umiのインスタンスで承認を確認します。

まとめ

お疲れさまでした!Solanaの状態圧縮と圧縮NFTについて、包括的に解説しました。並行Merkleツリーの複雑さをひも解き、よくある誤解を解消し、Solanaの台帳を深く掘り下げました。理論だけでなく、Solanaのweb3.js、Metaplex、Heliusを活用してcNFTを取得、ミント、転送する方法も学びました。

トランザクションやストレージのコストが制約となり得る状況において、Solanaの状態圧縮は革新的です。圧縮により、セキュリティや分散性を損なうことなくコストを大幅に削減できます。これは、アーティスト、コレクター、開発者のすべてに前例のない可能性をもたらすパラダイムシフトです。

ここまで読んでくださった皆さん、ありがとうございます!この刺激的な最前線に貢献する準備は整いました。オンチェーンMMORPG向けに1,000万点のNFTコレクションをミントする、台帳の力を活用した分散型アプリを構築する、あるいは新たに得た知識をコミュニティと共有してみてください。未来を予測する最善の方法は、自ら創り出すことです。

その他のリソースと参考資料

Heliusを購読

Solana開発の最新情報や新しい記事の公開通知を受け取れます

拡大画像