关于Solana上的压缩,你需要了解的一切

  • Helius
  • 发布于 2023-10-12 10:14
  • 阅读 21

这篇文章深入探讨了Solana上的状态压缩和压缩NFT(cNFTs)的概念,阐明了其原理、实现方式及应用。它详细解释了如何利用并发Merkle树优化存储,提高成本效益,同时维持安全性与去中心化。文章还提供了创建和传输压缩NFT的实用示例代码,适合希望在Solana生态上进行开发的读者。

solana compression

你需要知道的关于 Solana 压缩的所有内容

本文介绍了什么?

如果我告诉你,现在可以以不足 150 美元的价格铸造一百万个 NFT,你会相信我吗?荒谬!根据区块链的不同,铸造这么多 NFT 的成本至少要超过一百万美元!难道不是吗?

状态压缩是一种新颖的原语,利用 Merkle 树和 Solana 的账本大幅降低存储成本,同时继承 Solana 基层的安全性和去中心化。本文旨在全面深入地探讨 Solana 上的压缩技术。它涵盖了从常见误解到转移压缩 NFT 的所有内容。如果你想了解状态压缩,以及如何提取、铸造或转移压缩 NFT,那么这将是你 需要 的唯一一篇文章。

本文假设你已经阅读过我们的文章 加密工具 101 - 哈希函数和 Merkle 树解释。在阅读本文之前,阅读它是很重要的,因为我们假设你懂得 Merkle 树。本文还扩展了并发 Merkle 树,并更深入地探讨了其大小和创建方法。

本文使用 Bubblegum SDK 和 Umi 演示了创建并发 Merkle 树的各种方法,以及铸造和转移压缩 NFT。熟悉这两种工具非常重要,因为你将在各种代码库中遇到它们。包括 Bubblegum SDK 有助于学习,特别是因为其工作流程使潜在机制更透明,而 Umi 提供了更简洁的工作流程,简化了这些过程。

常见误解

在深入了解状态压缩和压缩 NFT 的复杂性之前,我们需要澄清一些事情:

Solana 上的压缩和传统压缩是一样的

这是 错误的。传统上,压缩是用于减小文件和数据的大小。其主要目标是在比原始文件更少的位数中存储或传输数据。压缩算法主要有两类:

  • 无损压缩:可以从压缩数据重构出原始数据
  • 有损压缩:去除“次要”信息以减少文件大小

压缩 NFT 并不是指经过某种无损或有损压缩算法而使其数据变小的 NFT。它与减少艺术、音乐或与 NFT 关联的元数据的质量或维度无关。这个概念在 Solana 上呈现出完全不同的形式。它是优化底层区块链账本如何存储与该 NFT 相关的信息。从账户的上下文来看,我们通过将多个账户(在本例中为 NFT)聚合到单一的 Merkle 根中,并将其存储在状态中,实现对账本的压缩。这个过程显著减少了存储成本,同时保持了可验证性。

将压缩数据存储在链外是危险的,并导致脆弱性

这是错误的 - 你可以通过对数据进行哈希并将其 Merkle 根存储在链上,安全地存储链外数据。技术上讲,压缩 NFT 不是存储在链外。数据仍然在链上,因为任何可以由账本重新推导出来的数据都被视为链上的。不同之处在于账户的状态被激励在内存中由验证者持有,而账本需要通过归档节点访问。状态压缩将两者合并,以通过账户中的状态验证账本数据,仍然保持 Solana 本身的安全性和去中心化。我们将在后面的部分详细讨论账本是什么,为什么它是安全的。

如果我用来存储我的树的索引器或 RPC 提供者宕机,则会丢失我的并发 Merkle 树

你不会丢失你的树 - 任何一个可以访问账本的人都可以通过重放树的历史来重建整棵树。

并发 Merkle 树可以处理并行更新

一个常见的误解是使用“并发”一词意味着可以同时对链上的 Merkle 树进行多个更新。虽然并发 Merkle 树可以在同一块中容纳多个叶子替换,但这些更新是由验证者依次处理的。当验证者接收到一组影响链上并发 Merkle 树的交易时,验证者可以在同一时隙内处理。但每个时隙的数据并不是同时生产的。我们将在下一个部分 什么是状态压缩? 中对此进行扩展。

