MỚI: Helius mua lại Light Protocol
nén trên Solana
Blog/Kiến thức nền tảng

Mọi điều bạn cần biết về nén trên Solana

Developer Experience Engineer0xIchigo trên X0xIchigo trên LinkedIn0xIchigo trên GitHub
Đọc trong 32 phút

Bài viết này nói về điều gì?

Bạn có tin nếu tôi nói rằng ngay lúc này bạn có thể mint một triệu NFT với chi phí chưa đến 150 USD không? Thật vô lý! Tùy thuộc vào blockchain, việc mint số lượng NFT lớn như vậy có thể tốn hơn một triệu đô la! Chẳng phải vậy sao?

Nén trạng thái là một primitive mới tận dụng cây Merkle và sổ cái của Solana để giảm mạnh chi phí lưu trữ, đồng thời kế thừa tính bảo mật và phi tập trung của lớp cơ sở Solana. Bài viết này phân tích toàn diện và chuyên sâu về nén trên Solana. Nội dung bao quát mọi thứ, từ những hiểu lầm phổ biến cho đến cách chuyển NFT nén. Nếu bạn muốn tìm hiểu về nén trạng thái cũng như cách truy xuất, mint hoặc chuyển NFT nén, đây là bài viết duy nhất bạn cần để bắt đầu.

Bài viết này giả định rằng bạn đã đọc bài viết Nhập môn công cụ mật mã học - Giải thích hàm băm và cây Merkle của chúng tôi. Bạn cần đọc bài viết đó trước vì nội dung dưới đây giả định bạn đã hiểu về cây Merkle. Bài viết này cũng mở rộng về cây Merkle đồng thời và đi sâu hơn vào cách xác định kích thước cũng như cách tạo chúng.

Bài viết này sử dụng cả Bubblegum SDK và Umi để minh họa các phương pháp tạo cây Merkle đồng thời, cũng như mint và chuyển NFT nén. Việc quen thuộc với cả hai công cụ rất hữu ích vì bạn có thể gặp từng công cụ trong nhiều cơ sở mã khác nhau. Bubblegum SDK được đưa vào nhằm hỗ trợ việc học, vì quy trình của nó thể hiện rõ hơn các cơ chế nền tảng, trong khi Umi cung cấp một quy trình ngắn gọn hơn để tinh giản các tác vụ này.

Những hiểu lầm phổ biến

Trước khi đi sâu vào nén trạng thái và những điểm phức tạp của NFT nén, chúng ta cần làm rõ một vài điều:

Nén trên Solana giống với kỹ thuật nén truyền thống

Điều này không đúng. Theo cách truyền thống, kỹ thuật nén được dùng để giảm kích thước tệp và dữ liệu. Mục tiêu chính là lưu trữ hoặc truyền dữ liệu bằng số bit ít hơn so với tệp ban đầu. Có hai loại thuật toán nén chính:

  • Nén không mất dữ liệu, trong đó dữ liệu gốc có thể được khôi phục từ dữ liệu đã nén
  • Nén mất dữ liệu, trong đó thông tin “ít quan trọng hơn” bị loại bỏ để giảm kích thước tệp

NFT nén không phải là NFT đã trải qua một thuật toán nén không mất dữ liệu hoặc mất dữ liệu nào đó để thu nhỏ dữ liệu. Khái niệm này cũng không liên quan đến việc giảm chất lượng hoặc kích thước của tác phẩm nghệ thuật, âm nhạc hay siêu dữ liệu gắn với NFT. Trong bối cảnh Solana, nó mang một ý nghĩa hoàn toàn khác. Cụ thể, nó tối ưu hóa cách sổ cái blockchain nền tảng lưu trữ thông tin liên quan đến NFT đó. Xét trong bối cảnh account, chúng ta nén account vào sổ cái bằng cách tổng hợp nhiều account — trong trường hợp này là các NFT — thành một Merkle root duy nhất được lưu trong trạng thái. Quy trình này giảm đáng kể chi phí lưu trữ trong khi vẫn duy trì khả năng xác minh.

Lưu dữ liệu nén ngoài chuỗi rất rủi ro và dẫn đến lỗ hổng

Điều này không đúng — bạn có thể lưu dữ liệu ngoài chuỗi một cách an toàn bằng cách băm dữ liệu và lưu Merkle root của dữ liệu đó trên chuỗi. Về mặt kỹ thuật, NFT nén không được lưu ngoài chuỗi. Dữ liệu vẫn ở trên chuỗi vì mọi thứ có thể được tái tạo từ sổ cái đều được xem là dữ liệu trên chuỗi. Điểm khác biệt là các account trong trạng thái được khuyến khích lưu trong bộ nhớ của validator, còn sổ cái phải được truy cập qua các node lưu trữ. Nén trạng thái kết hợp cả hai để cho phép xác minh dữ liệu sổ cái thông qua trạng thái trong một account, đồng thời vẫn duy trì tính bảo mật và phi tập trung của chính Solana. Chúng ta sẽ tìm hiểu sổ cái là gì và vì sao nó an toàn trong một phần khác.

Tôi có thể mất cây Merkle đồng thời nếu trình lập chỉ mục hoặc nhà cung cấp RPC dùng để lưu cây ngừng hoạt động

Bạn sẽ không mất cây — bất kỳ ai có quyền truy cập vào sổ cái đều có thể tái tạo toàn bộ cây bằng cách phát lại lịch sử của cây.

Cây Merkle đồng thời có thể xử lý các bản cập nhật song song

Một hiểu lầm phổ biến là từ “đồng thời” ngụ ý rằng nhiều bản cập nhật đối với cây Merkle trên chuỗi có thể diễn ra song song. Mặc dù cây Merkle đồng thời có thể tiếp nhận nhiều lần thay thế lá trong cùng một block, các bản cập nhật này vẫn được validator xử lý tuần tự. Khi validator nhận được một lô transaction tác động đến cây Merkle đồng thời trên chuỗi, validator có thể xử lý chúng trong cùng một slot. Tuy nhiên, dữ liệu trong mỗi slot không được tạo đồng thời. Chúng tôi sẽ giải thích thêm trong phần Nén trạng thái là gì? tiếp theo.

