NFT 컬렉션 및 머클 트리 유지 관리

솔라나 온체인 프로그램에서 컬렉션과 머클 트리를 생성하여 cNFT를 발행하기 위한 완전한 탈중앙화 솔루션

액세스 프로토콜
Access Protocol
17 min readDec 22, 2023

--

블라드(Vlad)는 액세스 프로토콜의 풀 스택 소프트웨어 엔지니어로, 솔라나 프로그램 개발과 백엔드 및 UI를 담당하고 있습니다. 블라드는 전통 핀테크 회사에서 대규모 시스템을 구축하였으며, 이후 웹3로 뛰어들었습니다. 엑스(트위터) 또는 이메일을 통해 블라드에게 연락하실 수 있습니다.

이 아티클은 솔라나 cNFT 사용 방법의 두번째 파트입니다.

이전 글에서는 온체인 프로그램에서 cNFT 발행과 소각 방법을 알아보았습니다. 그러나 cNFT 컬렉션(collection)과 머클 트리 생성은 오프체인에서 이루어졌고, 컬렉션과 머클 트리 권한에 대한 요구 사항이 없었습니다. 이는 악의적인 공격자가 자신이 소유한 계정으로 프로그램을 호출한 다음 컬렉션이나 머클 트리를 어떤 식으로든 수정할 수 있기 때문에 실제 프로그램에서 보안 문제를 일으킬 수 있습니다.

솔라나 지갑에 표시된 NFT 컬렉션

*솔라나 온체인 프로그램은 컨트랙트를 의미하며, NFT 컬렉션은 creator를 의미합니다.

컬렉션 생성

저희의 새로운 코드의 첫 번째 부분은 체인에서 컬렉션 생성을 해결합니다. 이는 이전 글의 앵커 컨트랙트에 직접 연결할 수 있습니다.

컬렉션 데이터와 계정 구조

컬렉션을 생성하고 전용 계정에 발행 주소(mint address)를 온체인에 저장할 것입니다. 이 계정은 컬렉션(나중에는 머클 트리) 권한과 같은 역할을 하게 될 것입니다.

#[account]
pub struct CentralStateData {
pub collection_address: Pubkey,
pub merkle_tree_address: Option<Pubkey>,
}

impl CentralStateData {
pub const MAX_SIZE: usize = 32 * 3;
}

#[derive(Accounts)]
pub struct Init<'info> {
/// CHECK: ok, we are passing in this account ourselves
#[account(mut, signer)]
pub signer: AccountInfo<'info>,
#[account(
init,
payer = signer,
space = 8 + CentralStateData::MAX_SIZE,
seeds = [b"central_authority"],
bump
)]
pub central_authority: Account<'info, CentralStateData>,
#[account(
init,
payer = signer,
mint::decimals = 0,
mint::authority = central_authority.key(),
mint::freeze_authority = central_authority.key(),
)]
pub mint: Account<'info, Mint>,
#[account(
init_if_needed,
payer = signer,
associated_token::mint = mint,
associated_token::authority = central_authority
)]
pub associated_token_account: Account<'info, TokenAccount>,
/// CHECK - address
#[account(
mut,
address = find_metadata_account(& mint.key()).0,
)]
pub metadata_account: AccountInfo<'info>,
/// CHECK: address
#[account(
mut,
address = find_master_edition_account(& mint.key()).0,
)]
pub master_edition_account: AccountInfo<'info>,

pub token_program: Program<'info, Token>,
pub associated_token_program: Program<'info, AssociatedToken>,
pub token_metadata_program: Program<'info, Metadata>,
pub system_program: Program<'info, System>,
pub rent: Sysvar<'info, Rent>,
}

컬렉션 생성 코드

컬렉션은 몇 가지 특정 세부 사항이 있는 일반적인 비압축 NFT일 뿐입니다. 이를 설정하려면 다음 세 가지 계정을 만들어야 합니다.

  • Mint account
  • Metadata account
  • Master Edition Account

계정 구조와 목적에 대한 자세한 내용은 메타플렉스 문서에서 확인할 수 있습니다.

이것이 단순한 NFT가 아닌 컬렉션인지 확인하기 위해 컬렉션 세부 정보를 최대 공급량을 1로 설정하면 됩니다.

Some(CollectionDetails::V1 { size: 1 })

