🎟️

Solana 上で複数の Compressed NFT (cNFT) を一度にMintする時のTips

2024/01/17に公開

※この記事は2024年1月21日に編集しました。cNFTは比較的Mint失敗しやすいので、失敗前提で全て成功するまでリトライする方法に変更し、複数Mint対応の質を(以前の状態より)高めることができました。

gm! Epics DAOのkishi.solです。

これまで以上に大きなNFTコレクションの発行を可能にする圧縮技術 Solana Compressed NFT (cNFT)は、ゲーム上のアイテムをすべてNFTで表現できる可能性があるため、Web3ゲーム開発現場から注目されています。

Epics DAOではプライオリティパスや、NFTカード及びNFTカードパックにcNFT技術を活用しています。

https://magiceden.io/marketplace/ebtpp

たくさんのNFTを低コストで発行できるようになったため、ゲーム以外の用途にも様々な活用が期待されています。

cNFTを複数同時にMintしたい場面が出てくると思うので、今回はその時のTipsについて書いていきたいと思います。

改めてcNFTとは何かからざっくり書いていきますが、結論を知りたい方は飛ばして適宜必要な部分だけお持ち帰りいただければ幸いです。

Compressed NFT (cNFT)とは? - コストメリット

この図は1 SOL = 約20ドルの時の物なので、今はもう少しかかってしまうものの、このCompression技術のおかげで10億個規模のNFTをMintすることが可能になったことがわかります。

下記スレッドによると、9個以上のNFTを発行する場合、cNFTにコストメリットがあります。

https://twitter.com/256hax/status/1744377415726399958

cNFTの最大Mint数について

cNFTのMint方法については下記記事をご覧いただければ幸いです。

https://zenn.dev/ki4themecha2q/articles/7dcb9753783a23

cNFTコレクションの最大Mint数は、マークルツリーを作るときに決まります。

await createTree(umi, {
      merkleTree,
      maxDepth: 14,
      maxBufferSize: 64,
    })

例えばこの時、下記の表を参照して最大数は16,384個のcNFTをこのマークルツリーにMintすることができます。

ちなみにこのマークルツリーですが、NFT コレクションとは別物です。

    const mint = await mintToCollectionV1(umi, {
      leafOwner: MINT_ITEM_TO,
      merkleTree: merkleTreeAccount.publicKey,
      collectionMint: COLLECTION_MINT,
      metadata: {
        name: NFT_ITEM_NAME,
        uri: nftItemJsonUri,
        sellerFeeBasisPoints: FEE_PERCENT * 100,
        collection: { key: COLLECTION_MINT, verified: false },
        creators: CREATORS,
      },
    }).sendAndConfirm(umi)

このようにmint時に、マークルツリーと、NFTコレクションの両方が必要になります。

一つのNFTコレクションに複数のマークルツリーを作ることもできます。(管理は複雑になりそうですが)

工夫次第で色々調整することができそうです。

Burnされた場合はデータがマークルツリーから完全に消去されるため、BurnされていないNFTが(上記例の場合)16,384個このマークルツリーに存在できます。上限に達した場合、そのマークルツリーにそれ以上Mintできなくなります。

cnftを複数同時にMintしたい

本題についてですが、参考にEpics DAOの発行しているプライオリティパスcNFTの実装の一部コードを使い解説していきます。

各ポイントにはコメントで番号を振ってあり、コードの下にそれぞれ番号の箇所についての解説を書いていきます。

import { NFTStorage, Blob } from 'nft.storage'
import {
  signerIdentity,
  createSignerFromKeypair,
  publicKey,
  signAllTransactions,
  transactionBuilder,
  Transaction,
} from '@metaplex-foundation/umi'
import { base58 } from '@metaplex-foundation/umi/serializers'
import { createUmi } from '@metaplex-foundation/umi-bundle-defaults'
import {
  fetchMerkleTree,
  mintToCollectionV1,
  mplBubblegum,
  safeFetchTreeConfigFromSeeds,
} from '@metaplex-foundation/mpl-bubblegum'
import { mplTokenMetadata } from '@metaplex-foundation/mpl-token-metadata'
import { NFT_PASS } from '@common/enums/nftPass'
import { format } from 'date-fns'
import { ADDRESS } from '@common/enums/address'

