신규: Helius가 Light Protocol을 인수했습니다
Solana 압축
블로그/기초

Solana 압축에 대해 알아야 할 모든 것

Developer Experience EngineerX의 0xIchigoLinkedIn의 0xIchigoGitHub의 0xIchigo
읽는 데 32분

이 글에서는 무엇을 다루나요?

지금 당장 150달러 미만으로 NFT 100만 개를 민팅할 수 있다고 하면 믿으시겠습니까? 터무니없게 들릴 것입니다. 블록체인에 따라 그만큼의 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 트리에 영향을 주는 트랜잭션 배치를 받으면 동일한 슬롯에서 처리할 수 있습니다. 하지만 슬롯별 데이터가 동시에 생성되는 것은 아닙니다. 다음 상태 압축이란 무엇인가요? 섹션에서 자세히 설명합니다.

트리는 컬렉션과 같습니다

동시성 Merkle 트리는 컬렉션과 다릅니다. 하나의 컬렉션은 동시성 Merkle 트리를 몇 개든 사용할 수 있습니다. NFT 그룹화 방식은 스토리지 방식과 독립적일 수 있다는 점이 중요합니다. NFT는 계정에 있거나 원장에 압축될 수 있으며, 하나 이상의 여러 트리에 걸쳐 존재할 수도 있습니다. 다만 복잡성을 줄이려면 동시성 Merkle 트리 하나를 단일 컬렉션에만 사용하는 것이 좋습니다.

상태 압축이란 무엇인가요?

상태 압축은 원장 데이터의 암호학적 해시를 생성하고 이를 계정에 저장해 스토리지를 최적화합니다. 이 방식은 원장이 본래 지닌 보안성과 불변성을 활용하면서 원장에 저장된 데이터를 검증할 수 있는 견고한 프레임워크를 제공합니다.

Solana 기반 애플리케이션을 위한 비용 효율적인 솔루션입니다. 이제 개발자는 더 비싼 계정 기반 스토리지 대신 원장 스토리지 공간을 사용할 수 있습니다. 따라서 상태 압축은 데이터 무결성을 보장할 뿐 아니라 Solana 리소스를 비용 효율적으로 할당할 수 있게 합니다.

Solana 상태 압축의 핵심은 동시성 Merkle 트리입니다. 동시성 Merkle 트리는 증명을 빠르게 최신 상태로 갱신할 수 있도록 여러 트랜잭션을 빠르게 연속 처리하는 데 최적화되어 있습니다. 업데이트할 때마다 트리의 증명이 무효화되는 기존 Merkle 트리와 다릅니다. 동시성 Merkle 트리는 루트 해시 및 이를 도출하는 데 필요한 증명과 함께 최근 변경 사항의 안전한 변경 로그를 저장합니다. 이 변경 로그는 해당 트리 전용 온체인 계정에 저장됩니다. 각 동시성 Merkle 트리에는 최대 버퍼 크기가 있습니다. 이 값은 Merkle 루트가 여전히 유효한 상태에서 트리에 적용할 수 있는 최대 변경 횟수를 나타냅니다. 계산된 증명 집합이 업데이트가 필요해지기 전까지 얼마나 "오래된" 상태여도 되는지를 나타낸다고 생각하면 됩니다.

따라서 검증인이 동일한 슬롯 내에서 온체인 Merkle 트리를 업데이트하라는 여러 요청을 받으면 트리의 변경 로그를 기준 정보로 사용할 수 있습니다. 이를 통해 최대 버퍼 크기만큼 Merkle 트리를 동시에 변경할 수 있습니다. 온체인에 저장되는 데이터 양을 직접 줄이지는 않지만 여러 업데이트를 동시에 처리할 수 있어 효율성이 높아집니다. 즉, 처리량이 높은 환경에서도 Merkle 트리가 제공하는 "포함 증명"의 무결성을 유지할 수 있습니다. 여기서 포함 증명이란 특정 데이터 요소가 함께 해싱되어 하나의 Merkle 루트를 구성한 데이터 집합에 실제로 포함되어 있음을 증명하는 능력입니다.

