NOVO: Helius adquire a Light Protocol
compressão na Solana
Blog/Fundamentos

Tudo o que você precisa saber sobre compressão na Solana

Developer Experience Engineer0xIchigo no X0xIchigo no LinkedIn0xIchigo no GitHub
32 min de leitura

Sobre o que é este artigo?

Você acreditaria se eu dissesse que é possível criar um milhão de NFTs agora mesmo por menos de US$ 150? Absurdo! Dependendo da blockchain, criar tantos NFTs custaria mais de um milhão de dólares! Não custaria?

A compressão de estado é um novo recurso primitivo que aproveita as árvores de Merkle e o ledger da Solana para reduzir drasticamente os custos de armazenamento, ao mesmo tempo que herda a segurança e a descentralização da camada base da Solana. Este artigo foi elaborado como uma análise abrangente e aprofundada da compressão na Solana. Ele aborda desde equívocos comuns até a transferência de NFTs comprimidos. Se você quer aprender sobre compressão de estado e também como buscar, criar ou transferir NFTs comprimidos, este é o único artigo de que precisará para começar.

Este artigo pressupõe que você já leu nosso artigo Introdução às ferramentas criptográficas — Entenda as funções hash e as árvores de Merkle. É importante lê-lo antes deste artigo, pois pressupomos que você já conheça as árvores de Merkle. Este artigo também amplia o conteúdo sobre árvores de Merkle concorrentes e se aprofunda no dimensionamento e na criação delas.

Este artigo usa o Bubblegum SDK e o Umi para demonstrar as diferentes abordagens de criação de árvores de Merkle concorrentes, bem como de criação e transferência de NFTs comprimidos. Ter familiaridade com as duas ferramentas é útil, pois você provavelmente encontrará ambas em diferentes bases de código. O Bubblegum SDK foi incluído especificamente para facilitar o aprendizado, já que seu fluxo de trabalho torna os mecanismos subjacentes mais transparentes, enquanto o Umi oferece um fluxo mais conciso que simplifica esses processos.

Equívocos comuns

Precisamos esclarecer alguns pontos antes de nos aprofundarmos na compressão de estado e nas particularidades dos NFTs comprimidos:

A compressão na Solana é igual à compressão tradicional

Isso é falso. Tradicionalmente, a compressão é usada para reduzir o tamanho de arquivos e dados. Seu principal objetivo é armazenar ou transmitir dados usando menos bits do que o arquivo original. Existem dois tipos gerais de algoritmos de compressão:

  • Compressão sem perdas, em que os dados originais podem ser reconstruídos a partir dos dados comprimidos
  • Compressão com perdas, em que informações “menos importantes” são removidas para reduzir o tamanho do arquivo

Um NFT comprimido não é um NFT que passou por algum tipo de algoritmo de compressão com ou sem perdas para reduzir seus dados. Também não se trata de reduzir a qualidade ou as dimensões da arte, da música ou dos metadados associados ao NFT. No contexto da Solana, o conceito assume uma forma completamente diferente. Trata-se de otimizar como o ledger subjacente da blockchain armazena as informações relacionadas a esse NFT. No contexto de uma conta, nós a comprimimos no ledger agregando várias contas — neste caso, NFTs — em uma única raiz de Merkle armazenada no estado. Esse processo reduz significativamente os custos de armazenamento e mantém a verificabilidade.

Armazenar dados comprimidos off-chain é arriscado e gera vulnerabilidades

Isso está errado: você pode armazenar dados off-chain com segurança aplicando um hash a eles e armazenando sua raiz de Merkle on-chain. Tecnicamente, NFTs comprimidos não são armazenados off-chain. Os dados continuam on-chain, pois tudo o que pode ser novamente derivado pelo ledger é considerado on-chain. A diferença é que há incentivos no estado para que as contas sejam mantidas na memória pelos validadores, enquanto o ledger precisa ser acessado por meio de nós de arquivamento. A compressão de estado combina os dois para permitir a verificação dos dados do ledger por meio do estado em uma conta, mantendo a segurança e a descentralização da própria Solana. Explicaremos o que é o ledger e por que ele é seguro em outra seção.

Posso perder minha árvore de Merkle concorrente se o indexador ou provedor de RPC que uso para armazená-la ficar indisponível

Você não perderá sua árvore: qualquer pessoa com acesso ao ledger pode reconstruí-la por completo reproduzindo o histórico da árvore.

Árvores de Merkle concorrentes aceitam atualizações em paralelo

Um equívoco comum é pensar que a palavra “concorrente” significa que várias atualizações de uma árvore de Merkle on-chain podem ocorrer em paralelo. Embora árvores de Merkle concorrentes possam aceitar várias substituições de folhas no mesmo bloco, essas atualizações são processadas sequencialmente pelos validadores. Quando um validador recebe um lote de transações que afetam uma árvore de Merkle concorrente on-chain, ele pode processá-las no mesmo slot. No entanto, os dados de cada slot não são produzidos de forma concorrente. Abordaremos isso em mais detalhes na próxima seção, O que é compressão de estado?

Uma árvore é a mesma coisa que uma coleção