type MintCNFTProps = {
  endpoint: string
  nftStorageToken: string
  fromWalletKey: number[]
  toAddressPubkey: string
  transferAmount: number
  collectionAddress: string
}
export const mintCNFT = async ({
  endpoint,
  nftStorageToken,
  fromWalletKey,
  toAddressPubkey,
  transferAmount,
  collectionAddress,
}: MintCNFTProps) => {
  const isEpicsDev =
    toAddressPubkey === ADDRESS.KAWASAKI_WALLET ||
    toAddressPubkey === ADDRESS.KISHI_WALLET
  const client = new NFTStorage({ token: nftStorageToken })
  const umi = createUmi(endpoint).use(mplTokenMetadata()).use(mplBubblegum())

  const keyPair = umi.eddsa.createKeypairFromSecretKey(
    new Uint8Array(Array.from(fromWalletKey)),
  )
  const signer = createSignerFromKeypair({ eddsa: umi.eddsa }, keyPair)
  umi.use(signerIdentity(signer))

  const toPubkey = publicKey(toAddressPubkey)

  // ① First, we complete the uploading data including JSON and set data for each cNFT. Raffle logic etc should be done before push the content to the array.
  const mintContents: any[] = []

  switch (collectionAddress) {
    case NFT_PASS.BETA_TESTER_PRIORITY_COLLECTION_ADDRESS:
      {
        // ② Getting the collection merkle Tree and the information
        const merkleTree = publicKey(NFT_PASS.BETA_TESTER_PRIORITY_MERKLE_TREE)
        const merkleTreeAccount = await fetchMerkleTree(umi, merkleTree)
        const treeConfig = await safeFetchTreeConfigFromSeeds(umi, {
          merkleTree,
        })

        if (
          !treeConfig ||
          !treeConfig.totalMintCapacity ||
          !treeConfig.numMinted
        ) {
          throw Error('bots?')
        }
        const minted = treeConfig?.numMinted
        console.log(minted)

        if (minted > 2023n) {
          throw Error('Mint Ended')
        }

        const creators = isEpicsDev
          ? [
              {
                address: publicKey(
                  'YLmoXgFkKFT6V6FumUPgBJXBJ9gAPPoYtacMfoTUPpy',
                ),
                verified: false,
                share: 40,
              },
              {
                address: publicKey(
                  'DcLN5EYHBSexnKdipnSmiFAKevcxGijURonzaWfri8Cq',
                ),
                verified: false,
                share: 30,
              },
              {
                address: publicKey(
                  'DfCHMeHfRYMBQwMje5bLSqimMWXhArYoTomX2vRr6Ty9',
                ),
                verified: false,
                share: 30,
              },
            ]
          : [
              {
                address: publicKey(
                  'YLmoXgFkKFT6V6FumUPgBJXBJ9gAPPoYtacMfoTUPpy',
                ),
                verified: false,
                share: 30,
              },
              {
                address: publicKey(
                  'DcLN5EYHBSexnKdipnSmiFAKevcxGijURonzaWfri8Cq',
                ),
                verified: false,
                share: 30,
              },
              {
                address: publicKey(
                  'DfCHMeHfRYMBQwMje5bLSqimMWXhArYoTomX2vRr6Ty9',
                ),
                verified: false,
                share: 30,
              },
              {
                address: toPubkey,
                verified: false,
                share: 10,
              },
            ]

        const collectionMint = publicKey(
          NFT_PASS.BETA_TESTER_PRIORITY_COLLECTION_ADDRESS,
        )

        for (let i = 0; i < transferAmount; i++) {
          // ③ Set the unique name for each cNFT
          const nftName = `EBTPP:CODE-${toAddressPubkey.slice(
            0,
            4,
          )}-${minted.toString()}-${i}`
          console.log(nftName)
          const feePoints = 5.5

          const nftItemJsonObject = {
            name: nftName,
            symbol: 'EBTPP',
            description:
              'Buidl to Earn. Epics is a social contribution blockchain game. We leverage gamification and token economics to solve social problems.',
            seller_fee_basis_points: feePoints * 100,
            image: NFT_PASS.BETA_TESTER_PRIORITY_IMAGE_URI,
            external_url: 'https://epics.dev/',
            attributes: [
              {
                trait_type: 'Role',
                value: 'OG',
              },
              {
                trait_type: 'Grade',
                value: 'Priority',
              },
              {
                trait_type: 'Wishing Well (Burn)',
                value: '3 Limited NFT Card Packs',
              },
              {
                trait_type: 'Snapshot (1st, March, 2024)',
                value: '3 Limited NFT Card Packs',
              },
              {
                trait_type: 'CODE',
                value: `${toAddressPubkey.slice(
                  0,
                  4,
                )}-${minted.toString()}-${i}`,
              },
              {
                trait_type: 'Rights',
                value: 'More information on Discord',
              },
              {
                trait_type: 'Minted By',
                value: toAddressPubkey,
              },
              {
                trait_type: 'Minted At',
                value: format(new Date(), 'yyyy-MM-dd HH:mm:ss'),
              },
            ],
            properties: {
              category: 'image',
              files: [
                {
                  file: NFT_PASS.BETA_TESTER_PRIORITY_IMAGE_URI,
                  type: 'image/png',
                },
              ],
              creators,
            },
          }

          const blob = new Blob([JSON.stringify(nftItemJsonObject)], {
            type: 'application/json',
          })

          const cid = await client.storeBlob(blob)
          const nftItemJsonUri = `https://${cid}.ipfs.nftstorage.link/`
          console.log('nftItemJsonUri:', nftItemJsonUri)
          mintContents.push({
            leafOwner: toPubkey,
            merkleTree: merkleTreeAccount.publicKey,
            collectionMint,
            metadata: {
              name: nftName,
              uri: nftItemJsonUri,
              sellerFeeBasisPoints: feePoints * 100,
              collection: {
                key: collectionMint,
                verified: false,
              },
              creators,
            },
          })
        }
      }
      break
    default:
      break
  }

  // ④ Build transactions and send them all.
  const mintTxs: Transaction[] = []
  const latestBlockhash = (await umi.rpc.getLatestBlockhash()).blockhash

  mintContents.forEach((mintContent) => {
    let tx = transactionBuilder().add(mintToCollectionV1(umi, mintContent))
    tx = tx.setBlockhash(latestBlockhash)
    const transaction = tx.build(umi)
    mintTxs.push(transaction)
  })

  const signedTransactions = await signAllTransactions(
    mintTxs.map((transaction, index) => ({
      transaction,
      signers: [umi.payer],
    })),
  )

  // ⑤ Record the rejected transactions
  let rejectedTransactionsIndex: { index: number; status: boolean }[] = []

  const sendPromises = signedTransactions.map((tx, index) => {
    return umi.rpc
      .sendTransaction(tx)
      .then(async (signature) => {
        try {
          // ⑥ get confirmation to make sure the transaction fulfilled or rejected
          const confirmation = await umi.rpc.confirmTransaction(signature, {
            strategy: {
              type: 'blockhash',
              ...(await umi.rpc.getLatestBlockhash()),
            },
          })
          console.log(
            `Transaction ${index + 1} confirmed with signature: ${
              base58.deserialize(signature)[0]
            }`,
          )
          return {
            status: 'fulfilled',
            signature: base58.deserialize(signature),
            confirmation,
          }
        } catch (error) {
          // ⑤
          rejectedTransactionsIndex.push({ index, status: false })
          console.error(`Transaction ${index + 1} try catch:`, error)
          return { status: 'rejected', reason: error }
        }
      })
      .catch((error) => {
        // ⑤
        rejectedTransactionsIndex.push({ index, status: false })
        console.error(`Transaction ${index + 1} then catch:`, error)
        return { status: 'rejected', reason: error }
      })
  })

  await Promise.allSettled(sendPromises)

  // ⑦ Retry the rejected transactions until suceeded  
  while (rejectedTransactionsIndex.length > 0) {
    const rejectedTransactions = rejectedTransactionsIndex.map(
      ({ index }) => mintContents[index],
    )
    const remintTxs: Transaction[] = []
    const relatestBlockhash = (await umi.rpc.getLatestBlockhash()).blockhash

    rejectedTransactions.forEach((mintContent) => {
      let tx = transactionBuilder().add(mintToCollectionV1(umi, mintContent))
      tx = tx.setBlockhash(relatestBlockhash)
      const transaction = tx.build(umi)
      remintTxs.push(transaction)
    })

    const signedTransactions = await signAllTransactions(
      remintTxs.map((transaction, index) => ({
        transaction,
        signers: [umi.payer],
      })),
    )

    const sendPromises = signedTransactions.map((tx, index) => {
      return umi.rpc
        .sendTransaction(tx)
        .then(async (signature) => {
          try {
            const confirmation = await umi.rpc.confirmTransaction(signature, {
              strategy: {
                type: 'blockhash',
                ...(await umi.rpc.getLatestBlockhash()),
              },
            })
            console.log(
              `Transaction ${index + 1} confirmed with signature: ${
                base58.deserialize(signature)[0]
              }`,
            )
            return {
              status: 'fulfilled',
              signature: base58.deserialize(signature),
              confirmation,
            }
          } catch (error) {
            console.error(`Transaction ${index + 1} try catch:`, error)
            return { status: 'rejected', reason: error }
          }
        })
        .catch((error) => {
          console.error(`Transaction ${index + 1} then catch:`, error)
          return { status: 'rejected', reason: error }
        })
    })

    await Promise.allSettled(sendPromises).then((results) => {
      results.forEach((result, index) => {
        if (
          result.status === 'fulfilled' &&
          result.value.status === 'fulfilled'
        ) {
          rejectedTransactionsIndex[index].status = true
        }
      })
      rejectedTransactionsIndex = rejectedTransactionsIndex.filter(
        (tx) => !tx.status,
      )
    })
  }
}