一棵树与一个集合是一样的

并发 Merkle 树与集合不同。单个集合可以使用 任何 数量的并发 Merkle 树。重要的是要注意,NFT 的分组与其存储是正交的。NFT 可以在账户中,或压缩到账本中,跨越任何数量的树,一个或多个。尽管建议并发 Merkle 树只用于一个集合以减少复杂性。

什么是状态压缩?

状态压缩通过创建账本数据的加密哈希并将该哈希存储在账户中来优化存储。这种方法利用了账本固有的安全性和不可变性,同时提供了一个强大的框架来验证存储在账本中的数据。

这是对构建在 Solana 上的应用程序的经济高效解决方案。开发者现在可以使用账本存储空间而不是更昂贵的基于账户的存储。因此,状态压缩不仅确保了数据完整性,还成为了 Solana 上资源分配的经济高效解决方案。

Solana 状态压缩的秘密在于使用并发 Merkle 树。并发 Merkle 树已经优化,以快速处理多笔交易,从而可以快进其证明。这与传统 Merkle 树不同,后者在每次更新时树的证明都会失效。并发 Merkle 树存储其最近变化的安全变更日志、根哈希和推导所需的证明。此变更日志在专门用于树的账户上链存储。每棵并发 Merkle 树都有一个最大的缓冲区大小。这个值代表了可以在 Merkle 根仍然有效时进行的最 多变化次数。可以将其视为一组“过时”证明可以维持的更新,而后需要被更新。

因此,当验证者在同一时隙收到多个请求以更新链上的 Merkle 树时,验证者可以将树的变更日志作为真相源。这允许在 Merkle 树中并发多达最大缓冲区大小的更改。尽管这并没有直接减少链上存储的数据量,但它通过允许多个更新时间保持了 Merkle 树所提供的“包含证明”完整性,即使在高吞吐量环境中。在此,包含证明简单地意味着能够证明特定的数据元素确实是已哈希到 Merkle 根中的数据集的一部分。

状态压缩与并发 Merkle 树的这种巧妙组合为构建在 Solana 上的应用程序提供了极具成本效益的解决方案。为了充分理解这些技术的影响,有必要讨论 Solana 的状态与账本之间的区别。

状态与账本

账本是自 Solana 的创世块以来所有由客户端签署的交易的历史记录。它是一个仅附加的数据结构,这意味着一旦添加,交易就无法修改或删除。验证者会验证添加到账本中的交易。为确保容错,账本由网络上多个节点存储。然而,为了减少存储,验证者的账本副本可能只包含较新的块,因为较旧的块在验证未来块时不是必需的。

状态表示 Solana 上所有账户和程序的当前快照。状态是可变的,当交易被处理时会发生变化。可以将状态视为一个优化的数据库,可以用于查询代币余额、程序和账户。

以下是一种区分两者的简单方法:假设 Alice 的余额为 100 SOL,Bob 的余额也为 100 SOL。Alice 发送一笔交易给 Bob 10 SOL。一旦验证,交易便被添加到一个块中,块被附加到账本。账本现在有一个不变的记录,说明 Alice 向 Bob 发送了 10 SOL。与此同时,状态将更新 Alice 和 Bob 的账户分别为 90 和 110 SOL。

两者之间的关键区别可以总结如下:

  • 账本是不可变的且仅可附加,而状态是可变且不断变化的
  • 账本是所有交易的历史记录,而状态反映了所有账户和程序的当前状态
  • 账本用于验证,而状态用于执行交易和运行程序

在账本作为不可变的历史记录以确保每笔交易可验证并可追踪之际,状态则充当账本的动态快照,适应实时操作,例如转账和程序执行。重要的是,两者都受链本身的共识影响。状态和账本共同构成了 Solana 的支柱,使其能够高效运行,同时维护去中心化信任。