상태 압축과 동시성 Merkle 트리의 독창적인 조합은 Solana에서 애플리케이션을 구축할 때 매우 비용 효율적인 솔루션을 제공합니다. 이러한 기술의 영향을 온전히 이해하려면 Solana 상태와 원장의 차이를 알아야 합니다.

상태와 원장

원장은 Solana의 제네시스 블록 이후 클라이언트가 서명해 발생한 모든 트랜잭션의 기록입니다. 추가 전용 데이터 구조이므로 트랜잭션이 추가되면 수정하거나 제거할 수 없습니다. 검증인은 원장에 추가되는 트랜잭션을 검증합니다. 내결함성을 보장하기 위해 네트워크 전반의 여러 노드가 원장을 저장합니다. 다만 이전 블록은 향후 블록 검증에 필요하지 않으므로 스토리지를 줄이기 위해 검증인의 원장 사본에는 최신 블록만 포함될 수 있습니다.

상태는 Solana의 모든 계정과 프로그램을 현재 시점에서 보여주는 스냅샷입니다. 상태는 변경 가능하며 트랜잭션이 처리될 때 바뀝니다. 토큰 잔액, 프로그램, 계정을 쿼리할 수 있도록 고도로 최적화된 데이터베이스라고 생각하면 됩니다.

두 개념을 쉽게 구분해 보겠습니다. Alice와 Bob이 각각 100 SOL의 잔액을 보유하고 있다고 가정합니다. Alice가 Bob에게 10 SOL을 보내는 트랜잭션을 전송합니다. 검증이 끝나면 트랜잭션이 블록에 추가되고, 해당 블록은 원장에 추가됩니다. 이제 원장에는 Alice가 Bob에게 10 SOL을 보냈다는 불변 기록이 남습니다. 동시에 상태에서는 Alice와 Bob의 계정이 각각 90 SOL과 110 SOL로 업데이트됩니다.

두 개념의 주요 차이는 다음과 같습니다.

  • 원장은 변경할 수 없는 추가 전용 구조지만, 상태는 변경할 수 있으며 계속 바뀝니다
  • 원장은 모든 트랜잭션의 기록이지만, 상태는 모든 계정과 프로그램의 현재 상태를 반영합니다
  • 원장은 검증에 사용되지만, 상태는 트랜잭션 실행과 프로그램 구동에 사용됩니다

원장은 모든 트랜잭션을 검증하고 추적할 수 있도록 변경 불가능한 기록으로 작동합니다. 상태는 원장의 동적 스냅샷으로서 전송과 프로그램 실행 같은 실시간 작업에 맞춰 조정됩니다. 중요한 점은 둘 다 체인 자체의 합의를 따른다는 것입니다. 상태와 원장은 함께 Solana의 근간을 이루며 탈중앙화된 신뢰를 유지하면서 효율적으로 작동할 수 있게 합니다.

압축 NFT란 무엇인가요?

압축 NFT(cNFT)는 상태 압축과 동시성 Merkle 트리를 사용해 스토리지 비용을 줄입니다. 각 NFT를 일반적인 Solana 계정에 저장하는 대신 메타데이터를 원장에 저장합니다. 이를 통해 원장의 보안성과 불변성을 그대로 유지하면서 스토리지 비용을 절감할 수 있습니다.

압축 NFT도 비압축 NFT와 정확히 동일한 메타데이터 스키마를 따릅니다. 따라서 NFT와 cNFT는 같은 방식으로 정의됩니다.