※ この複数トランザクション生成についてはMetaplex技術チームにサポートいただき、同社のオープンソースNFT Mint ツールであるCandy Machineのコードを参考に書かせていただきました。Metaplexチームの皆様いつもサポートいただき誠にありがとうございます。この場を借りて感謝をお伝えさせていただきます。

① まずはcNFT Mintのためのデータを揃えます。アイテムのJSONアップロードも行い、Mintのためのデータはすべて揃えるので、抽選などはこのアイテムをmintContentsに突っ込むまでに終わらせる必要があります。
これはlatestBlockhashが古くなるとMintが失敗するためです。トランザクションを構築する直前でlatestBlockhashを取得すると一番多くのMintをそこに詰め込めます。

② MintするcNFTのマークルツリーの特定とデータ取得をします。
予めNFTコレクションやマークルツリーについては作成してある前提の関数です。
もし個数制限をかけたければここでtreeConfigの中を見て色々できます。

https://developers.metaplex.com/bubblegum/create-trees

③ JSONデータをアップロードしてMintに必要な情報を揃えます。
各NFTの名前はユニークにしてください。同じ名前のアイテムはSolanaチェーン上で同一トランザクションと見なされ一つにまとめられてしまい、意図する個数のMintができなくなります。

④ 今回Mintする分のすべてのトランザクションを作っていきます。

