
关于 Solana 压缩技术,你需要了解的一切
目录
- 本文讲什么?
- 常见误区
- Solana 上的压缩与传统压缩相同
- 将压缩数据存储在链下存在风险,并会导致安全漏洞
- 如果用于存储树的索引器或 RPC 提供商宕机,我就会丢失并发 Merkle 树
- 并发 Merkle 树可以处理并行更新
- 树等同于集合
- 什么是状态压缩?
- 状态与账本
- 什么是压缩 NFT?
- 使用 DAS API 读取压缩 NFT 元数据
- 创建并发 Merkle 树的大小与成本
- 计算大小
- 计算成本
- 创建并发 Merkle 树
- 完整代码
- 代码解析
- 使用 Umi 创建并发 Merkle 树
- 通过直接与 Bubblegum 交互来铸造 cNFT
- 创建藏品系列
- 为我们的藏品系列铸造 NFT
- 为藏品系列铸造 NFT 的完整代码
- 铸造流程解析
- 使用 Umi 铸造 cNFT
- 不关联藏品系列进行铸造
- 为藏品系列铸造
- 使用 Helius 铸造 cNFT
- 转移 cNFT
- 通过直接与 Bubblegum 交互进行转移
- 完整代码
- 代码详解
- 使用 Umi 转移
- 总结
- 其他资源 / 延伸阅读
本文讲什么?
如果我告诉你,现在只需不到 150 美元就能铸造 100 万个 NFT,你会相信吗?太荒谬了!根据区块链的不同,铸造这么多 NFT 可能要花费超过 100 万美元!难道不是吗?
状态压缩是一种新型原语,它利用 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 树的交易时,可以在同一个 slot 中处理它们。不过,每个 slot 的数据并不是并发生成的。我们将在下一节**什么是状态压缩?**中进一步说明这一点。
树等同于集合
并发 Merkle 树并不等同于集合。单个集合可以使用任意数量的并发 Merkle 树。需要注意的是,NFT 的分组方式可以与其存储方式相互独立。NFT 可以存在于账户中,也可以压缩到账本里,并分布在任意数量的树中,无论是一棵还是多棵。不过,为降低复杂性,建议一棵并发 Merkle 树只用于一个集合。
什么是状态压缩?
状态压缩通过为账本数据创建密码学哈希并将该哈希存储在账户中,来优化存储。这种方法利用账本固有的安全性和不可变性,同时提供一个可靠的框架来验证存储在账本中的数据。
对于构建在 Solana 上的应用来说,这是一种经济高效的解决方案。开发者现在可以使用账本存储空间,而不必使用成本更高的账户存储。因此,状态压缩不仅能保障数据完整性,还能经济高效地分配 Solana 上的资源。
Solana 状态压缩背后的秘诀是使用并发 Merkle 树。并发 Merkle 树经过优化,可以快速连续处理多笔交易,并对其证明进行快进更新。这不同于传统 Merkle 树,后者每次更新都会使树的证明失效。并发 Merkle 树会安全地保存近期更改日志,以及根哈希和推导该根哈希所需的证明。这份更改日志存储在链上专用于该树的账户中。每棵并发 Merkle 树都有最大缓冲区大小。该值表示在 Merkle 根仍然有效的情况下,树能够接受的最大更改次数。你可以将其理解为:一组已计算的证明在必须更新之前,最多可以“过时”到什么程度。
因此,当验证者在同一个 slot 内收到多个更新链上 Merkle 树的请求时,可以将树的更改日志作为事实依据。这允许对 Merkle 树执行不超过最大缓冲区大小的并发更改。虽然这不会直接减少链上存储的数据量,但允许同时处理多次更新,从而提升效率。这意味着即使在高吞吐量环境中,系统也能保持 Merkle 树提供的“包含证明”完整性。这里的包含证明,是指能够证明某个特定数据元素确实属于一组已共同哈希为 Merkle 根的数据。
状态压缩与并发 Merkle 树的巧妙结合,为构建在 Solana 上的应用提供了极具成本效益的解决方案。要充分理解这些技术的影响,我们必须先讨论 Solana 状态与账本之间的区别。
状态与账本
账本记录了自 Solana 创世区块以来,由客户端签名并发生在 Solana 上的所有交易历史。它是一种仅追加的数据结构,这意味着交易一旦加入,就无法修改或删除。验证者负责验证添加到账本中的交易。账本由整个网络中的多个节点存储,以确保容错能力。不过,为减少存储需求,验证者持有的账本副本可能只包含较新的区块,因为验证未来区块不需要较旧的区块。
状态代表 Solana 上所有账户和程序的当前快照。状态可以更改,并会在处理交易时发生变化。你可以将状态理解为一个经过高度优化的数据库,用于查询代币余额、程序和账户。
可以通过一个简单例子来区分两者:假设 Alice 有 100 SOL,Bob 也有 100 SOL。Alice 发起一笔交易,向 Bob 转账 10 SOL。交易验证后会被加入某个区块,而该区块会被追加到账本中。此时,账本中会留下不可变的记录,表明 Alice 向 Bob 发送了 10 SOL。与此同时,状态会将 Alice 和 Bob 的账户余额分别更新为 90 SOL 和 110 SOL。
两者之间的主要区别可总结如下:
- 账本不可变且只能追加,而状态可变并且不断变化
- 账本是所有交易的历史记录,而状态反映所有账户和程序的当前情况
- 账本用于验证,而状态用于执行交易和运行程序
账本是一份不可变的历史记录,确保每笔交易都可验证、可追溯;状态则是账本的动态快照,会根据转账和程序执行等实时操作进行调整。需要强调的是,两者都受区块链本身共识机制的约束。状态和账本共同构成了 Solana 的基础,使其能够高效运行,同时维持去中心化信任。
什么是压缩 NFT?
压缩 NFT(cNFT)使用状态压缩和并发 Merkle 树来降低存储成本。压缩 NFT 将元数据存储在账本中,而不是将每个 NFT 都存储在典型的 Solana 账户中。这样既能降低存储成本,又能继承账本的安全性和不可变性。
压缩 NFT 仍然遵循与未压缩 NFT 完全相同的元数据模式。因此,NFT 和 cNFT 的定义方式相同。
NFT 与 cNFT 的主要区别如下:
- 压缩 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 等提供商会代你完成这项工作。开发者可以使用 Digital Asset Standard(DAS)API,这是一套用于获取资产信息的开源规范和系统。DAS API 同时支持压缩 NFT 和传统的未压缩 NFT。因此,两种 NFT 都可以使用同一个端点。
Helius 目前支持以下 DAS API 方法:
getAsset- 根据 id 获取特定资产getAssetBatch- 根据 ID 获取多个资产getAssetProof- 根据 id 获取压缩资产的 Merkle 证明getAssetProofBatch- 根据 ID 获取多个资产证明getAssetsByOwner- 获取某个地址拥有的资产列表getAssetsByAuthority- 获取具有特定权限的资产列表getAssetsByCreator- 获取由某个地址创建的资产列表getAssetsByGroup- 根据组键和值获取资产列表searchAssets- 根据多种参数搜索资产getSignaturesForAsset- 获取与压缩资产相关的交易签名列表- 分页 - 支持基于页码和键集的分页,以便一次获取超过 1000 条记录
查看 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 树的大小和相关构建成本。
创建并发 Merkle 树的大小与成本
计算大小
在链上创建并发 Merkle 树时,有三个重要指标会决定树的大小、创建成本,以及在保持 Merkle 根有效的情况下可以对树执行的并发更改次数:
- 最大深度
- 最大缓冲区大小
- 树冠深度
最大深度是指从任意叶节点到树根所需的最大跳数。每个叶节点仅与另一个叶节点相连,组成一个叶节点对,以便执行成对哈希。你可以使用以下公式计算一棵树可容纳的最大叶节点数量:numberOfNodes = 2 ^ maxDepth。树深度必须在创建时设置,因此你需要使用该公式确定存储数据所需的最低最大深度。例如,如果你计划在一棵树中存储大约 100 个压缩 NFT,那么将 maxDepth 设为 7 即可,因为 2^7 = 128 且 2^6 = 64。在链上构建并发 Merkle 树时,最大深度是决定成本的重要因素。这些成本会在创建树时预先产生,并随 maxDepth 值的增加而上升。
最大缓冲区大小是指在 Merkle 根仍然有效的情况下,一棵树最多可以发生多少次更改。对于并发 Merkle 树,更改日志缓冲区会在创建树时根据 maxBufferSize 值确定大小并完成设置。因此,当验证者在同一个 slot 中收到多个树更改请求时,可以使用更改日志,并在根仍然有效的情况下允许最多 maxBufferSize 次更改。
需要特别注意的是,创建新的并发 Merkle 树账户时,只能使用特定数量的有效 maxDepth 与 maxBufferSize 组合。@solana/spl-account-compression 软件包导出了常量 ALL_DEPTH_SIZE_PAIRS,它是一个数字数组的数组,包含所有有效组合。最小组合为 maxDepth 等于 3、maxBufferSize 等于 8;最大组合为 maxDepth 等于 30、maxBufferSize 等于 2048。
树冠深度是指存储在账户中的 Merkle 树子集。这些缓存证明用于补充通过网络传输的证明,因为后者受交易限制约束。当你尝试更改叶节点的数据(例如转移 NFT)时,必须使用完整路径来验证叶节点的原始所有权。树的最大深度越大,验证所需的证明节点就越多。树冠可以缩小证明大小,避免使用大小为 maxDepth 的证明来验证树。
树冠深度可以用最大深度减去所需证明大小来计算。例如,如果最大深度为 14,所需证明大小为 4,那么树冠深度就是 10。这意味着每笔更新交易只需提交 4 个证明节点。在链上构建并发 Merkle 树时,树冠深度也是决定成本的重要因素。这些成本会在创建树时预先产生,并随 canopyDepth 值的增加而上升。虽然较低的 canopyDepth 可以降低前期成本,但过低的 canopyDepth 会限制可组合性。这是因为每笔更新交易都需要更大的证明,从而受到交易大小限制的约束。例如,如果你的树具有较低的 canopyDepth,并用于压缩 NFT,那么 NFT 市场可能只能支持你集合中的简单转移。通常,为实现最大可组合性,maxDepth - canopyDepth 应小于或等于 10。Tensor 对 Tensor cNFT 最大证明长度的规范中对此进行了说明。
计算成本
确定并发 Merkle 树的大小和成本有多种方法。最简单的方法是使用压缩 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 会在控制台中记录 maxDepth、maxBufferSize、canopy、该树可以存储的 NFT 数量,以及以 SOL 计价的 rent 成本。因此,当我们使用所需证明大小调用 calculateCosts 时,就能在控制台中看到所有可能的树组合。
请注意,部分日志可能会输出:无法获取免租所需的最低余额。这是因为使用你指定的 maxProofSize 创建的账户会过大,因此我们无法获取让该账户免租所需的最低余额。
创建并发 Merkle 树
创建并发 Merkle 树时,我们需要创建两个账户:
- 并发 Merkle 树账户
- 并发 Merkle 树配置账户
树账户保存用于数据验证的 Merkle 树。我们使用上一节提到的所需最大深度、最大缓冲区大小和 canopy 深度来创建它。该账户归 Account Compression 程序所有,此程序由 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(`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 端点的连接,类型为Connectionpayer- 支付交易费用的账户,类型为KeypairtreeKeypair- 树的密钥对地址,类型为KeypairmaxDepthSizePair- 有效的maxDepth和maxBufferSize组合,类型为ValidDepthSizePaircanopyDepth- 树的 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,因为我们的函数不需要它。如有需要,可将解构方式改为 [treeAuthority, bump] 以保存 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 有三个参数。第一个是包含账户的对象,用于设置树创建者等属性。第二个对象与最大深度和最大缓冲区大小有关。它还包含一个类型为 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}`);
}我们将刚刚构建的两条指令添加到一笔交易中并发送。我们确保 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 Program 会根据可用账户空间确定 canopy 大小。你只需分配足够的空间,让程序能够准确判断应使用的 canopy 大小。
通过直接与 Bubblegum 交互来铸造 cNFT
创建藏品系列
按照 Metaplex 标准,NFT 通常会被归入一个藏品系列。压缩 NFT 和“常规”NFT 都是如此。要创建藏品系列:
- 创建新的代币“mint”
- 为该 mint 创建关联代币账户
- 铸造一个代币
- 将藏品系列的元数据存储在链上账户中
虽然这与状态压缩或压缩 NFT 的主题没有直接关系,因而超出了本文范围,但我们提供了一个脚本,供你在创建自己的藏品系列时参考。你可以在此处访问该脚本。
为我们的藏品系列铸造 NFT
创建好新的藏品系列后,你需要准备以下内容才能开始铸造:
collectionMint- 藏品系列的 mint 地址collectionAuthority- 拥有藏品系列权限的账户collectionMetadata- 藏品系列的元数据账户editionAccount- 保存其他属性的账户,例如主版本账户
为藏品系列铸造 NFT 的完整代码
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,
}
)
);我们向指令中添加一次铸造。只要交易不超过字节大小限制,就可以在同一笔交易中添加多次铸造。在这里,我们使用 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(`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 提供两种铸造流程:
- 铸造不与藏品系列关联的 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);此代码片段来自 Metaplex 关于使用 Bubblegum 铸造 cNFT 的文档。在这里,我们使用 Umi 实例铸造 cNFT。mintV1 指令的其他参数如下:
leafOwner是待铸造 cNFT 的所有者merkleTree是用于铸造 cNFT 的并发 Merkle 树账户地址metadata是包含待铸造 cNFT 元数据的对象。其中包括 cNFT 的名称、URI、我们已设为 none 的藏品系列及其创建者。你可以提供藏品系列对象,但需将 creators 中的 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 提供 mintToCollectionV1,便于直接为指定藏品系列铸造 cNFT。此指令的输入类型为 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。使用受委托的藏品系列权限时,必须提供委托记录 PDA,以确保该权限可以管理藏品系列 NFT。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);此代码片段可在 Metaplex 关于使用 Bubblegum 铸造 cNFT 的文档中找到。同样,我们使用 Umi 实例铸造压缩 NFT。与 mintV1 一样,我们传入 leafOwner 和 merkleTree。但这一次,我们还会传入 collectionMint。在 metadata 字段中,我们传入一个 collection 对象,其中 key 与 collectionMint 匹配,verified 字段则设为 false。请注意,Umi 身份被设为默认藏品系列权限。你可以将可选的 collectionAuthority 字段设为自定义藏品系列权限来更改此设置。
使用 Helius 铸造 cNFT
Helius 提供了 Mint API,让你无需处理额外的麻烦即可铸造压缩 NFT。我们会承担 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: '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();你可以在我们的文档中找到此代码片段以及请求 schema 的详细说明。
请注意,如果你未填写 uri 字段,我们会代你构建 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 深度。构建转移指令时需要这些值。
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。请注意,DAS API 会以字符串形式返回 root、dataHash 和 creatorHash,因此必须先将它们转换为 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);此代码来自 Metaplex 关于转移压缩 NFT 的文档。
Bubblegum 提供了一条非常易于使用的 transfer 指令。首先,它接收一个 Umi 实例。然后,它接收一个对象,其中包含资产及其证明、叶子所有者和新的叶子所有者等信息。要获取资产及其所需证明,可以使用 Bubblegum 同样提供的 getAssetWithProof 方法。请注意,可以使用叶子委托人代替叶子所有者——只需一个有权授权转移的账户即可。通过 .sendAndConfirm() 方法,我们会发送发起转移的交易,然后使用 Umi 实例确认该交易。
总结
恭喜!我们全面探索了 Solana 上的状态压缩和压缩 NFT。我们梳理了并发 Merkle 树的复杂机制,澄清了常见误解,并深入研究了 Solana 账本。除了理论知识,我们还学习了如何利用 Solana 的 web3.js、Metaplex 和 Helius 获取、铸造和转移 cNFT!
在交易和存储成本可能成为限制的环境中,Solana 的状态压缩堪称革命性技术。压缩技术在不牺牲安全性或去中心化的前提下大幅降低成本。这是一场范式转变,为艺术家、收藏家和开发者带来了前所未有的可能性。
如果你读到了这里,感谢你,匿名朋友!你已经做好充分准备,可以为这一激动人心的前沿领域贡献力量。现在就行动吧——为你的链上 MMORPG 铸造一个包含一千万枚 NFT 的集合,构建一款利用账本能力的去中心化应用,或者直接与社区分享你刚学到的知识。预测未来的最佳方式,就是创造未来。
其他资源 / 延伸阅读
相关文章
订阅 Helius
及时了解 Solana 开发的最新动态,并在我们发布新内容时收到更新


