Solana 压缩 NFT 技术原理:Merkle 树存储与并发铸币的工程实现

发布时间:2026/7/11 16:56:13
Solana 压缩 NFT 技术原理:Merkle 树存储与并发铸币的工程实现 Solana 压缩 NFT 技术原理Merkle 树存储与并发铸币的工程实现一、传统 NFT 的存储成本困境在 Solana 上铸造一个标准 NFTmint 账户占用约 0.012 SOL 的租金rent加上 metadata 账户的存储成本。单个 NFT 的成本可以接受——但在需要铸造 100 万个 NFT 的场景下仅账号租金就超过 12000 SOL远超出项目方预算。压缩 NFTCompressed NFTcNFT是 Solana 生态针对大规模铸造 NFT场景的解决方案。核心思路是不将每个 NFT 的元数据存储在独立链上账户中而是将所有 NFT 的哈希值组织成一棵 Merkle 树只将树的根哈希存储在链上。每个 NFT 的完整元数据和所有权证明通过 Merkle 证明在链下管理只在需要验证时才提交到链上。这套设计将单 NFT 的存储成本从约 0.012 SOL 降低到约 0.00005 SOL——成本压缩超过 200 倍。但代价是转移和验证需要通过 Merkle 证明比标准 NFT 的 SPL Token 转移更复杂。二、Merkle 树并发更新的工程架构flowchart TB subgraph 链上 CA[并发 Merkle 树br/ConcurrentMerkleTree Account] CA --|存储| RH[Merkle Root Hash] CA --|存储| CP[Change Logsbr/最新 N 条变更] CA --|存储| RP[Rightmost Proofbr/最右路径证明] BP[Bubblegum Programbr/cNFT 核心合约] end subgraph 链下 MT[Merkle Treebr/完整树结构] RPC[RPC Providerbr/提供 RPC 方法] end subgraph 用户操作 MINT[铸造 cNFT] --|构建路径证明| BP TRANSFER[转移 cNFT] --|验证路径证明| BP VERIFY[验证所有权] --|查询路径| RPC end BP --|更新根哈希| CA BP --|写入变更日志| CA RPC --|同步| MT MT --|计算证明| RPCConcurrent Merkle Tree 是 Solana 为 cNFT 专门设计的数据结构。普通 Merkle 树的插入操作是串行的——每个插入必须等前一个完成才能计算新的叶子位置。Concurrent Merkle Tree 通过维护最右路径证明Rightmost Proof允许多个插入操作并发执行。最右路径是指从根节点到当前最右侧叶子节点的路径。插入新叶子时只需要这条路径上的节点会被更新其余部分保持不变。Bubblegum 程序使用这个特性在同一区块内让多个铸币交易并发修改 Merkle 树。变更日志Change Log保存最近 N 次树变更的历史。它不是简单的替换日志而是包含每次变更前后的节点哈希值。RPC 节点通过重放变更日志来重建完整的链下 Merkle 树为用户提供所有权证明。三、铸造与转移的核心实现使用 Metaplex 的solana/spl-account-compression和metaplex-foundation/mpl-bubblegumSDK// scripts/mint-cnft.ts import { createAllocTreeIx, SPL_ACCOUNT_COMPRESSION_PROGRAM_ID, ConcurrentMerkleTreeAccount, } from solana/spl-account-compression; import { createMintToCollectionV1Instruction, MetadataArgs, PROGRAM_ID as BUBBLEGUM_PROGRAM_ID, } from metaplex-foundation/mpl-bubblegum; import { Connection, Keypair, PublicKey, Transaction, sendAndConfirmTransaction, } from solana/web3.js; const connection new Connection(process.env.SOLANA_RPC_URL!); /** * 创建并发 Merkle 树账户 * 设计决策 * - maxDepth14 支持 2^14 16384 个叶子cNFT * - maxBufferSize64 允许最多 64 个并发变更 * - 树一旦创建不能扩展必须在创建时规划好容量 */ async function createMerkleTree(payer: Keypair): PromisePublicKey { const treeKeypair Keypair.generate(); // Merkle 树配置参数 const maxDepthSizePair { maxDepth: 14, // 树深度 maxBufferSize: 64, // 并发缓冲区大小 }; // 计算所需空间 const requiredSpace ConcurrentMerkleTreeAccount.getSize( maxDepthSizePair.maxDepth, maxDepthSizePair.maxBufferSize, 64 // canopyDepth0 表示链下存储所有节点 ); const allocTreeIx createAllocTreeIx( treeKeypair.publicKey, payer.publicKey, maxDepthSizePair, requiredSpace ); const tx new Transaction().add(allocTreeIx); await sendAndConfirmTransaction(connection, tx, [payer, treeKeypair]); return treeKeypair.publicKey; } /** * 铸造压缩 NFT * 设计决策 * - 使用 MintToCollectionV1 确保 NFT 归属某个 Collection * - metadata 不存储在链上账户而是编码后作为 Merkle 树的叶子数据 */ async function mintCompressedNFT( payer: Keypair, treeAddress: PublicKey, collectionMint: PublicKey, metadata: { name: string; symbol: string; uri: string; } ): Promisestring { // 构建 NFT 元数据 const metadataArgs: MetadataArgs { name: metadata.name, symbol: metadata.symbol, uri: metadata.uri, sellerFeeBasisPoints: 500, // 5% 版税 primarySaleHappened: false, isMutable: true, editionNonce: null, tokenStandard: null, // cNFT 没有独立的 Token Standard collection: { key: collectionMint, verified: false, // 链下验证后可设为 true }, uses: null, tokenProgramVersion: Original, creators: [ { address: payer.publicKey, verified: false, share: 100, // 100% 版税归创建者 }, ], }; // 构建铸造指令 const mintIx createMintToCollectionV1Instruction( { treeAuthority: treeAddress, leafOwner: payer.publicKey, leafDelegate: payer.publicKey, payer: payer.publicKey, treeDelegate: payer.publicKey, collectionAuthority: payer.publicKey, collectionAuthorityRecordPda: BUBBLEGUM_PROGRAM_ID, collectionMint, collectionMetadata: await getMetadataPDA(collectionMint), editionAccount: await getEditionPDA(collectionMint), bubblegumSigner: getBubblegumSignerPDA(), logWrapper: SPL_NOOP_PROGRAM_ID, compressionProgram: SPL_ACCOUNT_COMPRESSION_PROGRAM_ID, tokenMetadataProgram: TOKEN_METADATA_PROGRAM_ID, systemProgram: SystemProgram.programId, }, { metadataArgs, } ); const tx new Transaction().add(mintIx); const sig await sendAndConfirmTransaction(connection, tx, [payer]); return sig; } /** * 转移压缩 NFT * 需要链下 RPC 提供 Merkle 证明然后提交到链上验证 */ async function transferCompressedNFT( payer: Keypair, assetId: PublicKey, newOwner: PublicKey ): Promisestring { // 1. 从 RPC 获取所有权证明 const proof await getAssetProof(assetId); // 2. 构建转移指令提交证明 const transferIx createTransferInstruction( { treeAuthority: proof.tree_id, leafOwner: payer.publicKey, leafDelegate: payer.publicKey, newLeafOwner: newOwner, logWrapper: SPL_NOOP_PROGRAM_ID, compressionProgram: SPL_ACCOUNT_COMPRESSION_PROGRAM_ID, anchorRemainingAccounts: proof.proof.slice(0, 14).map((p: string) ({ pubkey: new PublicKey(p), isWritable: false, isSigner: false, })), }, { root: Buffer.from(proof.root, hex), dataHash: Buffer.from(proof.data_hash, hex), creatorHash: Buffer.from(proof.creator_hash, hex), nonce: proof.nonce, index: proof.node_index, } ); const tx new Transaction().add(transferIx); return await sendAndConfirmTransaction(connection, tx, [payer]); } // ---- Helper Functions ---- import { PROGRAM_ID as TOKEN_METADATA_PROGRAM_ID } from metaplex-foundation/mpl-token-metadata; import { SPL_NOOP_PROGRAM_ID } from solana/spl-account-compression; import { SystemProgram } from solana/web3.js; function getBubblegumSignerPDA(): PublicKey { const [pda] PublicKey.findProgramAddressSync( [Buffer.from(collection_cpi)], BUBBLEGUM_PROGRAM_ID ); return pda; } async function getMetadataPDA(mint: PublicKey): PromisePublicKey { const [pda] PublicKey.findProgramAddressSync( [Buffer.from(metadata), TOKEN_METADATA_PROGRAM_ID.toBuffer(), mint.toBuffer()], TOKEN_METADATA_PROGRAM_ID ); return pda; } async function getEditionPDA(mint: PublicKey): PromisePublicKey { const [pda] PublicKey.findProgramAddressSync( [Buffer.from(metadata), TOKEN_METADATA_PROGRAM_ID.toBuffer(), mint.toBuffer(), Buffer.from(edition)], TOKEN_METADATA_PROGRAM_ID ); return pda; } /** * 从 RPC 获取 cNFT 的 Merkle 证明 * 使用 DAS API (Digital Asset Standard) 的 getAssetProof 方法 */ async function getAssetProof(assetId: PublicKey) { const response await fetch(process.env.SOLANA_RPC_URL!, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ jsonrpc: 2.0, id: cnft-proof, method: getAssetProof, params: { id: assetId.toBase58() }, }), }); const data await response.json(); return data.result; }四、并发铸币的坑与解决方案并发缓冲区溢出。maxBufferSize 设置为 64 意味着树可以接受 64 个未确认的并发变更。在高流量场景如 NFT 发售抢购超出 64 个并发交易会导致后续交易失败。解决方案是分批铸造或在预期高并发时使用更深的缓冲区但会增加账户空间成本。RPC 节点的树同步延迟。链上的 Concurrent Merkle Tree 只存储最近 N 条变更日志RPC 节点需要通过这些日志重建链下完整树。如果某个 RPC 提供商的同步落后getAssetProof 返回的证明可能引用已失效的根哈希。需要实现 RPC 健康检查如果服务端返回的根与链上最新根不一致切换备选 RPC。元数据 URI 的持久化承诺。cNFT 的 metadata 不存储在链上而是通过 URI 指向链下资源通常是 Arweave 或 IPFS。如果 URI 指向的资源失效cNFT 的元数据永久丢失。铸造时必须确保元数据已上传到不可变存储且 URI 使用内容寻址如 ar://hash而非位置寻址https://...。Collection 验证的链下性。cNFT 的 collection verified 字段通常通过链下索引标记为 true不执行链上验证。这意味着恶意 RPC 可能伪造 verified 状态。对涉及收藏品真实性的应用需要额外的链上逻辑如在 Bubblegum 层面做 collection 校验或在读取时交叉验证多个 RPC 来源。五、总结压缩 NFT 是 Solana 生态在资产存储范式上的一次有价值的工程创新。它用 Merkle 树的链上根哈希替代每个 NFT 的链上账户在大规模铸造场景下实现了 200 倍的成本优化。生产使用中需要重点关注三个方面并发树的容量规划maxDepth 和 maxBufferSize 一旦创建不可变更、RPC 提供商对 DASDigital Asset StandardAPI 的支持质量、元数据的持久化存储策略。cNFT 不是标准 NFT 的替代品而是针对特定场景大规模、低单价 NFT的专用方案——选择技术方案的前提是匹配业务需求而非追求技术新颖性。