NFT와 cNFT의 주요 차이는 다음과 같습니다.

  • 압축 NFT는 일반 NFT로 변환할 수 있지만, 일반 NFT는 압축 NFT로 변환할 수 없습니다
  • 압축 NFT는 Solana 네이티브 토큰이 아닙니다. 토큰 계정, 민트 계정 또는 메타데이터가 없습니다. 하지만 안정적인 식별자(자산 ID)는 있습니다. 압축을 해제해도 NFT는 동일한 식별자를 유지합니다. 따라서 압축 상태의 NFT는 네이티브 토큰이 아니지만 필요하면 네이티브 토큰으로 만들 수 있습니다
  • 하나의 동시성 Merkle 트리 계정에 수백만 개의 NFT를 보관할 수 있습니다
  • 하나의 컬렉션이 여러 트리 계정에 걸쳐 존재할 수 있습니다
  • 모든 NFT 변경은 Bubblegum 프로그램을 통해 이루어집니다
  • 압축 NFT 정보를 읽을 때는 DAS API 호출을 권장합니다

흥미롭게도 압축 NFT 정보를 가져오려면 DAS API를 사용해야 합니다. 왜 그럴까요? 그리고 DAS API란 무엇일까요?

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 - 압축 자산과 관련된 트랜잭션 서명 목록 가져오기
  • 페이지네이션 - 한 번에 1,000개가 넘는 레코드를 가져오기 위한 페이지 기반 및 키셋 페이지네이션 지원

각 메서드에 관한 자세한 내용은 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를 저장하려면 2^7 = 128 및 2^6 = 64이므로 maxDepth 7이면 충분합니다. 최대 깊이는 온체인 동시성 Merkle 트리를 구축할 때 비용을 좌우하는 주요 요소입니다. 이 비용은 트리 생성 시 선불로 발생하며 maxDepth 값이 클수록 증가합니다.

최대 버퍼 크기는 Merkle 루트가 여전히 유효한 상태에서 트리에 적용할 수 있는 최대 변경 횟수를 의미합니다. 동시성 Merkle 트리의 변경 로그 버퍼는 트리 생성 시 maxBufferSize 값으로 크기가 정해지고 설정됩니다. 따라서 검증인이 동일한 슬롯에서 트리에 대한 여러 변경 요청을 받으면 변경 로그를 사용해 루트가 유효한 상태에서 최대 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 값이 낮은 트리를 압축 NFT에 사용하면 NFT 마켓플레이스에서 해당 컬렉션의 단순 전송만 지원할 수도 있습니다. 일반적으로 구성 가능성을 극대화하려면 maxDepth - canopyDepth이 10 이하여야 합니다. 자세한 내용은 Tensor cNFT의 최대 증명 길이에 대한 Tensor 사양을 참조하세요.

비용 계산

동시성 Merkle 트리의 크기와 비용을 산정하는 방법은 여러 가지입니다. 가장 간단한 방법은 압축 NFT 계산기에 해당 트리에 저장할 압축 NFT 수를 입력하는 것입니다.

이 사이트는 저장하려는 자산 수에 필요한 최적의 트리 깊이와 구성 가능성에 따른 다양한 비용 옵션을 자세히 보여줍니다. 예를 들어 그림에 따르면 압축 NFT 1,000만 개를 저장하는 구성 가능성이 높은 트리를 만드는 데 드는 비용은 단 ~7.67 SOL입니다. NFT 1,000만 개를 민팅하는 데 드는 약 ~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 키로 설정할 수 있는 mainnet 연결이 필요합니다. calculateCosts 함수는 maxDepth, maxBufferSize, canopy, 이 트리에 저장할 수 있는 NFT 수, 그리고 SOL 단위의 rent 비용을 콘솔에 기록합니다. 따라서 원하는 증명 크기로 calculateCosts을 호출하면 가능한 모든 트리 조합을 콘솔에서 확인할 수 있습니다.

일부 로그에는 임대료 면제에 필요한 최소 잔액을 가져올 수 없음이 출력될 수 있습니다. 지정한 maxProofSize의 계정이 너무 커서 생성할 수 없기 때문에 계정을 임대료 면제 상태로 만드는 최소 잔액을 가져올 수 없는 것입니다.

동시성 Merkle 트리 생성하기

동시성 Merkle 트리를 생성할 때는 두 개의 계정을 만들어야 합니다.

  • 동시성 Merkle 트리 계정
  • 동시성 Merkle 트리 구성 계정