Cây cũng giống như bộ sưu tập

Cây Merkle đồng thời không giống với bộ sưu tập. Một bộ sưu tập duy nhất có thể sử dụng bất kỳ số lượng cây Merkle đồng thời nào. Điều quan trọng cần lưu ý là cách nhóm NFT có thể độc lập với cách lưu trữ chúng. NFT có thể nằm trong các account hoặc được nén vào sổ cái, trên một hay nhiều cây với số lượng bất kỳ. Tuy vậy, để giảm độ phức tạp, mỗi cây Merkle đồng thời chỉ nên được dùng cho một bộ sưu tập.

Nén trạng thái là gì?

Nén trạng thái tối ưu hóa việc lưu trữ bằng cách tạo một hàm băm mật mã từ dữ liệu sổ cái và lưu hàm băm đó trong một account. Phương pháp này tận dụng tính bảo mật và bất biến vốn có của sổ cái, đồng thời cung cấp một khuôn khổ vững chắc để xác minh dữ liệu được lưu trong sổ cái.

Đây là giải pháp tiết kiệm chi phí cho các ứng dụng được xây dựng trên Solana. Giờ đây, nhà phát triển có thể sử dụng không gian lưu trữ của sổ cái thay vì kiểu lưu trữ dựa trên account vốn đắt hơn. Vì vậy, nén trạng thái không chỉ đảm bảo tính toàn vẹn của dữ liệu mà còn là giải pháp phân bổ tài nguyên tiết kiệm chi phí trên Solana.

Bí quyết đằng sau khả năng nén trạng thái của Solana là việc sử dụng cây Merkle đồng thời. Cây Merkle đồng thời được tối ưu hóa để xử lý nhiều transaction liên tiếp trong thời gian ngắn, nhờ đó các bằng chứng của cây có thể được cập nhật nhanh. Điều này khác với cây Merkle truyền thống, nơi bằng chứng của cây mất hiệu lực sau mỗi lần cập nhật. Cây Merkle đồng thời lưu một nhật ký thay đổi an toàn về những thay đổi gần nhất, cùng với root hash và bằng chứng cần thiết để suy ra root hash đó. Nhật ký thay đổi này được lưu trên chuỗi trong một account dành riêng cho cây. Mỗi cây Merkle đồng thời có một kích thước bộ đệm tối đa. Giá trị này biểu thị số lượng thay đổi lớn nhất có thể thực hiện trên cây trong khi Merkle root vẫn còn hiệu lực. Có thể hiểu đây là mức độ “lỗi thời” tối đa của một tập hợp bằng chứng đã tính toán trước khi chúng cần được cập nhật.

Do đó, khi validator nhận được nhiều yêu cầu cập nhật một cây Merkle trên chuỗi trong cùng một slot, validator có thể sử dụng nhật ký thay đổi của cây làm nguồn dữ liệu chuẩn. Cách này cho phép số thay đổi đồng thời đối với cây Merkle đạt đến kích thước bộ đệm tối đa. Mặc dù không trực tiếp giảm lượng dữ liệu lưu trên chuỗi, nó nâng cao hiệu quả bằng cách cho phép xử lý đồng thời nhiều bản cập nhật. Điều này có nghĩa là hệ thống vẫn có thể duy trì tính toàn vẹn của “bằng chứng bao hàm” do cây Merkle cung cấp, ngay cả trong môi trường có thông lượng cao. Ở đây, bằng chứng bao hàm đơn giản là khả năng chứng minh một phần tử dữ liệu cụ thể thực sự thuộc một tập hợp dữ liệu đã được băm chung thành một Merkle root.

Sự kết hợp thông minh giữa nén trạng thái và cây Merkle đồng thời mang lại một giải pháp cực kỳ tiết kiệm chi phí cho các ứng dụng xây dựng trên Solana. Để hiểu đầy đủ tác động của những công nghệ này, chúng ta cần thảo luận về sự khác biệt giữa trạng thái và sổ cái của Solana.

Trạng thái và sổ cái

Sổ cái là bản ghi lịch sử của mọi transaction do máy khách ký đã diễn ra trên Solana kể từ genesis block. Đây là cấu trúc dữ liệu chỉ cho phép nối thêm, nghĩa là không thể sửa đổi hoặc xóa transaction sau khi nó được thêm vào. Validator xác thực các transaction được thêm vào sổ cái. Sổ cái được lưu trên nhiều node trong toàn mạng lưới để đảm bảo khả năng chịu lỗi. Tuy nhiên, bản sao sổ cái của một validator có thể chỉ chứa các block mới hơn nhằm giảm dung lượng lưu trữ, vì không cần các block cũ để xác thực block trong tương lai.

Trạng thái đại diện cho ảnh chụp hiện tại của tất cả account và program trên Solana. Trạng thái có thể thay đổi và được cập nhật khi transaction được xử lý. Có thể xem trạng thái như một cơ sở dữ liệu được tối ưu hóa cao, cho phép truy vấn số dư token, program và account.

Đây là một cách đơn giản để phân biệt hai khái niệm: Giả sử Alice có số dư 100 SOL và Bob cũng có số dư 100 SOL. Alice gửi một transaction để chuyển cho Bob 10 SOL. Sau khi được xác minh, transaction được thêm vào một block và block đó được nối vào sổ cái. Lúc này, sổ cái có một bản ghi bất biến cho biết Alice đã gửi 10 SOL cho Bob. Đồng thời, trạng thái sẽ cập nhật account của Alice và Bob lần lượt thành 90 và 110 SOL.

Có thể tóm tắt những khác biệt chính giữa hai khái niệm như sau:

  • Sổ cái là bất biến và chỉ cho phép nối thêm, trong khi trạng thái có thể thay đổi và được cập nhật liên tục
  • Sổ cái là bản ghi lịch sử của mọi transaction, trong khi trạng thái phản ánh tình trạng hiện tại của tất cả account và program
  • Sổ cái được dùng để xác minh, trong khi trạng thái được dùng để thực thi transaction và chạy program