컬렉션 발행 주소를 central_authority 계정에 저장하면 다음과 같이 발행 명령에 전달된 컬렉션 계정을 확인할 수 있습니다.

require_keys_eq!(
*ctx.accounts.collection_mint.key,
ctx.accounts.central_authority.collection_address,
MyError::InvalidCollection
);

컬렉션 설정의 전체 코드는 다음과 같습니다.

pub fn initialize(
ctx: Context<Init>,
name: String,
symbol: String,
uri: String,
) -> Result<()> {
let bump_seed = [ctx.bumps.central_authority];
let signer_seeds: &[&[&[u8]]] = &[&[
"central_authority".as_bytes(),
&bump_seed.as_ref(),
]];
// create mint account
let cpi_context = CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
MintTo {
mint: ctx.accounts.mint.to_account_info(),
to: ctx.accounts.associated_token_account.to_account_info(),
authority: ctx.accounts.central_authority.to_account_info(),
},
signer_seeds,
);

mint_to(cpi_context, 1)?;

// create metadata account
let cpi_context = CpiContext::new_with_signer(
ctx.accounts.token_metadata_program.to_account_info(),
CreateMetadataAccountsV3 {
metadata: ctx.accounts.metadata_account.to_account_info(),
mint: ctx.accounts.mint.to_account_info(),
mint_authority: ctx.accounts.central_authority.to_account_info(),
update_authority: ctx.accounts.central_authority.to_account_info(),
payer: ctx.accounts.signer.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
rent: ctx.accounts.rent.to_account_info(),
},
signer_seeds,
);

let data_v2 = DataV2 {
name,
symbol,
uri,
seller_fee_basis_points: 0,
creators: None,
collection: None,
uses: None,
};

create_metadata_accounts_v3(
cpi_context,
data_v2,
true,
true,
Some(CollectionDetails::V1 { size: 1 }),
)?;

//create master edition account
let cpi_context = CpiContext::new_with_signer(
ctx.accounts.token_metadata_program.to_account_info(),
CreateMasterEditionV3 {
edition: ctx.accounts.master_edition_account.to_account_info(),
mint: ctx.accounts.mint.to_account_info(),
update_authority: ctx.accounts.central_authority.to_account_info(),
mint_authority: ctx.accounts.central_authority.to_account_info(),
payer: ctx.accounts.signer.to_account_info(),
metadata: ctx.accounts.metadata_account.to_account_info(),
token_program: ctx.accounts.token_program.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
rent: ctx.accounts.rent.to_account_info(),
},
signer_seeds,
);

create_master_edition_v3(cpi_context, Some(0))?;

ctx.accounts.central_authority.collection_address = ctx.accounts.mint.key();
Ok(())
}

머클 트리 생성

머클 트리를 생성하기 위해 메타플렉스 버블검 라이브러리에서 다음을 사용합니다.

instructions::CreateTreeConfigCpiBuilder

그러나 체인에서 CPI 호출을 사용하여 머클 트리를 성공적으로 생성하기 전에 클라이언트 측에서 해결해야 할 함정이 한 가지 있습니다.

머클 트리를 초기화하려면 먼저 필요한 공간을 온체인에 미리 할당해야 합니다. 일반적으로 내부 명령어는 최대 10KB의 공간을 할당할 수 있고 일반적으로 사용되는 대부분의 트리에는 그 이상의 공간이 필요하기 때문에 온체인 프로그램에서는 이 작업을 수행할 수 없습니다. 그러면 다음과 같은 오류가 발생합니다.

SystemProgram::CreateAccount data size limited to 10240 in inner instructions

다행히도 자바스크립트 @solana/spl-account-compressio library는 필요한 공간을 미리 할당하는 인스트럭션을 생성하는 도우미 함수를 제공합니다.

createAllocTreeIx

이 글의 맨 아래 깃허브 리포지토리에서 테스트에 사용된 예제를 확인할 수 있습니다. 공간을 미리 할당하고 나면 트리 생성은 매우 간단합니다.

머클 트리 계정 구조 및 상수(constants)

저는 제 프로그램에서 생성된 모든 트리의 크기가 같아야 한다고 결정했습니다. 다른 크기가 필요하거나 여러 크기를 허용하려는 경우를 위해 적절한 계정 크기를 계산하는 코드 조각을 포함했습니다. 트리를 만들 때 캐노피(canopy) 깊이는 지정되지 않고 대신 계정 크기에서 파생된다는 점에 유의하기를 바랍니다.