Árvores de Merkle concorrentes não são a mesma coisa que uma coleção. Uma única coleção pode usar qualquer número de árvores de Merkle concorrentes. É importante observar que os agrupamentos de NFTs podem ser independentes de seu armazenamento. Os NFTs podem estar em contas ou comprimidos no ledger, distribuídos entre uma ou várias árvores. Ainda assim, recomenda-se que cada árvore de Merkle concorrente seja usada para apenas uma coleção, reduzindo a complexidade.

O que é compressão de estado?

A compressão de estado otimiza o armazenamento criando um hash criptográfico dos dados do ledger e armazenando esse hash em uma conta. Essa abordagem aproveita a segurança e a imutabilidade inerentes ao ledger, além de oferecer uma estrutura robusta para verificar os dados nele armazenados.

Essa é uma solução econômica para aplicações criadas na Solana. Agora, os desenvolvedores podem usar o espaço de armazenamento do ledger em vez do armazenamento mais caro baseado em contas. Assim, além de garantir a integridade dos dados, a compressão de estado também oferece uma solução econômica para a alocação de recursos na Solana.

O segredo por trás da compressão de estado da Solana é o uso de árvores de Merkle concorrentes. Elas são otimizadas para processar várias transações em rápida sucessão, permitindo que suas provas avancem rapidamente. Isso é diferente das árvores de Merkle tradicionais, cujas provas são invalidadas a cada atualização. Árvores de Merkle concorrentes armazenam um registro seguro das alterações mais recentes, junto com o hash da raiz e a prova necessária para derivá-lo. Esse registro de alterações é armazenado on-chain em uma conta dedicada à árvore. Cada árvore de Merkle concorrente tem um tamanho máximo de buffer. Esse valor representa o maior número de alterações que podem ser feitas na árvore enquanto sua raiz de Merkle ainda é válida. Pense nisso como o quanto um conjunto calculado de provas pode ficar “desatualizado” antes de precisar ser atualizado.

Assim, quando um validador recebe várias solicitações para atualizar uma árvore de Merkle on-chain no mesmo slot, ele pode usar o registro de alterações da árvore como fonte da verdade. Isso permite um número de alterações concorrentes na árvore de Merkle limitado ao tamanho máximo do buffer. Embora isso não reduza diretamente a quantidade de dados armazenados on-chain, melhora a eficiência ao permitir o processamento simultâneo de várias atualizações. Dessa forma, o sistema consegue preservar a integridade da “prova de inclusão” oferecida pelas árvores de Merkle, mesmo em um ambiente de alta capacidade de processamento. Aqui, prova de inclusão significa simplesmente a capacidade de demonstrar que um elemento de dados específico realmente faz parte de um conjunto de dados combinados por hash em uma raiz de Merkle.

Essa combinação engenhosa de compressão de estado e árvores de Merkle concorrentes oferece uma solução extremamente econômica para aplicações criadas na Solana. Para entender plenamente o impacto dessas tecnologias, é essencial explicar a diferença entre o estado da Solana e seu ledger.

Estado vs. ledger

O ledger é um registro histórico de todas as transações assinadas por clientes que ocorreram na Solana desde seu bloco gênese. É uma estrutura de dados somente para anexação, o que significa que uma transação não pode ser modificada nem removida depois de adicionada. Os validadores verificam as transações adicionadas ao ledger. O ledger é armazenado por vários nós da rede para garantir tolerância a falhas. No entanto, a cópia do ledger mantida por um validador pode conter apenas os blocos mais recentes para reduzir o armazenamento, pois blocos antigos não são necessários para validar blocos futuros.

O estado representa o snapshot atual de todas as contas e programas na Solana. O estado é mutável e muda quando as transações são processadas. Pense nele como um banco de dados altamente otimizado que pode ser consultado para obter saldos de tokens, programas e contas.

Esta é uma maneira fácil de diferenciar os dois: suponha que Alice tenha um saldo de 100 SOL e Bob também tenha 100 SOL. Alice envia uma transação para transferir 10 SOL a Bob. Depois de verificada, a transação é adicionada a um bloco, e o bloco é anexado ao ledger. Agora, o ledger contém um registro imutável indicando que Alice enviou 10 SOL a Bob. Ao mesmo tempo, o estado atualizaria as contas de Alice e Bob para 90 e 110 SOL, respectivamente.

As principais diferenças entre os dois podem ser resumidas da seguinte forma:

  • O ledger é imutável e somente para anexação, enquanto o estado é mutável e muda constantemente
  • O ledger é um registro histórico de todas as transações, enquanto o estado reflete a situação atual de todas as contas e programas
  • O ledger é usado para verificação, enquanto o estado é usado para executar transações e programas

Enquanto o ledger funciona como um registro histórico imutável para garantir que cada transação possa ser verificada e rastreada, o estado funciona como um snapshot dinâmico do ledger, ajustando-se a operações em tempo real, como transferências e execução de programas. É importante destacar que ambos estão sujeitos ao consenso da própria blockchain. Juntos, o estado e o ledger formam a base da Solana, permitindo sua operação eficiente sem abrir mão da confiança descentralizada.

O que são NFTs comprimidos?