什么是压缩 NFT?

压缩 NFT (cNFTs) 使用状态压缩和并发 Merkle 树来降低存储成本。压缩 NFT 将其元数据存储在账本上,而不是在典型的 Solana 账户中存储每个 NFT。这允许降低存储成本,同时继承账本的安全性和不可变性。

压缩 NFT 的元数据架构与未压缩的 NFT 完全相同。因此,NFT 和 cNFTs 是同样定义的。

NFT 和 cNFTs 的主要区别如下:

  • 压缩 NFT 可以转换为常规 NFT,但常规 NFT 不能转换为压缩 NFT
  • 压缩 NFT 不是 原生 Solana 代币 - 它们没有代币账户、铸造账户或元数据。不过,它们有一个稳定的标识符(资产 ID)。在解压后,NFT 保持相同的标识符。因此,压缩状态下的 NFT 不属于原生代币,但如有必要可将其转变为原生代币
  • 单个并发 Merkle 树账户可以容纳数百万个 NFT
  • 单个集合可以跨多个树账户
  • 所有 NFT 修改均通过 Bubblegum 程序 进行
  • 建议使用 DAS API 调用来获取有关压缩 NFT 的任何信息

有趣的是,我们需要使用 DAS API 来获取有关压缩 NFT 的信息。这是为什么?更重要的是,那是什么?

使用 DAS API 读取压缩 NFT 元数据

我们需要索引器的帮助,因为 cNFT 的元数据存储在账本上,而不是在传统账户中。尽管你可以通过重放相关交易来推导压缩 NFT 的当前状态,但像 Helius 这样的提供者为你提供了便利。开发者可以使用 数字资产标准 (DAS) API,这是一个开源规范和系统,用于提取资产的信息。DAS API 支持压缩和传统或未压缩的 NFT。因此,你可以对这两种 NFT 类型使用相同的端点。

Helius 当前支持以下 DAS API 方法:

  • getAsset - 通过其 ID 获取特定资产
  • getAssetBatch - 通过其 ID 获取多个资产
  • getAssetProof - 通过其 ID 获取某个压缩资产的 Merkle 证明
  • getAssetProofBatch - 通过 ID 获取多个资产的证明
  • getAssetsByOwner - 获取某个地址拥有的资产列表
  • getAssetsByAuthority - 获取特定权限的资产列表
  • getAssetsByCreator - 获取某个地址创建的资产列表
  • getAssetsByGroup - 通过组密钥和值获取资产列表
  • searchAssets - 通过多种参数搜索资产
  • getSignaturesForAsset - 获取与压缩资产相关的交易签名列表
  • 分页 - 支持基于页面和键集的分页以一次提取超过 1000 条记录

请参阅 Helius DAS API 文档 以了解各个方法的更多信息。作为示例,如果你想提取某个地址所有资产的列表,可以通过以下 POST 请求使用 getAssetsByOwner

代码

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, // 从 1 开始
        limit: 1000
      },
    }),
  });
  const { result } = await response.json();
  console.log("资产按所有者: ", result.items);
};
getAssetsByOwner();

检索压缩资产很方便,但如果我们想创建自己的资产呢?在开始铸造之旅之前,计算将存储这些资产的并发 Merkle 树的大小和相关成本至关重要。

创建并发 Merkle 树的大小和成本

计算大小

在链上创建并发 Merkle 树时,有三个重要的指标将决定树的大小、创建树的成本以及在保持 Merkle 根有效的情况下可以对树进行的并发更改的数量:

  • 最大深度
  • 最大缓冲区大小
  • 树冠深度

最大深度是指从任何叶子到树根的最大跳数。每个叶子仅连接到一个其他叶子,作为配对哈希的一对叶子。你可以使用公式 numberOfNodes = 2 ^ maxDepth 计算树可以容纳的最大叶子节点数。树的深度 必须 在创建时设置,因此需要使用此公式来确定存储数据所需的最低可能最大深度。例如,如果你打算在一棵树中存储大约 100 个压缩 NFT,最大深度 7 是足够的,因为 2^7 = 1282^6 = 64。最大深度是构建链上并发 Merkle 树时一个重要的成本因素。这些成本是在树创建期间提前发生的,并随着最大深度值的增加而增加。