⑤ rejectedされたトランザクションを記録しておきます

⑥ confirmationTransactionを取ることで、少し時間はかかりますがそのトランザクションの成功・失敗を保証できます。

⑦ rejectedされたトランザクションを記録してあるので、成功するまでリトライします。

こうすることで、複数個数指定のcNFTを高い確率でさばくことができます。

まとめ

個人的にハマった注意ポイントをざっくりまとめると

  • 同時にMintされるNFTの名前をユニークにすること
  • アップロード作業等時間のかかるタスクはlatestBlockhash取得前にすべて終わらせておくこと
  • cNFT Mintのトランザクションは失敗しやすいので、しっかりリトライを組む

の大きく3つです。

最初にプライオリティパスを発行する時、名前を同じにしていたため、Solanaチェーンの方で同一トランザクションと見なされてしまい、たとえ2つのMintでも、1つしか成功しない落とし穴にハマりました。
シンプルな連番でも良いので、それぞれのNFTに同時に発行している中でのユニークな名前を設定してください。

横着してアップロードのループとtx生成のループを分けずに書いてしまいましたごめんなさい。これを分けずにやるとlatestBlockhashが古くなってMint失敗しやすくなります。

cNFTのMintは時間内に終わらせることが大事かと思っていましたが、10連だろうが5連だろうが、失敗することが多いです。5連を試して1回も成功しないこともあります。
そのため、1/21の編集にて、リトライ機構を追加しました。失敗してしまったMintは同じ内容でlatestBlockhashを更新して投げ直します。
間違った内容を投げておらず、チェーンが動いていることを前提とすれば、これはいずれ成功するリトライです。

Metaplexの技術チームにも聞いてみたのですが、現状は一度に15個以上の同時Mintは避けたほうが良いとのことでした。
たしかにリトライがあれば15個以上もいけるかもしれませんが、リトライは完全にトランザクションの失敗を待って実行されるため時間が多めにかかるので、必ずリトライになるような数を一度にMintするのはロスが多いかもしれません。

以上になります!

長くなりましたがお読みいただきありがとうございました!

普段はオープンソース開発環境を応援、改善していくためのEpics DAOという取り組みをやっていっています。

誰でも好きな部分だけ参加できるようになっていますので、是非公式Discordの方をご覧いただければ幸いです!

また、このcNFTはもちろん、Solana関連技術についても議論、質問をする場がありますので、こちらもぜひご参加いただければと思います!

Epics DAO 公式 Discord:

https://discord.gg/GmHYfyRamx

それでは今後とも宜しくお願いいたします🤲

LFG🚀🔥

Discussion