Trong khi sổ cái đóng vai trò là bản ghi lịch sử bất biến để đảm bảo mọi transaction đều có thể được xác minh và truy vết, trạng thái hoạt động như một ảnh chụp động của sổ cái, điều chỉnh theo các hoạt động theo thời gian thực như chuyển tài sản và thực thi program. Quan trọng là cả hai đều chịu sự đồng thuận của chính chuỗi. Trạng thái và sổ cái cùng tạo nên xương sống của Solana, giúp mạng lưới vận hành hiệu quả trong khi vẫn duy trì niềm tin phi tập trung.

NFT nén là gì?

NFT nén (cNFT) sử dụng nén trạng thái và cây Merkle đồng thời để giảm chi phí lưu trữ. NFT nén lưu siêu dữ liệu trên sổ cái thay vì lưu từng NFT trong một account Solana thông thường. Cách này giúp giảm chi phí lưu trữ trong khi vẫn kế thừa tính bảo mật và bất biến của sổ cái.

NFT nén vẫn tuân theo chính xác cùng một lược đồ siêu dữ liệu như các NFT không nén tương ứng. Vì vậy, NFT và cNFT được định nghĩa theo cùng một cách.

Những khác biệt chính giữa NFT và cNFT như sau:

  • NFT nén có thể được chuyển đổi thành NFT thông thường, nhưng NFT thông thường không thể được chuyển đổi thành NFT nén
  • NFT nén không phải là token Solana gốc — chúng không có token account, mint account hoặc siêu dữ liệu. Tuy nhiên, chúng có một mã định danh ổn định (asset ID). Sau khi giải nén, NFT vẫn giữ nguyên mã định danh đó. Vì vậy, NFT ở trạng thái nén không phải là token gốc, nhưng có thể trở thành token gốc nếu cần
  • Một account cây Merkle đồng thời có thể chứa hàng triệu NFT
  • Một bộ sưu tập có thể trải rộng trên nhiều account cây
  • Mọi thao tác sửa đổi NFT đều được thực hiện thông qua program Bubblegum
  • Nên dùng lệnh gọi DAS API để đọc bất kỳ thông tin nào về NFT nén

Điều thú vị là chúng ta cần sử dụng DAS API để lấy thông tin về NFT nén. Tại sao lại như vậy? Và quan trọng hơn, DAS API là gì?

Đọc siêu dữ liệu NFT nén bằng DAS API

Chúng ta cần sự hỗ trợ của các trình lập chỉ mục vì siêu dữ liệu của cNFT được lưu trên sổ cái thay vì trong một account truyền thống. Mặc dù có thể suy ra trạng thái hiện tại của NFT nén bằng cách phát lại các transaction liên quan, các nhà cung cấp như Helius thực hiện việc này thay bạn để thuận tiện hơn. Nhà phát triển có thể sử dụng Digital Asset Standard (DAS) API, một đặc tả và hệ thống nguồn mở để truy xuất thông tin của tài sản. DAS API hỗ trợ cả NFT nén và NFT truyền thống, tức NFT không nén. Vì vậy, bạn có thể sử dụng cùng một endpoint cho cả hai loại NFT.

Helius hiện hỗ trợ các phương thức DAS API sau:

  • getAsset - lấy một tài sản cụ thể theo id
  • getAssetBatch - lấy nhiều tài sản theo ID
  • getAssetProof - lấy bằng chứng Merkle cho một tài sản nén theo id
  • getAssetProofBatch - lấy bằng chứng của nhiều tài sản theo ID
  • getAssetsByOwner - lấy danh sách tài sản thuộc sở hữu của một địa chỉ
  • getAssetsByAuthority - lấy danh sách tài sản có một authority cụ thể
  • getAssetsByCreator - lấy danh sách tài sản do một địa chỉ tạo ra
  • getAssetsByGroup - lấy danh sách tài sản theo khóa và giá trị của nhóm
  • searchAssets - tìm kiếm tài sản theo nhiều tham số
  • getSignaturesForAsset - lấy danh sách chữ ký transaction liên quan đến một tài sản nén
  • Phân trang - hỗ trợ phân trang dựa trên trang và keyset để truy xuất hơn 1.000 bản ghi mỗi lần

Hãy xem tài liệu Helius DAS API để tìm hiểu thêm về từng phương thức. Ví dụ, nếu muốn truy xuất danh sách tất cả tài sản thuộc sở hữu của một địa chỉ, bạn có thể gửi yêu cầu POST sau bằng getAssetsByOwner:

Mã
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();

Việc truy xuất tài sản nén rất thuận tiện, nhưng nếu muốn tự tạo tài sản thì sao? Trước khi bắt đầu quá trình mint, điều quan trọng là phải tính toán kích thước và chi phí liên quan đến việc xây dựng cây Merkle đồng thời dùng để lưu trữ các tài sản này.

Xác định kích thước và chi phí tạo cây Merkle đồng thời

Tính kích thước

Khi tạo cây Merkle đồng thời trên chuỗi, có ba chỉ số quan trọng quyết định kích thước cây, chi phí tạo cây và số lượng thay đổi đồng thời có thể thực hiện trên cây trong khi vẫn giữ Merkle root hợp lệ:

  • Độ sâu tối đa
  • Kích thước bộ đệm tối đa
  • Độ sâu canopy

Độ sâu tối đa là số bước nhảy lớn nhất để đi từ một lá bất kỳ đến root của cây. Mỗi lá chỉ được kết nối với một lá khác, tạo thành một cặp lá để băm theo cặp. Bạn có thể tính số lượng node lá tối đa mà cây có thể chứa bằng công thức: numberOfNodes = 2 ^ maxDepth. Độ sâu cây phải được đặt khi khởi tạo, vì vậy bạn cần dùng công thức này để xác định độ sâu tối đa thấp nhất có thể lưu dữ liệu. Ví dụ, nếu muốn lưu khoảng 100 NFT nén trong một cây, maxDepth bằng 7 là đủ vì 2^7 = 128 và 2^6 = 64. Độ sâu tối đa là yếu tố quan trọng quyết định chi phí khi xây dựng cây Merkle đồng thời trên chuỗi. Các chi phí này phát sinh trước tại thời điểm tạo cây và tăng theo giá trị maxDepth.