最大缓冲区大小是指能够在 Merkle 根仍然有效的情况下,对树发生变化的最大次数。使用 maxBufferSize 值在创建树时设置变更日志缓冲区大小。因此,当验证者在同一时隙内接收到多个对树的变更请求时,他们可以使用变更日志,允许多达 maxBufferSize 次更改且根仍然有效。

值得注意的是,在创建新的并发 Merkle 树账户时,仅存在特定数量的合法 maxDepthmaxBufferSize 组合。@solana/spl-account-compression 包 导出常量 ALL_DEPTH_SIZE_PAIRS,该常量是一个数字数组的数组,包含所有有效组合。最小值为最大深度 3 和最大缓冲区大小 8,而最大值为最大深度 30 和最大缓冲区大小 2048。

树冠深度是存储在账户中的 Merkle 树的一个子集。这些缓存证明用于补充通过链上传输的证明,因为它们受交易限制。当尝试更改叶子的数据时(例如,当转移 NFT 时),必须使用完整路径以验证原始所有权。树的最大深度越大,验证所需的证明节点就越多。树冠可以减少证明大小,并避免使用最大深度的证明大小来验证树。

树冠深度可以通过从最大深度中减去所需的证明大小来计算。因此,如果你的最大深度是 14 并且希望证明大小为 4,那么树冠深度就是 10。这意味着每个更新交易只需提交 4 个证明节点。树冠深度同样在构建链上并发 Merkle 树时是一个重要的成本因素。这些成本是在树创建期间提前发生的,并随着树冠深度值的增加而增加。较低的树冠深度导致较低的前期成本,但较低的树冠深度可能会限制可组合性。这是因为每个更新交易将需要更大的证明大小,从而对交易大小限制施加约束。例如,如果你具有低树冠深度的树用于压缩 NFT,那 NFT 市场可能只能支持对你集合的简单转移。一般而言,maxDepth - canopyDepth 应小于或等于 10 以实现最大可组合性。这在 Tensor 对 Tensor cNFTs 的最大证明长度规范 中进行了详细说明。

计算成本

确定并发 Merkle 树的大小和成本存在不同的方法。最简单的方法是使用 压缩 NFT 计算器,然后输入计划在树中存储的压缩 NFT 数量:

压缩 NFT 计算器

该网站提供了所需存储资产所需的最优树深度的详细分解,以及基于可组合性的各种成本选项。例如,图中显示创建能够存储 1000 万个压缩 NFT 的高度可组合树仅需约 7.67 SOL。考虑到铸造 1000 万个 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 会将 maxDepthmaxBufferSizecanopy、此树中可以存储的 NFTs 数量和 rent 的成本(以 SOL 计算)记录在控制台中。因此,当我们使用所需的证明大小调用 calculateCosts 时,我们可以看到控制台中的所有可能的树组合。

请注意,一些日志可能输出:无法获取最低的免租金余额。这是因为具有指定最大证明大小的账户过大,因此我们无法获取使该账户免租金的最低余额。

创建并发 Merkle 树

在创建并发 Merkle 树时,我们需要创建两个账户:

  • 一个并发 Merkle 树账户
  • 一个并发 Merkle 树配置账户

树账户持有用于数据验证的 Merkle 树。我们使用所需的最大深度、最大缓冲区大小和树冠深度创建此账户,如前面所述。此账户由 账户压缩程序 拥有,该程序由 Solana 创建和维护。它用于验证压缩 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(`成功创建 Merkle 树,交易签名:${transactionSignature}`);
    } catch (error: any) {
        console.error(`创建 Merkle 树失败,错误:${error}`);
    }
}

代码分析