NFTs comprimidos (cNFTs) usam compressão de estado e árvores de Merkle concorrentes para reduzir os custos de armazenamento. Eles armazenam seus metadados no ledger, em vez de armazenar cada NFT em uma conta típica da Solana. Isso reduz os custos de armazenamento e, ao mesmo tempo, herda a segurança e a imutabilidade do ledger.

Os NFTs comprimidos seguem exatamente o mesmo esquema de metadados que seus equivalentes não comprimidos. Portanto, NFTs e cNFTs são definidos da mesma forma.

As principais diferenças entre NFTs e cNFTs são:

  • Um NFT comprimido pode ser convertido em um NFT comum, mas um NFT comum não pode ser convertido em um NFT comprimido
  • NFTs comprimidos não são tokens nativos da Solana: eles não têm uma conta de token, conta de criação ou metadados. No entanto, têm um identificador estável, o ID do ativo. Após a descompressão, o NFT mantém o mesmo identificador. Portanto, NFTs em seu estado comprimido não são tokens nativos, mas podem ser transformados neles, se necessário
  • Uma única conta de árvore de Merkle concorrente pode armazenar milhões de NFTs
  • Uma única coleção pode abranger várias contas de árvore
  • Todas as modificações de NFTs ocorrem por meio do programa Bubblegum
  • Recomenda-se uma chamada à DAS API para ler qualquer informação sobre um NFT comprimido

Curiosamente, precisamos usar a DAS API para obter informações sobre um NFT comprimido. Por quê? E, mais importante, o que é isso?

Como ler os metadados de NFTs comprimidos com a DAS API

Precisamos da ajuda de indexadores porque os metadados de um cNFT são armazenados no ledger, e não em uma conta tradicional. Embora seja possível derivar o estado atual de um NFT comprimido reproduzindo as transações relevantes, provedores como a Helius fazem isso para sua conveniência. Os desenvolvedores podem usar a Digital Asset Standard (DAS) API, uma especificação e um sistema de código aberto para buscar as informações de um ativo. A DAS API oferece suporte a NFTs comprimidos e tradicionais, ou não comprimidos. Portanto, você pode usar o mesmo endpoint para os dois tipos de NFT.

Atualmente, a Helius oferece suporte aos seguintes métodos da DAS API:

  • getAsset - obtém um ativo específico por seu ID
  • getAssetBatch - obtém vários ativos por seus IDs
  • getAssetProof - obtém uma prova de Merkle de um ativo comprimido por seu ID
  • getAssetProofBatch - obtém várias provas de ativos por seus IDs
  • getAssetsByOwner - obtém uma lista de ativos pertencentes a um endereço
  • getAssetsByAuthority - obtém uma lista de ativos com uma autoridade específica
  • getAssetsByCreator - obtém uma lista de ativos criados por um endereço
  • getAssetsByGroup - obtém uma lista de ativos por chave e valor de grupo
  • searchAssets - busca ativos usando diversos parâmetros
  • getSignaturesForAsset - obtém uma lista de assinaturas de transações relacionadas a um ativo comprimido
  • Paginação - suporte à paginação baseada em páginas e conjuntos de chaves para buscar mais de 1.000 registros por vez

Consulte a documentação da DAS API da Helius para saber mais sobre cada método. Por exemplo, se você quiser buscar uma lista de todos os ativos pertencentes a um endereço, poderá fazer a seguinte solicitação POST com getAssetsByOwner:

Código
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();

É conveniente recuperar ativos comprimidos, mas e se quisermos criar os nossos? Antes de iniciar nossa jornada de criação, é essencial calcular o tamanho e o custo associado à construção de uma árvore de Merkle concorrente que armazenará esses ativos.

Dimensionamento e custos da criação de uma árvore de Merkle concorrente

Como calcular o tamanho

Ao criar uma árvore de Merkle concorrente on-chain, existem três métricas importantes que determinam o tamanho da árvore, o custo para criá-la e o número de alterações concorrentes que podem ser feitas enquanto a raiz de Merkle continua válida:

  • Profundidade máxima
  • Tamanho máximo do buffer
  • Profundidade do canopy

A profundidade máxima se refere ao número máximo de saltos necessários para ir de qualquer folha até a raiz da árvore. Cada folha está conectada a apenas uma outra folha, formando um par de folhas para a aplicação de hash em pares. Você pode calcular o número máximo de nós folha que uma árvore comporta usando a fórmula: numberOfNodes = 2 ^ maxDepth. A profundidade da árvore deve ser definida na criação. Portanto, você precisa usar essa fórmula para determinar a menor profundidade máxima possível para armazenar seus dados. Por exemplo, se você pretende armazenar cerca de 100 NFTs comprimidos em uma árvore, um maxDepth de 7 seria suficiente, pois 2^7 = 128 e 2^6 = 64. A profundidade máxima é um fator significativo no custo de construção de uma árvore de Merkle concorrente on-chain. Esses custos são pagos antecipadamente durante a criação da árvore e aumentam conforme o valor de maxDepth cresce.

O tamanho máximo do buffer se refere ao número máximo de alterações que podem ocorrer em uma árvore enquanto sua raiz de Merkle continua válida. O buffer do registro de alterações é dimensionado e definido no momento da criação de árvores de Merkle concorrentes usando o valor maxBufferSize. Assim, quando um validador recebe várias solicitações de alteração de uma árvore no mesmo slot, ele pode usar o registro de alterações e permitir até maxBufferSize alterações sem invalidar a raiz.