Kích thước bộ đệm tối đa là số lượng thay đổi lớn nhất có thể xảy ra trên cây trong khi Merkle root vẫn còn hiệu lực. Bộ đệm nhật ký thay đổi của cây Merkle đồng thời được xác định kích thước và thiết lập khi tạo cây bằng giá trị maxBufferSize. Vì vậy, khi validator nhận được nhiều yêu cầu thay đổi một cây trong cùng một slot, họ có thể sử dụng nhật ký thay đổi và cho phép tối đa maxBufferSize thay đổi trong khi root vẫn còn hiệu lực.

Điều quan trọng cần lưu ý là chỉ có một số lượng cụ thể các cặp maxDepth và maxBufferSize hợp lệ để tạo account cây Merkle đồng thời mới. Gói @solana/spl-account-compression xuất hằng số ALL_DEPTH_SIZE_PAIRS, là một mảng gồm các mảng số chứa tất cả tổ hợp hợp lệ. Giá trị tối thiểu là maxDepth bằng 3 và maxBufferSize bằng 8, trong khi giá trị tối đa là maxDepth bằng 30 và maxBufferSize bằng 2048.

Độ sâu canopy chỉ một tập con của cây Merkle được lưu trong một account. Các bằng chứng được lưu vào bộ nhớ đệm này dùng để bổ sung cho những bằng chứng được truyền qua mạng, vì chúng chịu giới hạn của transaction. Khi muốn thay đổi dữ liệu của một lá, chẳng hạn như khi chuyển NFT, phải dùng toàn bộ đường dẫn để xác minh quyền sở hữu ban đầu của lá đó. Độ sâu tối đa của cây càng lớn thì càng cần nhiều node bằng chứng để xác minh. Canopy cho phép giảm kích thước bằng chứng và tránh phải dùng bằng chứng có kích thước maxDepth để xác minh cây.

Có thể tính độ sâu canopy bằng cách lấy độ sâu tối đa trừ đi kích thước bằng chứng mong muốn. Vì vậy, nếu độ sâu tối đa là 14 và bạn muốn kích thước bằng chứng là 4, độ sâu canopy sẽ là 10. Điều này có nghĩa là bạn chỉ phải gửi 4 node bằng chứng cho mỗi transaction cập nhật. Độ sâu canopy cũng là yếu tố quan trọng quyết định chi phí khi xây dựng cây Merkle đồng thời trên chuỗi. Các chi phí này phát sinh trước tại thời điểm tạo cây và tăng theo giá trị canopyDepth. Mặc dù canopyDepth thấp hơn giúp giảm chi phí ban đầu, giá trị canopyDepth thấp có thể hạn chế khả năng kết hợp. Nguyên nhân là mỗi transaction cập nhật sẽ cần kích thước bằng chứng lớn hơn, từ đó chịu nhiều ràng buộc hơn bởi giới hạn kích thước transaction. Ví dụ, nếu cây có canopyDepth thấp được dùng cho NFT nén, một sàn giao dịch NFT có thể chỉ hỗ trợ các thao tác chuyển đơn giản cho bộ sưu tập của bạn. Nhìn chung, maxDepth - canopyDepth nên nhỏ hơn hoặc bằng 10 để đạt khả năng kết hợp tối đa. Điều này được nêu trong đặc tả của Tensor về độ dài bằng chứng tối đa cho cNFT trên Tensor.

Tính chi phí

Có nhiều phương pháp để xác định kích thước và chi phí của cây Merkle đồng thời. Cách đơn giản nhất là sử dụng Công cụ tính NFT nén và nhập số lượng NFT nén dự kiến lưu trong cây đó:

Trang web cung cấp thông tin chi tiết về độ sâu cây tối ưu cần thiết cho số lượng tài sản muốn lưu, cùng nhiều phương án chi phí dựa trên khả năng kết hợp. Ví dụ, hình minh họa cho thấy việc tạo một cây có khả năng kết hợp cao để lưu 10 triệu NFT nén chỉ tốn ~7,67 SOL. Nếu tính thêm chi phí transaction khoảng ~50 SOL để mint 10 triệu NFT, tổng chi phí sẽ vào khoảng ~57,67 SOL.

Nhà phát triển cũng có thể sử dụng gói @solana/spl-account-compression để tính không gian cần thiết cho một kích thước cây nhất định và chi phí phân bổ không gian cần thiết cho cây trên chuỗi. Có thể thực hiện việc này bằng tập lệnh sau:

Mã
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();

Ở đây, chúng ta nhập các module cần thiết từ @solana/web3.js và @solana/spl-account-compression. Chúng ta cần kết nối đến mainnet, có thể thiết lập bằng khóa Helius API. Hàm calculateCosts ghi vào console các giá trị maxDepth, maxBufferSize, canopy, số lượng NFT có thể lưu trong cây này, cũng như chi phí rent tính bằng SOL. Vì vậy, khi gọi calculateCosts với kích thước bằng chứng mong muốn, chúng ta có thể xem tất cả tổ hợp cây khả thi trong console.

Lưu ý rằng một số log có thể hiển thị: Không thể truy xuất số dư tối thiểu để được miễn phí thuê. Nguyên nhân là account với maxProofSize được chỉ định sẽ quá lớn để tạo, do đó chúng ta không thể truy xuất số dư tối thiểu cần thiết để account được miễn phí thuê.

Tạo cây Merkle đồng thời

Khi tạo cây Merkle đồng thời, chúng ta cần tạo hai tài khoản:

  • Một tài khoản cây Merkle đồng thời
  • Một tài khoản cấu hình cây Merkle đồng thời

Tài khoản cây lưu giữ cây Merkle dùng để xác minh dữ liệu. Chúng ta tạo tài khoản này bằng độ sâu tối đa, kích thước bộ đệm tối đa và độ sâu canopy mong muốn như đã đề cập trong phần trước. Tài khoản này thuộc sở hữu của chương trình Account Compression, do Solana tạo và duy trì. Tài khoản được dùng để xác minh tính xác thực của các NFT nén.