这是一个示例函数,用于在 Solana 上创建并发 Merkle 树。要调用此示例函数 createTree,必须传入以下参数:

  • connection - 与完整节点 JSON RPC 端点的连接,类型为 Connection
  • payer - 将支付交易费用的账户,类型为 Keypair
  • treeKeypair - 树的密钥对地址,类型为 Keypair
  • maxDepthSizePair - 有效的 maxDepthmaxBufferSize 对,类型为 ValidDepthSizePair
  • canopyDepth - 树的树冠深度,类型为 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,
) => {
    // 剩余代码
}

在这里,我们定义了函数 createTree 及其上述参数。

代码

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

createAllocTreeIx 是一个辅助函数,用于创建并发 Merkle 树账户。SPL 账户压缩包建议使用此方法初始化并发 Merkle 树账户,因为这些账户通常比较大,可能超过通过 CPI 可以分配的限制。在这里,我们创建了在链上为树账户分配的指令。这还计算了存储树在链上所需的空间以及成本,因此我们无需担心这些问题。

代码

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

我们需要通过树配置账户推导其授权权属,该权属由 Bubblegum 程序拥有。这是 createCreateTreeInstruction 的参数之一(创建树的指令),因为我们需要将 treeAuthority 作为参数传入。在这里,我们使用树的公钥和 Bubblegum 程序 ID 通过 findProgramAddressSync 方法推导 PDA。我们需要解构 treeAuthority,因为返回的是权威和 bump。我已经省略了 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 有三个参数。第一个是一个对象,其中包含设置具有树的各种属性的账户。第二个对象则涉及最大深度和缓冲区大小。还包括一个类型为 booleanpublic 参数。将 public 设置为 true 将允许任何人从树中铸造压缩 NFT。否则,只有树的创建者或委托人可以从树中铸造压缩 NFT。委托账户可以代表树的所有者进行操作,例如转移或销毁压缩 NFT。顺便说一下,你可以使用 @metaplex-foundation/mpl-bubblegum 包中的 createSetTreeDelegateInstruction 来分配树的委托:

代码

const changeTreeDelegateTransaction = createSetTreeDelegateInstruction({
    merkleTree: treeKeypair.publicKey
    newTreeDelegate: ,
    treeAuthority,
    treeCreator: treeCreator.publicKey // 在我们的脚本中,这将是 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(`成功创建 Merkle 树,交易签名:${transactionSignature}`);
} catch (error: any) {
    console.error(`创建 Merkle 树失败,错误:${error}`);
}

我们将这两个指令添加到事务中并将其发送。我们确保 treeKeypairpayer 签署交易。成功的交易签名将记录在控制台。我们将此过程包装在 try-catch 块中,因此如果发生任何错误,都会通过 console.error 日志记录控制台。

使用 Umi 创建并发 Merkle 树

对于初学者而言,使用 Bubblegum SDK、Solana 的账户压缩程序和 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。这些参数可以自定义,允许传入自定义创建者和 public 值为 true。这是一种创建链上并发 Merkle 树的更快方法。

注意 Bubblegum 对树冠大小是无关紧要的。这是因为 Solana 的账户压缩程序将根据可用账户空间来确定树冠大小。只需分配足够的空间,以便程序可以准确识别应该使用哪个树冠大小。

直接与 Bubblegum 交互铸造 cNFTs

创建集合

NFT 通常使用 Metaplex 标准归类成集合。这对于压缩和“常规” NFT 都是准确的。创建集合的步骤如下:

  • 创建一个新的代币“铸币”
  • 为铸币创建关联的代币账户 -铸造一枚代币
  • 将集合的元数据存储在链上的账户中

虽然4589这些步骤与状态压缩或压缩 NFT 的主题没有直接关系,因此超出了本文的范围,但我们已经提供了一个脚本作为创建自己集合的参考。你可以在 这里 访问该脚本。

向我们的集合铸造 NFT

使用新创建的集合,你需要以下信息以开始铸造:

  • collectionMint - 当前集合的铸币地址
  • 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,
        }
    );