É essencial observar que existe apenas um número específico de pares válidos de maxDepth e maxBufferSize para criar uma nova conta de árvore de Merkle concorrente. O pacote @solana/spl-account-compression exporta a constante ALL_DEPTH_SIZE_PAIRS, que é um array de arrays numéricos contendo todas as combinações válidas. O mínimo é um maxDepth de 3 e um maxBufferSize de 8, enquanto o máximo é um maxDepth de 30 e um maxBufferSize de 2048.

A profundidade do canopy se refere a um subconjunto da árvore de Merkle armazenado em uma conta. Essas provas armazenadas em cache são usadas para complementar as provas transmitidas pela rede, pois elas estão sujeitas aos limites das transações. O caminho completo precisa ser usado para verificar a propriedade original de uma folha ao tentar alterar seus dados, como ao transferir um NFT. Quanto maior a profundidade máxima da árvore, mais nós de prova são necessários para a verificação. O canopy permite reduzir o tamanho da prova e evita usar uma prova de tamanho maxDepth para verificar a árvore.

A profundidade do canopy pode ser calculada subtraindo o tamanho desejado da prova da profundidade máxima. Assim, se você tiver uma profundidade máxima de 14 e quiser uma prova de tamanho 4, terá uma profundidade de canopy de 10. Isso significa que você só precisaria enviar 4 nós de prova por transação de atualização. A profundidade do canopy também é um fator significativo no custo de construção de uma árvore de Merkle concorrente on-chain. Esses custos são pagos antecipadamente durante a criação da árvore e aumentam conforme o valor de canopyDepth cresce. Embora um canopyDepth menor resulte em um custo inicial mais baixo, um canopyDepth baixo pode limitar a capacidade de composição. Isso ocorre porque cada transação de atualização exigirá uma prova maior, impondo restrições devido aos limites de tamanho das transações. Por exemplo, se sua árvore com um canopyDepth baixo for usada para NFTs comprimidos, um marketplace de NFTs talvez consiga oferecer apenas transferências simples para sua coleção. Em geral, maxDepth - canopyDepth deve ser menor ou igual a 10 para maximizar a capacidade de composição. Isso é descrito na especificação da Tensor sobre o tamanho máximo das provas de cNFTs da Tensor.

Como calcular os custos

Existem diferentes métodos para determinar o tamanho e o custo de uma árvore de Merkle concorrente. A abordagem mais simples é usar a Calculadora de NFTs comprimidos e inserir o número de NFTs comprimidos que você pretende armazenar na árvore:

O site oferece uma análise detalhada da profundidade ideal da árvore para armazenar o número desejado de ativos, além de várias opções de custo baseadas na capacidade de composição. A imagem, por exemplo, mostra que criar uma árvore altamente componível capaz de armazenar 10 milhões de NFTs comprimidos custaria apenas ~7,67 SOL. Considerando os custos de transação de ~50 SOL para criar 10 milhões de NFTs, o custo total seria de aproximadamente ~57,67 SOL.

Os desenvolvedores também podem usar o pacote @solana/spl-account-compression para calcular o espaço necessário para uma árvore de determinado tamanho e o custo de alocar esse espaço para a árvore on-chain. Isso pode ser feito com o seguinte script:

Código
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();

Aqui, importamos os módulos necessários de @solana/web3.js e @solana/spl-account-compression. Precisamos de uma conexão com a mainnet, que podemos estabelecer usando uma chave de API da Helius. A função calculateCosts registra no console o maxDepth, o maxBufferSize, o canopy, o número de NFTs que poderiam ser armazenados nessa árvore e o custo de rent em SOL. Assim, quando chamamos calculateCosts usando o tamanho desejado da prova, podemos ver no console todas as combinações possíveis de árvores.

Observe que alguns registros podem exibir: Não foi possível buscar o saldo mínimo para isenção de aluguel. Isso ocorre porque a conta com o maxProofSize especificado seria grande demais para ser criada e, portanto, não podemos buscar um saldo mínimo que torne a conta isenta de aluguel.

Criando uma árvore de Merkle concorrente

Precisamos criar duas contas ao criar uma árvore de Merkle concorrente:

  • Uma conta de árvore de Merkle concorrente
  • Uma conta de configuração da árvore de Merkle concorrente

A conta da árvore contém a árvore de Merkle usada para verificar dados. Nós a criamos usando a profundidade máxima, o tamanho máximo do buffer e a profundidade do canopy desejados, conforme mencionado na seção anterior. Essa conta pertence ao programa Account Compression, criado e mantido pela Solana. Ela é usada para verificar a autenticidade de NFTs compactados.

A conta de configuração da árvore é um PDA derivado do endereço da conta da árvore de Merkle concorrente. Ela é usada para armazenar configurações adicionais, como o criador da árvore e o número de NFTs compactados emitidos.

A Metaplex chama as árvores de Merkle concorrentes com uma conta de configuração de árvore associada de “árvore Bubblegum”.

Código completo