Tài khoản cấu hình cây là một PDA được dẫn xuất từ địa chỉ của tài khoản cây Merkle đồng thời. Tài khoản này dùng để lưu các cấu hình bổ sung, chẳng hạn như người tạo cây và số lượng NFT nén đã được đúc.

Metaplex gọi các cây Merkle đồng thời có tài khoản cấu hình cây liên kết là “Bubblegum tree”.

Mã đầy đủ

Mã
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}`);
    }
}

Phân tích mã

Đây là một hàm mẫu để tạo cây Merkle đồng thời trên Solana. Để gọi hàm mẫu createTree này, bạn phải truyền các tham số sau:

  • connection - kết nối đến endpoint JSON RPC của một full node, thuộc kiểu Connection
  • payer - tài khoản sẽ thanh toán cho giao dịch, thuộc kiểu Keypair
  • treeKeypair - địa chỉ cặp khóa của cây, thuộc kiểu Keypair
  • maxDepthSizePair - cặp maxDepth và maxBufferSize hợp lệ, thuộc kiểu ValidDepthSizePair
  • canopyDepth - độ sâu canopy của cây, thuộc kiểu number và được đặt thành giá trị mặc định 0
Mã
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";

Trước tiên, chúng ta import @solana/web3.js, @solana/spl-account-compression và @metaplex-foundation/mpl-bubblegum cùng các mô-đun cần thiết.

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

Tại đây, chúng ta định nghĩa hàm createTree với các tham số nêu trên.

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

createAllocTreeIx là hàm trợ giúp dùng để tạo tài khoản cây Merkle đồng thời. Gói SPL Account Compression khuyên dùng phương thức này để khởi tạo tài khoản cây Merkle đồng thời vì các tài khoản này thường khá lớn và có thể vượt quá giới hạn cấp phát qua CPI. Tại đây, chúng ta tạo instruction để cấp phát tài khoản của cây trên chuỗi. Thao tác này cũng tính toán không gian cần thiết để lưu cây trên chuỗi cũng như chi phí, nên chúng ta không cần xử lý những vấn đề này về sau.

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

Chúng ta cần dẫn xuất tài khoản cấu hình cây với authority thuộc sở hữu của chương trình Bubblegum. Đây là yêu cầu đối với createCreateTreeInstruction, instruction tạo cây, vì chúng ta cần truyền treeAuthority làm đối số. Tại đây, chúng ta dẫn xuất PDA bằng phương thức findProgramAddressSync, sử dụng public key của cây và ID chương trình Bubblegum. Chúng ta cần phân rã treeAuthority vì cả authority và bump đều được trả về. Tôi đã bỏ qua bump vì hàm của chúng ta không cần đến nó. Nếu cần lưu bump, hãy đổi phép phân rã thành [treeAuthority, bump].

Mã
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,
);

Chúng ta dùng createCreateTreeInstruction từ Bubblegum SDK để xây dựng instruction tạo cây Merkle đồng thời. Thao tác này tạo cây trên chuỗi với chương trình Bubblegum làm chủ sở hữu. createCreateTreeInstruction có ba tham số. Tham số đầu tiên là một đối tượng chứa các tài khoản để thiết lập các thuộc tính như người tạo cây. Đối tượng thứ hai liên quan đến độ sâu tối đa và kích thước bộ đệm tối đa. Đối tượng này cũng bao gồm tham số public thuộc kiểu boolean. Đặt public thành true sẽ cho phép bất kỳ ai đúc NFT nén từ cây. Nếu không, chỉ người tạo cây hoặc delegate của cây mới có thể đúc NFT nén từ cây. Tài khoản được ủy quyền có thể thực hiện các hành động thay mặt chủ sở hữu cây, chẳng hạn như chuyển hoặc đốt NFT nén. Ngoài ra, bạn có thể chỉ định một delegate cho cây bằng createSetTreeDelegateInstruction từ gói @metaplex-foundation/mpl-bubblegum như sau:

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

Chúng ta cũng truyền ID chương trình của chương trình Bubblegum. Giờ hãy quay lại phần mã còn lại:

Mã
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}`);
}

Chúng ta thêm hai instruction vừa tạo vào một giao dịch rồi gửi đi. Chúng ta bảo đảm cả treeKeypair và payer đều ký giao dịch. Sau đó, chữ ký của giao dịch thành công được ghi vào console. Chúng ta bọc quy trình này trong một khối try-catch để nếu xảy ra lỗi vì bất kỳ lý do nào, lỗi đó sẽ được ghi vào console qua console.error.

Tạo cây Merkle đồng thời bằng Umi

Việc sử dụng Bubblegum SDK, chương trình nén tài khoản của Solana và các gói web3.js của Solana có thể khá khó hiểu với nhà phát triển mới và mất công thiết lập mỗi lần. May mắn là Bubblegum SDK cung cấp thao tác createTree xử lý mọi thứ cho chúng ta và kết hợp hiệu quả với Umi. Mã như sau:

Mã
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 là một framework mô-đun để xây dựng và sử dụng các client JavaScript cho chương trình Solana. Umi cung cấp một thư viện không có dependency cùng một tập hợp giao diện cốt lõi mà các thư viện khác có thể dựa vào, không bị ràng buộc với một cách triển khai cụ thể. Umi do Metaplex cung cấp và bạn có thể xem tài liệu tại đây.

Chúng ta dùng phiên bản Umi của mình để tạo signer, tạo cây Merkle, đồng thời gửi và xác nhận giao dịch đã xây dựng. Theo mặc định, người tạo cây được đặt thành danh tính Umi và tham số public được đặt thành false. Có thể tùy chỉnh các tham số này để truyền vào một người tạo cây tùy chỉnh và giá trị công khai true. Đây là cách nhanh hơn nhiều để tạo cây Merkle đồng thời trên chuỗi.

Lưu ý rằng Bubblegum không phụ thuộc vào kích thước canopy. Lý do là chương trình Account Compression của Solana sẽ xác định kích thước canopy dựa trên không gian tài khoản hiện có. Bạn chỉ cần cấp phát đủ không gian để chương trình có thể xác định chính xác kích thước canopy phù hợp cần sử dụng.