``````javascript
try {
    const txt = new Transaction().add(...mintInstructions);

    txt.feePayer = payer.publicKey;

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

    console.log(`成功铸造了一个 cNFT,交易签名: ${transactionSignature}`);

  } catch (error: any) {
    console.error(`铸造 cNFT 失败,错误: ${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
) {
    // 代码的其他部分
}

我们定义了 mintCompressedNFT,这个函数接受多个参数:

  • connection - 用于与 Solana 交互的连接对象
  • payer - 将为交易费用支付的账户
  • treeAddress - 并发 Merkle 树的账户
  • collectionMint - 集合的铸造地址
  • 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,
      }
    )
);

我们在指令中添加了一个铸造。只要交易保持在字节大小限制内,我们可以在同一出入中添加多个铸造。在这里,我们使用 createMintToCollectionV1Instruction 从我们的集合铸造我们的压缩 NFT。这个指令需要两个对象,一个包含处理指令所需的账户,另一个提供程序指令数据。大多数这些参数应该在前面的部分中看起来很熟悉。请注意,你可以在铸造时设置任何委托地址,但它通常应该与 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(`成功铸造了一个 cNFT,交易签名: ${transactionSignature}`);

  } catch (error: any) {
    console.error(`铸造 cNFT 失败,错误: ${error}`);
  }

然后我们构建交易,将 payer 设置为 feePayer,并发送交易。我们将此逻辑包围在 try-catch 块中,以防发送和确认交易时出现错误。如果发生任何错误,我们使用 console.error 记录它们。

使用 Umi 铸造 cNFTs

Bubblegum 程序通过 Umi 提供两种铸造过程:

  • 铸造未与任何集合关联的 NFT
  • 向给定集合铸造 NFT

不带集合的铸造

Bubblegum 的 MintV1 指令允许从 Bubblegum Tree 铸造没有集合的压缩 NFTs。如果树是公开的,则任何人都可以铸造到这棵树。否则,只有树的创建者或委托者才可以使用此指令。下面是如何铸造没有集合的压缩 NFT:

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

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

这个代码片段源自 Metaplex 文档,关于使用 Bubblegum 铸造 cNFTs。在这里,我们使用 Umi 的一个实例来铸造 cNFT。mintV1 指令的其他参数如下:

  • leafOwner 是要铸造的 cNFT 的拥有者
  • merkleTree 是 cNFT 将从中铸造的并发 Merkle 树账户地址
  • metadata 是一个包含要铸造的 cNFT 的元数据的对象。这包括 cNFT 的名称、URI、集合(我们调用了 none)以及其创作者。可以提供集合对象,但将创作者中的 verified 字段设置为false,因为指令中未要求集合权限。创作者也可以通过将 verified 字段设置为 true 并将自己作为剩余账户的签名者来验证自己。

mintV1 指令还包含许多可选字段,因为该函数的输入类型为 MintV1InstructionAccounts & MintV1InstructionArgs。这些类型的定义如下:

// 账户
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 是一个难以捉摸的类型,最终归结为一个包含元数据字段的对象。这个元数据字段是 MetadataArgsArgs 类型,定义如下:

export type MetadataArgsArgs = {
  /** 资产的名称 */
  name: string;
  /** 资产的符号 */
  symbol?: string;
  /** 指向表示资产的 JSON 的 URI */
  uri: string;
  /** 二级销售中创作者应获得的版权基点(0-10000) */
  sellerFeeBasisPoints: number;
  primarySaleHappened?: boolean;
  isMutable?: boolean;
  /** 如有必要,便于计算版本的 nonce */
  editionNonce?: OptionOrNullable;
  /** 由于我们无法轻易更改元数据,我们在最后添加新的 DataV2 字段。 */
  tokenStandard?: OptionOrNullable;
  /** 集合 */
  collection: OptionOrNullable;
  /** 用途 */
  uses?: OptionOrNullable;
  tokenProgramVersion?: TokenProgramVersionArgs;
  creators: Array;
};

mintV1 和所有其关联类型的完整函数定义可以在此处找到。但是,最起码通过 Umi 的一个实例,如果你传入所需的元数据、叶拥有者和并发 Merkle 树账户,你就可以毫无问题地铸造没有集合的 cNFT。

使用集合铸造

Bubblegum 提供 mintToCollectionV1 作为方便地将 cNFT 直接铸造到给定集合的方法。此指令的输入类型为 MintToCollectionV1InstructionAccountsMintToCollectionV1InstructionArgs , 最终是 MetadataArgsArgs 类型的对象。MintToCollectionV1InstructionAccounts 的类型定义如下:

// 账户
export type MintToCollectionV1InstructionAccounts = {
  treeConfig?: PublicKey | Pda;
  leafOwner: PublicKey | Pda;
  leafDelegate?: PublicKey | Pda;
  merkleTree: PublicKey | Pda;
  payer?: Signer;
  treeCreatorOrDelegate?: Signer;
  collectionAuthority?: Signer;
  /**
   * 如果没有收件人权限记录 PDA,则
   * 此字段必须是 Bubblegum 程序地址。
   */
  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;
};

关键参数是集合铸造、集合权限和集合权限记录 PDA。当使用委托集合权限时,必须提供委托记录 PDA,以确保此权限能够管理集合 NFT。元数据参数 必须 包含一个集合对象,其地址字段与集合铸造参数相匹配且已将 verified 字段设置为 false。创作者也可以通过签署交易并将自己作为剩余账户添加来验证自己。

下面是如何与集合一起铸造压缩 NFT:

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

await mintToCollectionV1(umi, {
  leafOwner,
  merkleTree,
  collectionMint,
  metadata: {
    name: '我的压缩 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);

该代码片段可以在 Metaplex 文档中找到,关于使用 Bubblegum 铸造 cNFTs。同样,我们在这里使用 Umi 的实例铸造压缩 NFT。同样传入 leafOwnermerkleTree。然而这次我们传入 collectionMint。在元数据字段中,我们传入一个 collection 对象,key 与 collectionMint 匹配,verified 字段设置为 false。请注意,Umi 身份设置为默认的集合权限。通过将可选的 collectionAuthority 字段设置为自定义集合权限,可以更改此设置。

使用 Helius 铸造 cNFTs

在 Helius,我们提供一个铸造 API,允许你没有额外的麻烦铸造压缩 NFTs。我们承载 Solana 费用、Merkle 树创建,并将你的离链元数据上传到 Arweave。我们还确保交易已成功提交并得到网络确认,因此你无需担心自己进行轮询。我们还从交易中解析资产 ID,允许你将其立即与分布式应用程序(DAS)API一起使用。

为了让 Helius 将 NFT 铸入你的集合,必须将其委托为集合权限。权限必须委托给以下账户中的一个,具体取决于你的集群:

  • 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: '被禁忌的埃克索迪亚',
                symbol: 'ETFO',
                owner: 'DCQnfUH6mHA333mzkU22b4hMvyqcejUBociodq8bB5HF',
                description:
                    '被禁忌的埃克索迪亚是一种强大的 legendary 生物,分为五部分: ' +
                    '右腿、左腿、右臂、左臂以及头。当五个部分组合在一起时,埃克索迪亚将成为一种无法阻挡的力量。',
                attributes: [\
                    {\
                        trait_type: '类型',\
                        value: '传奇',\
                    },\
                    {\
                        trait_type: '力量',\
                        value: '无限',\
                    },\
                    {\
                        trait_type: '元素',\
                        value: '黑暗',\
                    },\
                    {\
                        trait_type: '稀有度',\
                        value: '神秘',\
                    },\
                ],
                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('铸造的资产: ', result.assetId);
};
mintCompressedNft();

这个代码片段及请求模式的进一步细分可以在我们的文档中找到。

请注意,如果你未填写 uri 字段,我们将会为你构建一个 JSON 文件并上传到 Arweave。该文件将遵循v1.0 Metaplex JSON 标准,并将通过Irys(之前称为 Bundlr)上传。

转移 cNFTs

转移压缩 NFT 的一般步骤如下:

  • 从索引器获取 cNFT 的资产数据
  • 从索引器获取 cNFT 的证明
  • 从 Solana 获取并发 Merkle 树账户
  • 准备资产证明
  • 构建并发送转移交易

使用 Umi 和 Metaplex 可以大大简化此过程,但本节将展示在幕后发生的情况。下面我们将概述如何使用 web3.js 和 Metaplex 转移压缩 NFT。

直接与 Bubblegum 交互转移

在使用我们的脚本执行转移之前,我们需要获取一些有关压缩 NFT 的信息。首先,我们需要使用 DAS API 上的 getAsset 方法来检索压缩 NFT 的元数据。我们需要查找 data_hashcreator_hashownerdelegateleaf_id

// 示例 getAsset 调用:
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("资产: ", result);
};
getAsset();

这是成功响应的一部分将是什么样子的:

{
  ...
  },
  "compression": {
    "eligible": true,
    "compressed": true,
    "data_hash": "字符串",
    "creator_hash": "字符串",
    "asset_hash": "字符串",
    "tree": "字符串",
    "seq": 0,
    "leaf_id": 0
  ...
    "ownership": {
    ...
    "delegate": "字符串",
    "ownership_model": "字符串",
    "owner": "字符串",
    ...
  }
}

获取必要信息后,我们需要使用 getAssetProof 方法来检索 prooftree_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("资产证明: ", result);
};
getAssetProof();

这是成功响应的样子:

{
  "root": "字符串",
  "proof": [\
    "字符串"\
  ],
  "node_index": 0,
  "leaf": "字符串",
  "tree_id": "字符串"
}

现在,通过根、证明和树 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(`成功转移了 cNFT,交易签名: ${transactionSignature}`);
  } catch (error: any) {
    console.error(`转移 cNFT 失败,错误: ${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
) => {
    // 代码的其他部分
}

我们定义了 transferCompressedNFT 函数,在其中我们将解析证明路径,构建转账指令并执行它。

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

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

我们从区块链获取并发 Merkle 树账户,并提取树的权限和叶架深度。这些值都是构建转移指令所需的。

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 数量的证明值。我们这样做是为了移除树中已经在链上缓存的部分树。然后我们将每个剩余的证明值结构化为有效的 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。请注意,rootdataHashcreatorHash 是作为字符串从 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(`成功转移了 cNFT,交易签名: ${transactionSignature}`);
  } catch (error: any) {
    console.error(`转移 cNFT 失败,错误: ${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);

这段代码来自于 Metaplex 文档,关于转移压缩 NFTs。

Bubblegum 提供了一个 transfer 指令,使用起来非常简单。首先,它接受 Umi 的实例。然后,它接受一个包含资产和其证明的信息对象、叶拥有者和新的叶拥有者。为了获取带有相应证明的资产,我们可以使用 Bubblegum 提供的 getAssetWithProof 方法。请注意,叶委托者可以替代叶拥有者使用 - 只需要一个授权转移的账户即可。使用 .sendAndConfirm() 方法,我们将发送启动转移的交易,并随后使用 Umi 确认。

结论

恭喜你!我们以非常全面的方式探讨了 Solana 上的状态压缩和压缩 NFTs。我们遍历了并发 Merkle 树的复杂性,阐明了常见误解,并深入分析了 Solana 的账本。抛开理论,我们学会了如何使用 Solana 的 web3.js、Metaplex 和 Helius 的力量来获取、铸造和转移 cNFTs!

Solana 的状态压缩在交易和存储成本可能较高的环境中具有革命性意义。压缩在不妨碍安全性或去中心化的情况下大幅降低成本。这一范式转变为艺术家、收藏者和开发者打开了前所未有的机遇。

如果你读到了这里,感谢你!你具备了为这一令人兴奋的前沿做出贡献的良好装备。去吧 - 为你的链上 MMORPG 铸造一千万的 NFT 集合,构建一个利用账本力量的去中心化应用,或者简单地与社区分享你新获得的知识。预见未来的最佳方式是创造未来。

额外资源 / 进一步阅读

  • 原文链接: helius.dev/blog/all-you-...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Helius
Helius
https://www.helius.dev/