Código
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}`);
    }
}

Detalhamento do código

Esta é uma função de exemplo para criar uma árvore de Merkle concorrente na Solana. Para invocar essa função de exemplo, createTree, os seguintes parâmetros devem ser passados:

  • connection - uma conexão com um endpoint JSON RPC de um nó completo, do tipo Connection
  • payer - a conta que pagará pela transação, do tipo Keypair
  • treeKeypair - o endereço do par de chaves da árvore, do tipo Keypair
  • maxDepthSizePair - o par válido de maxDepth e maxBufferSize, do tipo ValidDepthSizePair
  • canopyDepth - a profundidade do canopy da árvore, do tipo number e definida com o valor padrão 0
Código
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";

Primeiro, importamos @solana/web3.js, @solana/spl-account-compression e @metaplex-foundation/mpl-bubblegum com os módulos necessários.

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

Aqui, definimos a função createTree com os parâmetros mencionados anteriormente.

Código
const allocTreeInstruction = await createAllocTreeIx(
		connection,
    treeKeypair.publicKey,
    payer.publicKey,
    maxDepthSizePair,
    canopyDepth,
);

createAllocTreeIx é uma função auxiliar usada para criar a conta da árvore de Merkle concorrente. O pacote SPL Account Compression recomenda usar esse método para inicializar uma conta de árvore de Merkle concorrente porque essas contas costumam ser muito grandes e podem exceder o limite do que pode ser alocado via CPI. Aqui, criamos a instrução para alocar a conta da árvore on-chain. Isso também calcula o espaço necessário para armazenar a árvore on-chain e o custo, portanto não precisamos nos preocupar com isso depois.

Código
const [treeAuthority, ] = PublicKey.findProgramAddressSync(
    [treeKeypair.publicKey.toBuffer()],
		PROGRAM_ID,
);

Precisamos derivar a conta de configuração da árvore com sua autoridade pertencente ao programa Bubblegum. Isso é necessário para createCreateTreeInstruction, a instrução que cria a árvore, pois precisamos passar treeAuthority como argumento. Aqui, derivamos o PDA com o método findProgramAddressSync usando a chave pública da árvore e o ID do programa Bubblegum. Precisamos desestruturar treeAuthority, pois tanto a autoridade quanto o bump são retornados. Omiti o bump porque ele não é necessário para nossa função. Se necessário, altere a desestruturação para [treeAuthority, bump] para salvar o bump.

Código
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,
);

Usamos createCreateTreeInstruction do SDK Bubblegum para montar a instrução que cria a árvore de Merkle concorrente. Isso cria a árvore on-chain, tendo o programa Bubblegum como proprietário. createCreateTreeInstruction tem três parâmetros. O primeiro é um objeto que contém contas para configurar propriedades como o criador da árvore. O segundo objeto corresponde à profundidade máxima e aos tamanhos máximos do buffer. Ele também inclui um parâmetro public do tipo boolean. Definir public como true permitirá que qualquer pessoa emita NFTs compactados a partir da árvore. Caso contrário, somente o criador ou o delegado da árvore poderá emitir NFTs compactados a partir dela. Uma conta delegada pode realizar ações em nome do proprietário da árvore, como transferir ou queimar um NFT compactado. Como observação, você pode atribuir um delegado da árvore usando createSetTreeDelegateInstruction do pacote @metaplex-foundation/mpl-bubblegum desta forma:

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

Também passamos o ID do programa Bubblegum. Agora, voltando ao restante do código:

Código
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}`);
}

Adicionamos as duas instruções que acabamos de criar a uma transação e a enviamos. Garantimos que treeKeypair e payer assinem a transação. Em seguida, a assinatura da transação concluída é registrada no console. Envolvemos esse processo em um bloco try-catch para que, se ocorrer algum erro por qualquer motivo, ele seja registrado no console via console.error.

Criando uma árvore de Merkle concorrente com Umi

Usar o SDK Bubblegum, o programa de compactação de contas da Solana e os pacotes web3.js da Solana pode ser bastante confuso para desenvolvedores iniciantes e trabalhoso de configurar a cada vez. Felizmente, o SDK Bubblegum oferece uma operação createTree que cuida de tudo para nós e funciona muito bem com Umi. O código é o seguinte:

Código
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 é um framework modular para criar e usar clientes JavaScript para programas da Solana. Ele oferece uma biblioteca sem dependências, com um conjunto de interfaces principais que outras bibliotecas podem usar sem ficarem limitadas a uma implementação específica. Umi é fornecido pela Metaplex, e sua documentação está disponível aqui.

Usamos nossa instância do Umi para gerar um signatário, criar nossa árvore de Merkle e enviar e confirmar a transação montada. Por padrão, o criador da árvore é definido como a identidade do Umi e o parâmetro public é definido como false. Esses parâmetros podem ser personalizados, permitindo também passar um criador de árvore personalizado e um valor público true. Essa é uma forma muito mais rápida de criar uma árvore de Merkle concorrente on-chain.

Observe que Bubblegum não depende do tamanho do canopy. Isso ocorre porque o programa Account Compression da Solana determinará o tamanho do canopy com base no espaço disponível na conta. Basta alocar espaço suficiente para que o programa possa identificar corretamente o tamanho de canopy adequado.