// The program will support only trees of the following parameters:
const MAX_TREE_DEPTH: u32 = 14;
const MAX_TREE_BUFFER_SIZE: u32 = 64;
// this corresponds to account with a canopy depth 11.
// If you need the tree parameters to be dynamic, you can use the following function:
// fn tree_bytes_size() -> usize {
// const CONCURRENT_MERKLE_TREE_HEADER_SIZE_V1: usize = 2 + 54;
// let merkle_tree_size = size_of::<ConcurrentMerkleTree<14, 64>>();
// msg!("merkle tree size: {}", merkle_tree_size);
// let canopy_size = ((2 << 9) - 2) * 32;
// msg!("canopy size: {}", canopy_size);
// CONCURRENT_MERKLE_TREE_HEADER_SIZE_V1 + merkle_tree_size + (canopy_size as usize)
// }
const REQUIRED_TREE_ACCOUNT_SIZE: usize = 162_808;

#[derive(Accounts)]
pub struct MerkleTree<'info> {
#[account(mut, signer)]
pub payer: Signer<'info>,

#[account(
seeds = [b"central_authority"],
bump,
mut
)]
pub central_authority: Account<'info, CentralStateData>,

/// CHECK: This account must be all zeros
#[account(zero, signer)]
pub merkle_tree: AccountInfo<'info>,

/// CHECK: This account is checked in the instruction
#[account(mut)]
pub tree_config: UncheckedAccount<'info>,

// program
pub bubblegum_program: Program<'info, MplBubblegum>,
pub system_program: Program<'info, System>,
pub log_wrapper: Program<'info, Noop>,
pub compression_program: Program<'info, SplAccountCompression>,
}

머클 트리 생성 코드

여러분은 이 명령어가 누구나 접근 가능하다는 것을 알아챘을 것입니다. 하지만 이 명령어는 현재 사용 중인 머클 트리를 빈 머클 트리로 교체하는 작업만 수행합니다. 따라서 보안상의 위험은 없습니다.

실제 시나리오에서는 현재 사용 중인 머클 트리의 남은 용량을 모니터링하고 공간이 부족해지면 이 명령어를 호출하여 새 머클 트리로 교체해야 한다는 점에 유의하세요.

pub fn initialize_tree<'info>(ctx: Context<'_, '_, '_, 'info, MerkleTree<'info>>) -> Result<()> {
msg!("initializing merkle tree");
require_eq!(ctx.accounts.merkle_tree.data.borrow().len(), REQUIRED_TREE_ACCOUNT_SIZE, MyError::UnsupportedTreeAccountSize);
let bump_seed = [ctx.bumps.central_authority];
let signer_seeds: &[&[&[u8]]] = &[&[
"central_authority".as_bytes(),
&bump_seed.as_ref(),
]];

CreateTreeConfigCpiBuilder::new(
&ctx.accounts.bubblegum_program.to_account_info(),
)
.tree_config(&ctx.accounts.tree_config.to_account_info())
.merkle_tree(&ctx.accounts.merkle_tree.to_account_info())
.payer(&ctx.accounts.payer.to_account_info())
.tree_creator(&ctx.accounts.central_authority.to_account_info())
.log_wrapper(&ctx.accounts.log_wrapper.to_account_info())
.compression_program(&ctx.accounts.compression_program.to_account_info())
.system_program(&ctx.accounts.system_program.to_account_info())
.max_depth(MAX_TREE_DEPTH)
.max_buffer_size(MAX_TREE_BUFFER_SIZE)
.invoke_signed(signer_seeds)?;

ctx.accounts.central_authority.merkle_tree_address = Some(ctx.accounts.merkle_tree.key());
Ok(())
}

마치며

전체 앵커 프로그램 예제는 깃허브 리포지토리에 올려두었습니다.

깃허브에서는 완벽하게 작동하는 온체인 프로그램 코드와 테스트에서 자바스크립트 사용 예시를 확인할 수 있습니다.

자유롭게 확인하시고 발견한 문제를 지적하거나 모두를 위해 코드를 개선할 수 있도록 PR을 만들어 주세요.

--

--

액세스 프로토콜
Access Protocol

전세계 모든 디지털 콘텐츠 크리에이터를 위한 새로운 수익 창출 레이어 https://accessprotocol.co