Đúc cNFT bằng cách tương tác trực tiếp với Bubblegum

Tạo bộ sưu tập

Theo truyền thống, các NFT được nhóm thành một bộ sưu tập theo tiêu chuẩn Metaplex. Điều này áp dụng cho cả NFT nén và NFT “thông thường”. Để tạo một bộ sưu tập:

  • Tạo một “mint” token mới
  • Tạo một tài khoản token liên kết cho mint
  • Đúc một token duy nhất
  • Lưu metadata của bộ sưu tập trong một tài khoản trên chuỗi

Mặc dù không liên quan trực tiếp đến chủ đề nén trạng thái hoặc NFT nén và do đó nằm ngoài phạm vi bài viết này, chúng tôi vẫn cung cấp một script làm tài liệu tham khảo để bạn tạo bộ sưu tập riêng. Bạn có thể truy cập script đó tại đây.

Đúc NFT vào bộ sưu tập của chúng ta

Với bộ sưu tập mới tạo, bạn sẽ cần những thành phần sau để bắt đầu đúc:

  • collectionMint - địa chỉ mint của bộ sưu tập
  • collectionAuthority - tài khoản có authority đối với bộ sưu tập
  • collectionMetadata - tài khoản metadata của bộ sưu tập
  • editionAccount - tài khoản lưu các thuộc tính bổ sung, chẳng hạn như tài khoản master edition

Mã đầy đủ để đúc vào một bộ sưu tập

Mã
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}`);
  }
}

Phân tích quy trình đúc

Mã
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";

Trước tiên, chúng ta import @solana/web3.js, @solana/spl-account-compression, @metaplex-foundation/mpl-bubblegum và @metaplex-foundation/mpl-token-metadata cùng các mô-đun cần thiết.

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

Chúng ta định nghĩa mintCompressedNFT, hàm nhận khá nhiều tham số:

  • connection - đối tượng kết nối dùng để tương tác với Solana
  • payer - tài khoản sẽ thanh toán phí giao dịch
  • treeAddress - tài khoản của cây Merkle đồng thời
  • collectionMint - địa chỉ mint của bộ sưu tập
  • collectionMetadata - tài khoản metadata của bộ sưu tập
  • collectionMasterEditionAccount - tài khoản master edition
  • compressedNFTMetadata - metadata dành riêng cho cNFT sẽ được đúc
  • receiverAddress - địa chỉ public key tùy chọn để nhận cNFT mới đúc
Mã
const [treeAuthority, ] = PublicKey.findProgramAddressSync([treeAddress.toBuffer()], BUBBLEGUM_PROGRAM_ID);

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

Tại đây, chúng ta tìm các PDA cần thiết và bỏ qua bump của chúng. Trước tiên, chúng ta dẫn xuất PDA cho authority của cây, sau đó dẫn xuất một PDA để làm signer cho hoạt động đúc nén. Chúng ta cần thêm collection_cpi vì đây là tiền tố tùy chỉnh mà chương trình Bubblegum yêu cầu.

Mã
const mintInstructions: TransactionInstruction[] = [];

Chúng ta đặt mintInstructions thành một mảng TransactionInstruction rỗng. Điều này cho phép chúng ta đúc nhiều cNFT cùng lúc nếu muốn.

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

metadataArgs bảo đảm compressedNFTMetadata được định dạng chính xác. Khi đúc NFT vào một bộ sưu tập bằng createMintToCollectionV1Instruction, trường verified phải được đặt thành false thì giao dịch mới thành công, mặc dù instruction này tự động xác minh bộ sưu tập.

Mã
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,
      }
  	)
);

Chúng ta thêm một mint vào instruction. Có thể thêm nhiều mint trong cùng một giao dịch, miễn là giao dịch vẫn nằm trong giới hạn kích thước byte. Tại đây, chúng ta dùng createMintToCollectionV1Instruction để đúc NFT nén từ bộ sưu tập. Instruction này nhận hai đối tượng: một đối tượng chứa các tài khoản cần thiết để xử lý instruction và một đối tượng cung cấp dữ liệu instruction cho chương trình. Hầu hết các tham số này hẳn đã quen thuộc từ những phần trước. Lưu ý rằng bạn có thể đặt bất kỳ địa chỉ delegate nào khi đúc, nhưng thông thường địa chỉ đó nên giống với leafOwner. Dù vậy, delegate sẽ tự động bị xóa khi cNFT được chuyển. Chúng ta đặt payer làm delegate vì tài khoản này cũng sẽ nhận cNFT nếu không cung cấp receiverAddress.

Mã
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}`);
  }

Sau đó, chúng ta xây dựng giao dịch, đặt payer làm feePayer và gửi giao dịch. Chúng ta bọc logic này trong một khối try-catch để xử lý mọi lỗi khi gửi và xác nhận giao dịch. Nếu xảy ra lỗi, chúng ta ghi lỗi vào console bằng console.error.

Đúc cNFT bằng Umi

Chương trình Bubblegum cung cấp hai quy trình đúc qua Umi:

  • Đúc NFT mà không liên kết với một bộ sưu tập
  • Đúc NFT vào một bộ sưu tập cụ thể.

Đúc mà không có bộ sưu tập

Instruction MintV1 của Bubblegum cho phép đúc NFT nén từ Bubblegum Tree mà không cần bộ sưu tập. Nếu cây là công khai, bất kỳ ai cũng có thể đúc vào cây này. Nếu không, chỉ người tạo cây hoặc delegate mới có thể dùng instruction này. Sau đây là cách đúc NFT nén mà không cần bộ sưu tập:

Mã
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);