트리 계정에는 데이터 검증에 사용되는 Merkle 트리가 들어 있습니다. 이전 섹션에서 설명한 원하는 최대 깊이, 최대 버퍼 크기, 캐노피 깊이로 생성합니다. 이 계정은 Solana에서 만들고 유지 관리하는 Account Compression 프로그램이 소유합니다. 압축 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 엔드포인트 연결이며 타입은 Connection입니다
  • payer - 트랜잭션 비용을 지불할 계정이며 타입은 Keypair입니다
  • treeKeypair - 트리의 키 쌍 주소이며 타입은 Keypair입니다
  • maxDepthSizePair - 유효한 maxDepth 및 maxBufferSize 쌍이며 타입은 ValidDepthSizePair입니다
  • canopyDepth - 트리의 캐노피 깊이이며 타입은 number이고 기본값은 0입니다
코드
import {
    Connection,
    Keypair,
    PublicKey,
    Transaction,
    sendAndConfirmTransaction,
} from "@solana/web3.js";

import {
    ValidDepthSizePair,
    createAllocTreeIx,
    SPL_NOOP_PROGRAM_ID,
    SPL_ACCOUNT_COMPRESSION_PROGRAM_ID
} from "@solana/spl-account-compression";

import {
    PROGRAM_ID,
    createCreateTreeInstruction
  } from "@metaplex-foundation/mpl-bubblegum";

먼저 @solana/web3.js, @solana/spl-account-compression, @metaplex-foundation/mpl-bubblegum에서 필요한 모듈을 가져옵니다.

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

여기서는 앞서 설명한 매개변수로 createTree 함수를 정의합니다.

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

createAllocTreeIx은 동시성 Merkle 트리 계정을 생성하는 데 사용하는 헬퍼 함수입니다. SPL Account Compression 패키지는 이러한 계정이 상당히 크고 CPI로 할당할 수 있는 한도를 넘을 수 있으므로 이 메서드로 동시성 Merkle 트리 계정을 초기화할 것을 권장합니다. 여기서는 온체인에 트리 계정을 할당하는 명령을 생성합니다. 온체인에 트리를 저장하는 데 필요한 공간과 비용도 계산하므로 나중에 따로 처리할 필요가 없습니다.

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

권한을 Bubblegum 프로그램이 소유하는 트리 구성 계정을 파생해야 합니다. 트리를 생성하는 명령인 createCreateTreeInstruction에 treeAuthority을 인수로 전달해야 하므로 필요합니다. 여기서는 트리의 공개 키와 Bubblegum 프로그램 ID를 사용해 findProgramAddressSync 메서드로 PDA를 파생합니다. 권한과 범프가 모두 반환되므로 treeAuthority을 구조 분해해야 합니다. 함수에 필요하지 않아 범프는 생략했습니다. 필요한 경우 구조 분해를 [treeAuthority, bump]으로 변경해 범프를 저장하세요.

코드
const createTreeInstruction = createCreateTreeInstruction(
		{
	    payer: payer.publicKey,
      treeCreator: payer.publicKey,
      treeAuthority,
      merkleTree: treeKeypair.publicKey,
      compressionProgram: SPL_ACCOUNT_COMPRESSION_PROGRAM_ID,
      logWrapper: SPL_NOOP_PROGRAM_ID,
    },
    {
      maxBufferSize: maxDepthSizePair.maxBufferSize,
      maxDepth: maxDepthSizePair.maxDepth,
      public: false,
    },
    PROGRAM_ID,
);

Bubblegum SDK의 createCreateTreeInstruction을 사용해 동시성 Merkle 트리를 구축하는 명령을 만듭니다. 이 명령은 Bubblegum 프로그램을 소유자로 지정해 온체인에 트리를 생성합니다. createCreateTreeInstruction에는 세 개의 매개변수가 있습니다. 첫 번째는 트리 생성자 같은 속성을 설정할 계정이 담긴 객체입니다. 두 번째 객체는 최대 깊이와 최대 버퍼 크기에 관한 것입니다. 타입이 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 ID로 설정되고 public 매개변수는 false로 설정됩니다. 이 매개변수는 사용자 지정할 수 있으므로 별도의 트리 생성자와 공개 값 true를 전달할 수도 있습니다. 온체인 동시성 Merkle 트리를 훨씬 빠르게 생성하는 방법입니다.