Emitindo cNFTs por meio da interação direta com Bubblegum

Criando uma coleção

Tradicionalmente, os NFTs são agrupados em uma coleção usando o padrão Metaplex. Isso vale tanto para NFTs compactados quanto para NFTs “comuns”. Para criar uma coleção:

  • Crie uma nova “mint” de token
  • Crie uma conta de token associada para a mint
  • Emita um único token
  • Armazene os metadados da coleção em uma conta on-chain

Embora não esteja diretamente relacionado ao tema da compactação de estado ou dos NFTs compactados e, portanto, esteja fora do escopo deste artigo, disponibilizamos um script como referência para criar sua própria coleção. Você pode acessar esse script aqui.

Emitindo um NFT para nossa coleção

Com sua coleção recém-criada, você precisará do seguinte para começar a emitir:

  • collectionMint - o endereço de mint da coleção
  • collectionAuthority - a conta com autoridade sobre a coleção
  • collectionMetadata - a conta de metadados da coleção
  • editionAccount - a conta que contém atributos adicionais, como uma conta de edição mestre

Código completo para emitir em uma coleção

Código
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}`);
  }
}

Detalhamento do processo de emissão

Código
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";

Primeiro, importamos @solana/web3.js, @solana/spl-account-compression, @metaplex-foundation/mpl-bubblegum e @metaplex-foundation/mpl-token-metadata com os módulos necessários.

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

Definimos mintCompressedNFT, que recebe vários parâmetros:

  • connection - o objeto de conexão usado para interagir com a Solana
  • payer - a conta que pagará as taxas da transação
  • treeAddress - a conta da árvore de Merkle concorrente
  • collectionMint - o endereço de mint da coleção
  • collectionMetadata - a conta de metadados da coleção
  • collectionMasterEditionAccount - a conta de edição mestre
  • compressedNFTMetadata - os metadados específicos do cNFT a ser emitido
  • receiverAddress - um endereço de chave pública opcional para o qual o cNFT recém-emitido será enviado
Código
const [treeAuthority, ] = PublicKey.findProgramAddressSync([treeAddress.toBuffer()], BUBBLEGUM_PROGRAM_ID);

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

Aqui, encontramos os PDAs necessários e ignoramos seus bumps. Primeiro, derivamos o PDA da autoridade da árvore e, depois, derivamos um PDA para atuar como signatário da emissão compactada. Precisamos incluir collection_cpi, pois ele é um prefixo personalizado exigido pelo programa Bubblegum.

Código
const mintInstructions: TransactionInstruction[] = [];

Definimos mintInstructions como um array vazio de TransactionInstruction. Isso nos permite emitir vários cNFTs ao mesmo tempo, se desejado.

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

metadataArgs garante que compressedNFTMetadata esteja formatado corretamente. Para que a transação de emissão de um NFT em uma coleção usando createMintToCollectionV1Instruction seja concluída, o campo verified precisa ser definido como false, apesar de a coleção ser verificada automaticamente.

Código
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,
      }
  	)
);

Adicionamos uma única emissão à nossa instrução. Poderíamos adicionar várias emissões à mesma transação, desde que ela permaneça dentro dos limites de tamanho em bytes. Aqui, usamos createMintToCollectionV1Instruction para emitir nosso NFT compactado a partir da coleção. Essa instrução recebe dois objetos: um com as contas necessárias para processar a instrução e outro para fornecer os dados da instrução ao programa. A maioria desses parâmetros deve ser familiar das seções anteriores. Observe que você pode definir qualquer endereço de delegado na emissão, mas normalmente ele deve ser igual a leafOwner. De qualquer forma, o delegado é removido automaticamente quando o cNFT é transferido. Definimos o pagador como delegado, pois ele também receberá o cNFT caso receiverAddress não seja fornecido.

Código
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}`);
  }

Em seguida, montamos nossa transação, definimos payer como feePayer e enviamos a transação. Envolvemos essa lógica em um bloco try-catch para lidar com possíveis erros no envio e na confirmação da transação. Se ocorrer algum erro, ele será registrado no console com console.error.

Emitindo cNFTs com Umi

O programa Bubblegum oferece dois processos de emissão via Umi:

  • Emitir um NFT sem associá-lo a uma coleção
  • Emitir um NFT para uma determinada coleção.

Emitindo sem uma coleção

A instrução MintV1 do Bubblegum permite emitir NFTs compactados a partir de uma árvore Bubblegum sem uma coleção. Se a árvore for pública, qualquer pessoa poderá emitir nela. Caso contrário, somente o criador ou o delegado da árvore poderá usar essa instrução. Veja como emitir um NFT compactado sem uma coleção:

Código
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);

Este trecho de código vem da documentação da Metaplex sobre a emissão de cNFTs com Bubblegum. Aqui, usamos uma instância do Umi para emitir um cNFT. Os outros parâmetros da instrução mintV1 são os seguintes:

  • leafOwner é o proprietário do cNFT a ser emitido
  • merkleTree é o endereço da conta da árvore de Merkle concorrente a partir da qual o cNFT será emitido
  • metadata é um objeto que contém os metadados do cNFT a ser emitido. Isso inclui o nome do cNFT, seu URI, sua coleção, que definimos como none, e seus criadores. É possível fornecer um objeto de coleção, mas definir o campo verified nos criadores como false, pois a autoridade da coleção não é solicitada na instrução. Os criadores também podem verificar a si próprios definindo o campo verified como true e fornecendo o criador como signatário nas contas restantes.