Đoạn mã này được lấy từ tài liệu Metaplex về cách đúc cNFT bằng Bubblegum. Tại đây, chúng ta dùng một phiên bản Umi để đúc cNFT. Các tham số khác của instruction mintV1 như sau:

  • leafOwner là chủ sở hữu của cNFT sẽ được đúc
  • merkleTree là địa chỉ tài khoản cây Merkle đồng thời dùng để đúc cNFT
  • metadata là một đối tượng chứa metadata của cNFT sẽ được đúc. Đối tượng này bao gồm tên cNFT, URI, bộ sưu tập mà chúng ta đã đặt thành none, cũng như những người tạo. Có thể cung cấp đối tượng bộ sưu tập, nhưng phải đặt trường verified trong creators thành false vì instruction không yêu cầu authority của bộ sưu tập. Những người tạo cũng có thể tự xác minh bằng cách đặt trường verified thành true và cung cấp creator làm signer trong các tài khoản còn lại.

Instruction mintV1 cũng chứa một số trường tùy chọn vì đầu vào của hàm thuộc kiểu MintV1InstructionAccounts & MintV1InstructionArgs. Các kiểu này được định nghĩa như sau:

Mã
// 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 là một kiểu khó nhận biết nằm trong các kiểu khác, cuối cùng quy về một đối tượng có trường metadata. Trường metadata này thuộc kiểu MetadataArgsArgs và được định nghĩa như sau:

Mã
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;
};

Bạn có thể xem định nghĩa hàm đầy đủ của mintV1 và tất cả các kiểu liên quan tại đây. Nhưng tối thiểu, với một phiên bản Umi, bạn có thể đúc cNFT mà không cần bộ sưu tập nếu truyền vào metadata, chủ sở hữu leaf và tài khoản cây Merkle đồng thời cần thiết.

Đúc với một bộ sưu tập

Bubblegum cung cấp mintToCollectionV1 như một cách thuận tiện để đúc trực tiếp cNFT vào một bộ sưu tập cụ thể. Đầu vào của instruction này thuộc các kiểu MintToCollectionV1InstructionAccounts và MintToCollectionV1InstructionArgs, cuối cùng là một đối tượng thuộc kiểu MetadataArgsArgs. Định nghĩa kiểu cho MintToCollectionV1InstructionAccounts như sau:

Mã
// 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;
};

Các tham số chính là collection mint, collection authority và PDA bản ghi collection authority. Phải cung cấp PDA bản ghi delegate khi dùng collection authority được ủy quyền để bảo đảm authority được phép quản lý NFT của bộ sưu tập. Tham số metadata phải chứa một đối tượng collection có trường address khớp với tham số collection mint và trường verified được đặt thành false. Những người tạo cũng có thể tự xác minh bằng cách ký giao dịch và tự thêm mình vào các tài khoản còn lại.

Sau đây là cách đúc NFT nén với một bộ sưu tập:

Mã
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);

Bạn có thể tìm thấy đoạn mã này trong tài liệu Metaplex về cách đúc cNFT bằng Bubblegum. Một lần nữa, chúng ta dùng một phiên bản Umi để đúc NFT nén. Tương tự mintV1, chúng ta truyền vào leafOwner và merkleTree. Tuy nhiên, lần này chúng ta truyền thêm collectionMint. Trong trường metadata, chúng ta truyền vào một đối tượng collection có key khớp với collectionMint và trường verified được đặt thành false. Lưu ý rằng danh tính Umi được đặt làm collection authority mặc định. Có thể thay đổi bằng cách đặt trường collectionAuthority tùy chọn thành một collection authority tùy chỉnh.

Mint cNFT bằng Helius

Tại Helius, chúng tôi cung cấp Mint API, cho phép bạn mint NFT nén mà không phải xử lý thêm các vấn đề phức tạp. Chúng tôi chi trả phí Solana, tạo cây Merkle và tải metadata ngoài chuỗi của bạn lên Arweave. Chúng tôi cũng đảm bảo transaction đã được gửi thành công và được mạng xác nhận, vì vậy bạn không cần tự mình thăm dò trạng thái. Ngoài ra, chúng tôi phân tích ID tài sản từ transaction để bạn có thể sử dụng ngay với DAS API.

Để Helius mint một NFT vào bộ sưu tập của bạn, Helius phải được ủy quyền quản lý bộ sưu tập. Quyền này phải được ủy quyền cho một trong các account sau, tùy theo cluster của bạn:

  • Devnet: 2LbAtCJSaHqTnP9M5QSjvAMXk79RNLusFspFN5Ew67TC
  • Mainnet: HnT5KVAywGgQDhmh6Usk4bxRg4RwKxCK4jmECyaDth5R

Sau đây là cách mint một cNFT bằng Helius Mint API:

Mã
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();

Bạn có thể tìm thấy đoạn mã này và phần phân tích chi tiết hơn về schema của request trong tài liệu của chúng tôi.

Lưu ý rằng nếu bạn không điền trường uri, chúng tôi sẽ tạo một tệp JSON và thay bạn tải tệp đó lên Arweave. Tệp sẽ tuân thủ tiêu chuẩn JSON Metaplex v1.0 và được tải lên qua Irys (trước đây gọi là Bundlr).

Chuyển cNFT

Quy trình chung để chuyển một NFT nén gồm các bước sau:

  • Lấy dữ liệu tài sản của cNFT từ indexer
  • Lấy proof của cNFT từ indexer
  • Lấy account cây Merkle đồng thời từ Solana
  • Chuẩn bị proof của tài sản
  • Tạo và gửi transaction chuyển

Việc sử dụng Umi và Metaplex giúp đơn giản hóa đáng kể quy trình này, nhưng phần này sẽ trình bày những gì diễn ra phía sau. Dưới đây, chúng tôi sẽ hướng dẫn cách chuyển một NFT nén bằng cả web3.js và Metaplex.

Chuyển bằng cách tương tác trực tiếp với Bubblegum

Chúng ta cần truy xuất một số thông tin về NFT nén trước khi dùng script để thực hiện chuyển. Trước tiên, cần sử dụng phương thức getAsset trên DAS API để truy xuất metadata của NFT nén. Ở đây, chúng ta cần tìm data_hash, creator_hash, owner, delegate và leaf_id:

Mã
// 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();

Đây là một phần của response thành công:

Mã
{
  ...
  },
  "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",
    ...
  }
}

Sau khi có thông tin cần thiết, chúng ta cần dùng phương thức getAssetProof để truy xuất proof và tree_id (địa chỉ của cây). Sau đây là một lệnh gọi mẫu:

Mã
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();

Đây là response thành công:

Mã
{
  "root": "string",
  "proof": [
    "string"
  ],
  "node_index": 0,
  "leaf": "string",
  "tree_id": "string"
}

Giờ đây, khi đã có root, proof và tree_id, chúng ta có thể chuyển sang script chuyển tài sản.

Mã đầy đủ

Mã
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}`);
  }
};

Phân tích mã

Mã
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";

Chúng ta import @solana/web3.js, @metaplex-foundation/mpl-bubblegum và @solana/spl-account-compression cùng các module cần thiết.

Mã
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
}

Chúng ta định nghĩa hàm transferCompressedNFT, trong đó sẽ phân tích đường dẫn proof, tạo instruction chuyển và thực thi instruction đó.

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

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

Chúng ta truy xuất account cây Merkle đồng thời từ blockchain, sau đó trích xuất quyền quản lý cây và độ sâu canopy. Các giá trị này cần thiết để tạo instruction chuyển.

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

Nói đơn giản, chúng ta phân tích danh sách địa chỉ proof thành một mảng hợp lệ thuộc kiểu AccountMeta. AccountMeta là metadata của account được dùng để định nghĩa transaction. Metadata này bao gồm public key của account, liệu một instruction có yêu cầu chữ ký transaction khớp với public key hay không và liệu public key có thể được tải dưới dạng account đọc-ghi hay không.

Chúng ta lấy một phần từ proof đầy đủ, bắt đầu từ đầu mảng và đảm bảo chỉ có proof.length - canopyDepth giá trị proof. Việc này loại bỏ phần cây đã được lưu vào bộ nhớ đệm trong canopy trên chuỗi. Sau đó, chúng ta cấu trúc từng giá trị proof còn lại thành một AccountMeta hợp lệ. Cần làm vậy vì proof được gửi lên chuỗi dưới dạng “các account bổ sung” trong instruction chuyển.

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

Sau đó, chúng ta đặt leafOwner thành tham số owner và leafDelegate thành tham số delegate.

Mã
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
  );

Chúng ta tạo transferInstruction bằng hàm hỗ trợ createTransferInstruction từ Bubblegum SDK. Lưu ý rằng root, dataHash và creatorHash được DAS API trả về dưới dạng chuỗi, vì vậy chúng ta phải chuyển đổi chúng sang kiểu PublicKey rồi thành mảng byte.

Mã
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}`);
  }

Sau khi tạo xong instruction, chúng ta thêm nó vào một transaction mới rồi gửi đến Solana. Nếu có lỗi, chúng ta ghi lỗi vào console bằng console.error.

Nếu gặp lỗi liên quan đến cây Merkle đồng thời, có thể RPC đang cung cấp dữ liệu cũ hoặc không chính xác cho proof của cây Merkle đồng thời. Điều này đôi khi xảy ra do vấn đề với bộ nhớ đệm. Để khắc phục, bạn có thể thử xác minh proof do RPC cung cấp ở phía client:

Mã
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());

Lưu ý rằng bạn cũng cần sử dụng giá trị leaf do lệnh gọi DAS API getAssetProof trả về. Điều này không bắt buộc vì quá trình xác thực proof thực tế được thực hiện trên chuỗi, tuy nhiên nó có thể giúp xử lý lỗi.

Vậy là bạn có thể thực hiện thêm một lệnh gọi getAsset để thấy rằng leafDelegate là một giá trị trống và leaf đã có chủ sở hữu mới!

Chuyển bằng Umi

Mã
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);

Mã này lấy từ tài liệu Metaplex về cách chuyển NFT nén.

Bubblegum cung cấp instruction transfer rất dễ sử dụng. Đầu tiên, instruction này nhận một instance của Umi. Sau đó, nó nhận một object chứa tài sản cùng thông tin về proof, chủ sở hữu leaf và chủ sở hữu leaf mới. Để lấy tài sản cùng proof cần thiết, chúng ta có thể dùng phương thức getAssetWithProof cũng do Bubblegum cung cấp. Lưu ý rằng có thể dùng người được ủy quyền của leaf thay cho chủ sở hữu leaf — chỉ cần một account có quyền phê duyệt việc chuyển. Với phương thức .sendAndConfirm(), chúng ta gửi transaction khởi tạo quá trình chuyển rồi xác nhận transaction đó bằng instance Umi.

Kết luận

Chúc mừng! Chúng ta đã tìm hiểu toàn diện về nén trạng thái và NFT nén trên Solana. Chúng ta đã khám phá sự phức tạp của cây Merkle đồng thời, làm sáng tỏ những quan niệm sai lầm phổ biến và tìm hiểu sâu về sổ cái của Solana. Không chỉ dừng ở lý thuyết, chúng ta còn học cách truy xuất, mint và chuyển cNFT bằng sức mạnh của web3.js trên Solana, Metaplex và Helius!

Cơ chế nén trạng thái của Solana mang tính cách mạng trong bối cảnh chi phí transaction và lưu trữ có thể trở thành rào cản. Cơ chế nén giúp giảm mạnh chi phí mà không ảnh hưởng đến tính bảo mật hay phi tập trung. Đây là một bước chuyển đổi mô hình, mở ra những khả năng chưa từng có cho cả nghệ sĩ, nhà sưu tập và nhà phát triển.

Nếu đã đọc đến đây, cảm ơn bạn, anon! Giờ bạn đã có đủ kiến thức để đóng góp cho lĩnh vực mới đầy hứng khởi này. Hãy bắt tay vào thực hiện — mint một bộ sưu tập mười triệu NFT cho MMORPG trên chuỗi, xây dựng một ứng dụng phi tập trung tận dụng sức mạnh của sổ cái hoặc đơn giản là chia sẻ kiến thức mới với cộng đồng. Cách tốt nhất để dự đoán tương lai là tạo ra nó.

Tài nguyên bổ sung / Đọc thêm

Đăng ký nhận tin từ Helius

Luôn cập nhật những thông tin mới nhất về phát triển Solana và nhận thông báo khi chúng tôi đăng bài

Hình ảnh phóng to