Bubblegum은 캐노피 크기와 무관하게 작동합니다. Solana의 Account Compression Program이 사용 가능한 계정 공간에 따라 캐노피 크기를 결정하기 때문입니다. 프로그램이 사용할 적절한 캐노피 크기를 정확히 판단할 수 있도록 충분한 공간만 할당하면 됩니다.

Bubblegum과 직접 상호작용해 cNFT 민팅하기

컬렉션 생성하기

전통적으로 NFT는 Metaplex 표준을 사용해 컬렉션으로 그룹화됩니다. 압축 NFT와 "일반" NFT 모두 마찬가지입니다. 컬렉션을 생성하려면 다음 작업을 수행합니다.

  • 새 토큰 "민트" 생성
  • 민트에 연결된 토큰 계정 생성
  • 단일 토큰 민팅
  • 컬렉션의 메타데이터를 온체인 계정에 저장

상태 압축이나 압축 NFT와 직접 관련된 내용은 아니므로 이 글의 범위를 벗어나지만, 자체 컬렉션을 만들 때 참고할 수 있는 스크립트를 제공했습니다. 해당 스크립트는 여기에서 확인할 수 있습니다.

컬렉션에 NFT 민팅하기

새로 만든 컬렉션에서 민팅을 시작하려면 다음 항목이 필요합니다.

  • collectionMint - 컬렉션의 민트 주소
  • collectionAuthority - 컬렉션에 대한 권한을 가진 계정
  • collectionMetadata - 컬렉션의 메타데이터 계정
  • editionAccount - 마스터 에디션 계정 같은 추가 속성을 보관하는 계정

컬렉션에 민팅하는 전체 코드

코드
import {
  Keypair,
  PublicKey,
  Connection,
  Transaction,
  sendAndConfirmTransaction,
  TransactionInstruction,
} from "@solana/web3.js";

import {
  SPL_ACCOUNT_COMPRESSION_PROGRAM_ID,
  SPL_NOOP_PROGRAM_ID,
} from "@solana/spl-account-compression";

import {
  PROGRAM_ID as BUBBLEGUM_PROGRAM_ID,
  MetadataArgs,
  createMintToCollectionV1Instruction,
} from "@metaplex-foundation/mpl-bubblegum";

import {
  PROGRAM_ID as TOKEN_METADATA_PROGRAM_ID,
} from "@metaplex-foundation/mpl-token-metadata";

export async function mintCompressedNFT(
  connection: Connection,
  payer: Keypair,
  treeAddress: PublicKey,
  collectionMint: PublicKey,
  collectionMetadata: PublicKey,
  collectionMasterEditionAccount: PublicKey,
  compressedNFTMetadata: MetadataArgs,
  receiverAddress?: PublicKey
) {
  const [treeAuthority, ] = PublicKey.findProgramAddressSync([treeAddress.toBuffer()], BUBBLEGUM_PROGRAM_ID);

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

  const mintInstructions: TransactionInstruction[] = [];

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

  mintInstructions.push(
    createMintToCollectionV1Instruction(
      {
        payer: payer.publicKey,

        merkleTree: treeAddress,
        treeAuthority,
        treeDelegate: payer.publicKey,
        leafOwner: receiverAddress || payer.publicKey,
        leafDelegate: payer.publicKey,

        collectionAuthority: payer.publicKey,
        collectionAuthorityRecordPda: BUBBLEGUM_PROGRAM_ID,
        collectionMint: collectionMint,
        collectionMetadata: collectionMetadata,
        editionAccount: collectionMasterEditionAccount,

        compressionProgram: SPL_ACCOUNT_COMPRESSION_PROGRAM_ID,
        logWrapper: SPL_NOOP_PROGRAM_ID,
        bubblegumSigner: bubblegumSigner,
        tokenMetadataProgram: TOKEN_METADATA_PROGRAM_ID,
      },
      {
        metadataArgs,
      }
    )
  );

  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 - 컬렉션의 민트 주소
  • 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를 찾고 범프는 무시합니다. 먼저 트리 권한의 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);