A instrução mintV1 também contém vários campos opcionais, pois a entrada da função é do tipo MintV1InstructionAccounts & MintV1InstructionArgs. Esses tipos são definidos da seguinte forma:

Código
// 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 é um tipo difícil de localizar entre os tipos que, em essência, é um objeto com um campo metadata. Esse campo metadata é do tipo MetadataArgsArgs e é definido da seguinte forma:

Código
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;
};

A definição completa da função mintV1 e todos os tipos associados pode ser encontrada aqui. No mínimo, com uma instância do Umi, você pode emitir um cNFT sem uma coleção ao fornecer os metadados necessários, o proprietário da folha e a conta da árvore de Merkle concorrente.

Emitindo com uma coleção

Bubblegum oferece mintToCollectionV1 como uma forma prática de emitir diretamente um cNFT para uma determinada coleção. A entrada dessa instrução é dos tipos MintToCollectionV1InstructionAccounts e MintToCollectionV1InstructionArgs, que, no fim, correspondem a um objeto do tipo MetadataArgsArgs. A definição do tipo MintToCollectionV1InstructionAccounts é a seguinte:

Código
// 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;
};

Os principais parâmetros são a mint da coleção, a autoridade da coleção e o PDA do registro da autoridade da coleção. Um PDA de registro do delegado deve ser fornecido ao usar uma autoridade de coleção delegada, para garantir que a autoridade tenha permissão para gerenciar o NFT da coleção. O parâmetro metadata deve conter um objeto collection cujo campo address corresponda ao parâmetro de mint da coleção e cujo campo verified esteja definido como false. Os criadores também podem verificar a si próprios assinando a transação e adicionando a si próprios como contas restantes.

Veja como emitir um NFT compactado com uma coleção:

Código
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);

Este trecho de código está disponível na documentação da Metaplex sobre a emissão de cNFTs com Bubblegum. Novamente, usamos uma instância do Umi para emitir o NFT compactado. Assim como em mintV1, passamos leafOwner e merkleTree. Desta vez, porém, passamos collectionMint. No campo metadata, passamos um objeto collection cuja chave corresponde a collectionMint e cujo campo verified é definido como false. Observe que a identidade do Umi é definida como a autoridade padrão da coleção. Isso pode ser alterado definindo o campo opcional collectionAuthority como uma autoridade de coleção personalizada.

Cunhando cNFTs com a Helius

Na Helius, oferecemos uma API de cunhagem que permite cunhar NFTs compactados sem complicações adicionais. Cobrimos as taxas da Solana, a criação da árvore de Merkle e o upload dos seus metadados off-chain para a Arweave. Também garantimos que a transação tenha sido enviada corretamente e confirmada pela rede, para que você não precise fazer o polling por conta própria. Além disso, extraímos o ID do ativo da transação, permitindo que você o use imediatamente com a DAS API.

Para que a Helius possa cunhar um NFT na sua coleção, a autoridade da coleção deve ser delegada a ela. Essa autoridade deve ser delegada a uma das contas a seguir, de acordo com o seu cluster:

  • Devnet: 2LbAtCJSaHqTnP9M5QSjvAMXk79RNLusFspFN5Ew67TC
  • Mainnet: HnT5KVAywGgQDhmh6Usk4bxRg4RwKxCK4jmECyaDth5R

Veja como cunhar um cNFT usando a API de cunhagem da Helius:

Código
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();

Esse trecho de código e uma explicação mais detalhada do schema da solicitação estão disponíveis em nossa documentação.

Observe que, se você não preencher o campo uri, criaremos um arquivo JSON e faremos o upload dele para a Arweave em seu nome. O arquivo seguirá o padrão JSON v1.0 da Metaplex e será enviado pela Irys (anteriormente conhecida como Bundlr).

Transferindo cNFTs

As etapas gerais para transferir um NFT compactado são:

  • Obter os dados do ativo do cNFT no indexador
  • Obter a prova do cNFT no indexador
  • Obter a conta da árvore de Merkle concorrente na Solana
  • Preparar a prova do ativo
  • Criar e enviar a transação de transferência

Usar Umi e Metaplex simplifica bastante esse processo, mas esta seção demonstrará o que acontece nos bastidores. A seguir, mostraremos como transferir um NFT compactado usando tanto web3.js quanto Metaplex.

Transferindo por meio de interação direta com o Bubblegum

Precisamos buscar algumas informações sobre nosso NFT compactado antes de usar nosso script para executar a transferência. Primeiro, precisamos usar o método getAsset da DAS API para recuperar os metadados do NFT compactado. Aqui, estamos procurando data_hash, creator_hash, owner, delegate e leaf_id:

Código
// 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();

Esta é a aparência de parte da resposta bem-sucedida:

Código
{
  ...
  },
  "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",
    ...
  }
}

Quando tivermos as informações necessárias, precisaremos usar o método getAssetProof para recuperar proof e tree_id (o endereço da árvore). Veja um exemplo de chamada:

Código
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();

Esta é a aparência da resposta bem-sucedida:

Código
{
  "root": "string",
  "proof": [
    "string"
  ],
  "node_index": 0,
  "leaf": "string",
  "tree_id": "string"
}

Agora, com root, proof e tree_id, podemos passar para o nosso script de transferência.

Código completo

Código
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}`);
  }
};

Analisando o código

Código
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";

Importamos @solana/web3.js, @metaplex-foundation/mpl-bubblegum e @solana/spl-account-compression com os módulos necessários.

Código
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
}

Definimos nossa função transferCompressedNFT, na qual analisaremos o caminho da prova, criaremos a instrução de transferência e a executaremos.

Código
const treeAccount = await ConcurrentMerkleTreeAccount.fromAccountAddress(connection, treeAddress);

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

Buscamos a conta da árvore de Merkle concorrente na blockchain e extraímos a autoridade da árvore e a profundidade do canopy. Esses valores são necessários para criar a instrução de transferência.

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

Em termos simples, analisamos a lista de endereços de prova e a transformamos em um array válido do tipo AccountMeta. AccountMeta são os metadados da conta usados para definir transações. Isso inclui a chave pública da conta, se uma instrução exige uma assinatura de transação correspondente à chave pública e se a chave pública pode ser carregada como uma conta de leitura e gravação.

Pegamos uma parte da prova completa, começando pelo início do array, e garantimos que ela tenha apenas proof.length - canopyDepth valores de prova. Fazemos isso para remover a parte da árvore que já está armazenada em cache no canopy on-chain. Em seguida, estruturamos cada valor de prova restante como um AccountMeta válido. Isso é necessário porque a prova é enviada on-chain na forma de “contas extras” dentro da instrução de transferência.

Código
const leafOwner = new PublicKey(owner);
const leafDelegate = new PublicKey(delegate);

Em seguida, definimos leafOwner com o parâmetro owner e leafDelegate com o parâmetro delegate.

Código
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
  );

Criamos transferInstruction usando a função auxiliar createTransferInstruction do Bubblegum SDK. Observe que root, dataHash e creatorHash são retornados pela DAS API como strings. Portanto, precisamos convertê-los para o tipo PublicKey e, depois, para um array de bytes.

Código
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}`);
  }

Com a instrução criada, nós a adicionamos a uma nova transação e a enviamos para a Solana. Se houver algum erro, nós o registramos no console usando console.error.

Se você estiver encontrando erros relacionados à árvore de Merkle concorrente, é possível que seu RPC esteja fornecendo dados desatualizados ou incorretos para a prova dessa árvore. Isso pode acontecer ocasionalmente devido a problemas de cache. Para corrigir isso, você pode tentar verificar no lado do cliente a prova fornecida pelo RPC:

Código
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());

Observe que você também precisará usar o valor leaf retornado pela nossa chamada getAssetProof à DAS API. Isso não é obrigatório porque a validação real da prova é realizada on-chain. No entanto, pode ajudar no tratamento de erros.

Com isso, você pode fazer outra chamada getAsset para verificar que leafDelegate está vazio e que a folha tem um novo proprietário!

Transferindo com Umi

Código
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);

Este código vem da documentação da Metaplex sobre a transferência de NFTs compactados.

O Bubblegum fornece uma instrução transfer muito simples de usar. Primeiro, ela recebe uma instância de Umi. Depois, aceita um objeto que contém o ativo, com informações sobre sua prova, o proprietário da folha e o novo proprietário da folha. Para obter o ativo com a prova necessária, podemos usar o método getAssetWithProof, também fornecido pelo Bubblegum. Observe que o delegado da folha pode ser usado no lugar do proprietário da folha — basta uma conta com autoridade para autorizar a transferência. Com o método .sendAndConfirm(), enviamos a transação que inicia a transferência e depois a confirmamos com nossa instância de Umi.

Conclusão

Parabéns! Exploramos de forma abrangente a compactação de estado e os NFTs compactados na Solana. Navegamos pelas complexidades das árvores de Merkle concorrentes, esclarecemos equívocos comuns e mergulhamos no ledger da Solana. Deixando a teoria de lado, aprendemos a buscar, cunhar e transferir cNFTs usando o poder do web3.js da Solana, da Metaplex e da Helius!

A compactação de estado da Solana é revolucionária em um cenário no qual os custos de transação e armazenamento podem ser restritivos. A compactação reduz drasticamente os custos sem comprometer a segurança ou a descentralização. Essa é uma mudança de paradigma que abre possibilidades inéditas para artistas, colecionadores e desenvolvedores.

Se você chegou até aqui, valeu, anon! Você está bem preparado para contribuir com essa nova e empolgante fronteira. Vá em frente: cunhe uma coleção de dez milhões de NFTs para seu MMORPG on-chain, crie um aplicativo descentralizado que aproveite o poder do ledger ou simplesmente compartilhe com a comunidade o conhecimento que acabou de adquirir. A melhor maneira de prever o futuro é criá-lo.

Recursos adicionais e leituras complementares

Assine a Helius

Acompanhe as novidades mais recentes do desenvolvimento Solana e receba atualizações quando publicarmos

Imagem ampliada