이 코드 조각은 Bubblegum으로 cNFT를 민팅하는 방법을 설명한 Metaplex 문서에서 가져왔습니다. 여기서는 Umi 인스턴스로 cNFT를 민팅합니다. mintV1 명령의 다른 매개변수는 다음과 같습니다.

  • leafOwner은 민팅할 cNFT의 소유자입니다
  • merkleTree은 cNFT를 민팅할 동시성 Merkle 트리 계정 주소입니다
  • metadata은 민팅할 cNFT의 메타데이터가 담긴 객체입니다. cNFT 이름, URI, none으로 설정한 컬렉션, 생성자 등이 포함됩니다. 컬렉션 객체를 제공할 수도 있지만, 명령에서 컬렉션 권한을 요청하지 않으므로 생성자의 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은 지정한 컬렉션에 cNFT를 직접 민팅할 수 있는 편리한 mintToCollectionV1을 제공합니다. 이 명령의 입력 타입은 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;
};

핵심 매개변수는 컬렉션 민트, 컬렉션 권한, 컬렉션 권한 레코드 PDA입니다. 위임된 컬렉션 권한을 사용할 때는 해당 권한이 컬렉션 NFT를 관리할 수 있는지 확인하기 위해 위임자 레코드 PDA를 제공해야 합니다. metadata 매개변수에는 주소 필드가 컬렉션 민트 매개변수와 일치하고 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);

이 코드 조각은 Bubblegum으로 cNFT를 민팅하는 방법을 설명한 Metaplex 문서에서 확인할 수 있습니다. 여기서도 Umi 인스턴스로 압축 NFT를 민팅합니다. mintV1과 마찬가지로 leafOwner 및 merkleTree을 전달합니다. 하지만 이번에는 collectionMint도 전달합니다. metadata 필드에는 키가 collectionMint과 일치하고 verified 필드가 false로 설정된 collection 객체를 전달합니다. Umi ID가 기본 컬렉션 권한으로 설정된다는 점에 유의하세요. 선택적 collectionAuthority 필드를 사용자 지정 컬렉션 권한으로 설정해 변경할 수 있습니다.

Helius로 cNFT 민팅하기

Helius는 번거로운 추가 작업 없이 압축 NFT를 민팅할 수 있는 Mint API를 제공합니다. Solana 수수료와 Merkle 트리 생성을 처리하고 오프체인 메타데이터를 Arweave에 업로드합니다. 트랜잭션이 성공적으로 제출되고 네트워크에서 확인되었는지도 보장하므로 직접 폴링할 필요가 없습니다. 또한 트랜잭션에서 자산 ID를 파싱하므로 즉시 DAS API에서 사용할 수 있습니다.

Helius가 컬렉션에 NFT를 민팅하려면 컬렉션 권한을 Helius에 위임해야 합니다. 클러스터에 따라 다음 계정 중 하나에 권한을 위임해야 합니다.

  • 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();

이 코드 조각과 요청 스키마에 관한 자세한 설명은 문서에서 확인할 수 있습니다.

uri 필드를 입력하지 않으면 Helius가 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 트리 계정을 가져오고 트리 권한과 캐노피 깊이를 추출합니다. 이 값들은 전송 명령을 구축하는 데 필요합니다.

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

간단히 말해 증명 주소 목록을 AccountMeta 타입의 유효한 배열로 파싱합니다. AccountMeta은 트랜잭션을 정의하는 데 사용되는 계정 메타데이터입니다. 여기에는 계정의 공개 키, 명령에 공개 키와 일치하는 트랜잭션 서명이 필요한지 여부, 공개 키를 읽기-쓰기 계정으로 불러올 수 있는지 여부가 포함됩니다.

전체 증명 배열의 처음부터 슬라이스를 가져오고 증명 값이 proof.length - canopyDepth개만 남도록 합니다. 온체인 캐노피에 이미 캐시된 트리 부분을 제거하기 위해서입니다. 그런 다음 남은 각 증명 값을 유효한 AccountMeta으로 구성합니다. 증명이 전송 명령 내의 "추가 계정" 형식으로 온체인에 제출되기 때문입니다.

코드
const leafOwner = new PublicKey(owner);
const leafDelegate = new PublicKey(delegate);

그런 다음 leafOwner을 owner 매개변수로, leafDelegate을 delegate 매개변수로 설정합니다.

코드
const transferInstruction = createTransferInstruction(
    {
      merkleTree: treeAddress,
      treeAuthority,
      leafOwner,
      leafDelegate,
      newLeafOwner,
      logWrapper: SPL_NOOP_PROGRAM_ID,
      compressionProgram: SPL_ACCOUNT_COMPRESSION_PROGRAM_ID,
      anchorRemainingAccounts: proofPath,
    },
    {
      root: [...new PublicKey(root.trim()).toBytes()],
      dataHash: [...new PublicKey(dataHash.trim()).toBytes()],
      creatorHash: [...new PublicKey(creatorHash.trim()).toBytes()],
      nonce: leafId,
      index: leafId,
    },
    PROGRAM_ID
  );

Bubblegum SDK의 createTransferInstruction 헬퍼 함수를 사용해 transferInstruction을 구축합니다. root, dataHash, creatorHash은 DAS API에서 문자열로 반환되므로 PublicKey 타입으로 변환한 다음 바이트 배열로 변환해야 합니다.

코드
try {
    const txt = new Transaction().add(transferInstruction);
    txt.feePayer = payer.publicKey;

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

    console.log(`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);

이 코드는 압축 NFT 전송 방법을 설명한 Metaplex 문서에서 가져왔습니다.

Bubblegum은 사용법이 매우 간단한 transfer 명령을 제공합니다. 먼저 Umi 인스턴스를 받습니다. 그런 다음 증명 정보가 포함된 자산, 리프 소유자, 새 리프 소유자가 담긴 객체를 받습니다. 필요한 증명이 포함된 자산을 가져오려면 Bubblegum에서 제공하는 getAssetWithProof 메서드를 사용할 수 있습니다. 리프 소유자 대신 리프 위임자를 사용할 수도 있습니다. 전송을 승인할 권한이 있는 계정만 있으면 됩니다. .sendAndConfirm() 메서드로 전송을 시작하는 트랜잭션을 보내고 Umi 인스턴스로 확인합니다.

결론

축하합니다! Solana의 상태 압축과 압축 NFT를 매우 폭넓게 살펴봤습니다. 동시성 Merkle 트리의 복잡성을 이해하고, 흔한 오해를 해소했으며, Solana 원장을 깊이 탐구했습니다. 이론을 넘어 Solana의 web3.js, Metaplex, Helius를 활용해 cNFT를 조회, 민팅, 전송하는 방법도 배웠습니다.

트랜잭션과 스토리지 비용이 제약이 될 수 있는 환경에서 Solana의 상태 압축은 혁신적인 기술입니다. 압축은 보안이나 탈중앙화를 훼손하지 않으면서 비용을 대폭 줄입니다. 아티스트, 수집가, 개발자 모두에게 전례 없는 가능성을 여는 패러다임 전환입니다.

여기까지 읽어주셔서 감사합니다, anon! 이제 이 흥미로운 개척 분야에 기여할 준비가 되었습니다. 온체인 MMORPG를 위한 NFT 1,000만 개 컬렉션을 민팅하거나, 원장의 강점을 활용하는 탈중앙화 앱을 만들거나, 새롭게 얻은 지식을 커뮤니티와 공유해 보세요. 미래를 예측하는 가장 좋은 방법은 직접 만드는 것입니다.

추가 자료 / 더 읽어보기

Helius 구독하기

최신 Solana 개발 소식을 확인하고 새 게시물 알림을 받아보세요

확대 이미지