NUEVO: Helius adquiere Light Protocol
compresión en Solana
Blog/Fundamentos

Todo lo que necesitas saber sobre la compresión en Solana

Developer Experience Engineer0xIchigo en X0xIchigo en LinkedIn0xIchigo en GitHub
32 min de lectura

¿De qué trata este artículo?

¿Me creerías si te dijera que ahora mismo puedes acuñar un millón de NFT por menos de $150 USD? ¡Absurdo! Dependiendo de la blockchain, acuñar tantos NFT costaría más de un millón de dólares. ¿O no?

La compresión de estado es una primitiva novedosa que aprovecha los árboles de Merkle y el libro mayor de Solana para reducir drásticamente los costos de almacenamiento, a la vez que hereda la seguridad y descentralización de la capa base de Solana. Este artículo ofrece un análisis exhaustivo de la compresión en Solana. Abarca desde conceptos erróneos comunes hasta la transferencia de NFT comprimidos. Si quieres aprender sobre la compresión de estado y cómo obtener, acuñar o transferir NFT comprimidos, este es el único artículo que necesitarás para comenzar.

Este artículo supone que ya leíste nuestro artículo Introducción a las herramientas criptográficas: explicación de las funciones hash y los árboles de Merkle. Es importante leerlo antes de continuar, ya que damos por sentado que conoces los árboles de Merkle. Este artículo también amplía el tema de los árboles de Merkle concurrentes y profundiza en cómo dimensionarlos y crearlos.

Este artículo utiliza tanto el SDK de Bubblegum como Umi para mostrar los distintos enfoques para crear árboles de Merkle concurrentes, así como para acuñar y transferir NFT comprimidos. Es útil conocer ambas herramientas, ya que probablemente las encontrarás en distintas bases de código. Incluimos el SDK de Bubblegum específicamente para facilitar el aprendizaje, pues su flujo de trabajo hace más transparentes los mecanismos subyacentes, mientras que Umi ofrece un flujo más conciso que simplifica estos procesos.

Conceptos erróneos comunes

Debemos aclarar algunos puntos antes de profundizar en la compresión de estado y las particularidades de los NFT comprimidos:

La compresión en Solana es igual que la compresión tradicional

Esto es falso. Tradicionalmente, la compresión se usa para reducir el tamaño de archivos y datos. Su objetivo principal es almacenar o transmitir datos con menos bits que el archivo original. Existen dos grandes tipos de algoritmos de compresión:

  • Compresión sin pérdida, en la que los datos originales pueden reconstruirse a partir de los datos comprimidos
  • Compresión con pérdida, en la que se elimina información “menos importante” para reducir el tamaño del archivo

Un NFT comprimido no es un NFT que haya pasado por algún algoritmo de compresión con o sin pérdida para reducir sus datos. Tampoco se trata de reducir la calidad o las dimensiones del arte, la música o los metadatos asociados con el NFT. En el contexto de Solana, el concepto adopta una forma completamente distinta. Se trata de optimizar cómo el libro mayor subyacente de la blockchain almacena la información relacionada con ese NFT. Desde la perspectiva de una cuenta, la comprimimos en el libro mayor al agrupar varias cuentas —en este caso, NFT— en una única raíz de Merkle almacenada en el estado. Este proceso reduce significativamente los costos de almacenamiento sin perder la capacidad de verificación.

Almacenar datos comprimidos fuera de la cadena es riesgoso y genera vulnerabilidades

Esto es incorrecto: puedes almacenar datos de forma segura fuera de la cadena calculando su hash y almacenando su raíz de Merkle en la cadena. Técnicamente, los NFT comprimidos no se almacenan fuera de la cadena. Los datos siguen estando en la cadena, ya que todo lo que pueda volver a derivarse a partir del libro mayor se considera parte de ella. La diferencia es que el estado incentiva que los validadores mantengan las cuentas en memoria, mientras que se debe acceder al libro mayor mediante nodos de archivo. La compresión de estado combina ambos para permitir la verificación de los datos del libro mayor mediante el estado de una cuenta, sin perder la seguridad y descentralización propias de Solana. En otra sección explicaremos qué es el libro mayor y por qué es seguro.

Puedo perder mi árbol de Merkle concurrente si deja de funcionar el indexador o proveedor de RPC que uso para almacenarlo

No perderás tu árbol: cualquier persona con acceso al libro mayor puede reconstruirlo por completo reproduciendo su historial.

Los árboles de Merkle concurrentes pueden procesar actualizaciones en paralelo

Un concepto erróneo común es asumir que la palabra “concurrente” implica que pueden realizarse en paralelo varias actualizaciones de un árbol de Merkle en la cadena. Aunque los árboles de Merkle concurrentes admiten varios reemplazos de hojas dentro del mismo bloque, los validadores procesan estas actualizaciones de forma secuencial. Cuando un validador recibe un lote de transacciones que afectan a un árbol de Merkle concurrente en la cadena, puede procesarlas en el mismo slot. Sin embargo, los datos de cada slot no se producen de forma concurrente. Profundizamos en esto en la siguiente sección: ¿Qué es la compresión de estado?

Un árbol es lo mismo que una colección

Los árboles de Merkle concurrentes no son lo mismo que una colección. Una sola colección puede usar cualquier cantidad de árboles de Merkle concurrentes. Es importante señalar que la agrupación de NFT puede ser independiente de su almacenamiento. Los NFT pueden estar en cuentas o comprimidos en el libro mayor, distribuidos entre cualquier cantidad de árboles, ya sea uno o varios. Sin embargo, se recomienda usar cada árbol de Merkle concurrente para una sola colección a fin de reducir la complejidad.

¿Qué es la compresión de estado?

La compresión de estado optimiza el almacenamiento al crear un hash criptográfico de los datos del libro mayor y almacenarlo en una cuenta. Este enfoque aprovecha la seguridad e inmutabilidad inherentes del libro mayor y, al mismo tiempo, proporciona un marco sólido para verificar los datos almacenados en él.

Es una solución rentable para las aplicaciones creadas sobre Solana. Ahora los desarrolladores pueden usar el espacio de almacenamiento del libro mayor en lugar del almacenamiento más costoso basado en cuentas. Por lo tanto, la compresión de estado no solo garantiza la integridad de los datos, sino que también ofrece una solución rentable para asignar recursos en Solana.

El secreto de la compresión de estado de Solana es el uso de árboles de Merkle concurrentes. Estos árboles están optimizados para procesar varias transacciones en rápida sucesión, de modo que sus pruebas puedan adelantarse rápidamente. Esto difiere de los árboles de Merkle tradicionales, cuyas pruebas se invalidan con cada actualización. Los árboles de Merkle concurrentes almacenan un registro seguro de sus cambios más recientes, junto con su hash raíz y la prueba necesaria para derivarlo. Este registro de cambios se almacena en la cadena, en una cuenta dedicada al árbol. Cada árbol de Merkle concurrente tiene un tamaño máximo de búfer. Este valor representa la mayor cantidad de cambios que se pueden realizar en el árbol mientras su raíz de Merkle sigue siendo válida. Puedes considerarlo como el grado de “desactualización” que puede tener un conjunto calculado de pruebas antes de que sea necesario actualizarlo.

Por lo tanto, cuando un validador recibe varias solicitudes para actualizar un árbol de Merkle en la cadena dentro del mismo slot, puede usar el registro de cambios del árbol como fuente de verdad. Esto permite realizar en el árbol de Merkle tantos cambios concurrentes como admita el tamaño máximo del búfer. Aunque esto no reduce directamente la cantidad de datos almacenados en la cadena, mejora la eficiencia al permitir que varias actualizaciones se procesen simultáneamente. Así, el sistema puede mantener la integridad de la “prueba de inclusión” que ofrecen los árboles de Merkle, incluso en un entorno de alto rendimiento. Aquí, la prueba de inclusión significa simplemente la capacidad de demostrar que un elemento de datos específico forma parte de un conjunto de datos cuyos hashes se combinaron en una raíz de Merkle.

Esta ingeniosa combinación de compresión de estado y árboles de Merkle concurrentes ofrece una solución extremadamente rentable para las aplicaciones que se desarrollan en Solana. Para comprender plenamente el impacto de estas tecnologías, es esencial analizar la diferencia entre el estado de Solana y su libro mayor.

Estado frente a libro mayor

El libro mayor es un registro histórico de todas las transacciones firmadas por clientes que se han producido en Solana desde su bloque génesis. Es una estructura de datos de solo adición, lo que significa que una transacción no se puede modificar ni eliminar después de agregarla. Los validadores verifican las transacciones que se incorporan al libro mayor. Varios nodos de la red almacenan el libro mayor para garantizar la tolerancia a fallos. Sin embargo, la copia del libro mayor de un validador puede contener solo los bloques más recientes para reducir el almacenamiento, ya que los bloques antiguos no son necesarios para validar los futuros.

El estado representa una instantánea actual de todas las cuentas y programas de Solana. Es mutable y cambia cuando se procesan transacciones. Piensa en el estado como una base de datos altamente optimizada que puedes consultar para conocer saldos de tokens, programas y cuentas.

Esta es una forma sencilla de diferenciarlos: supongamos que Alice tiene un saldo de 100 SOL y Bob también tiene 100 SOL. Alice envía una transacción para darle 10 SOL a Bob. Una vez verificada, la transacción se agrega a un bloque y este se incorpora al libro mayor. Ahora el libro mayor contiene un registro inmutable que indica que Alice envió 10 SOL a Bob. Al mismo tiempo, el estado actualiza las cuentas de Alice y Bob a 90 y 110 SOL, respectivamente.

Las diferencias principales entre ambos pueden resumirse así:

  • El libro mayor es inmutable y de solo adición, mientras que el estado es mutable y cambia constantemente
  • El libro mayor es un registro histórico de todas las transacciones, mientras que el estado refleja la situación actual de todas las cuentas y programas
  • El libro mayor se usa para la verificación, mientras que el estado se usa para ejecutar transacciones y programas

Mientras que el libro mayor actúa como un registro histórico inmutable que garantiza que cada transacción pueda verificarse y rastrearse, el estado funciona como una instantánea dinámica del libro mayor que se ajusta a operaciones en tiempo real, como transferencias y ejecución de programas. Es importante destacar que ambos están sujetos al consenso de la propia cadena. Juntos, el estado y el libro mayor constituyen la base de Solana y le permiten operar de manera eficiente mientras preserva la confianza descentralizada.

¿Qué son los NFT comprimidos?

Los NFT comprimidos (cNFT) usan la compresión de estado y árboles de Merkle concurrentes para reducir los costos de almacenamiento. En lugar de almacenar cada NFT en una cuenta típica de Solana, los NFT comprimidos guardan sus metadatos en el libro mayor. Esto reduce los costos de almacenamiento mientras hereda la seguridad e inmutabilidad del libro mayor.

Los NFT comprimidos siguen exactamente el mismo esquema de metadatos que sus equivalentes sin comprimir. Por lo tanto, los NFT y los cNFT se definen de la misma manera.

Las diferencias principales entre los NFT y los cNFT son las siguientes:

  • Un NFT comprimido puede convertirse en un NFT normal, pero un NFT normal no puede convertirse en uno comprimido
  • Los NFT comprimidos no son tokens nativos de Solana: no tienen una cuenta de token, una cuenta de acuñación ni metadatos. Sin embargo, sí tienen un identificador estable (el ID del activo). Tras la descompresión, el NFT conserva el mismo identificador. Por lo tanto, los NFT en estado comprimido no son tokens nativos, pero pueden convertirse en ellos si es necesario
  • Una sola cuenta de árbol de Merkle concurrente puede contener millones de NFT
  • Una sola colección puede distribuirse entre varias cuentas de árbol
  • Todas las modificaciones de NFT se realizan mediante el programa Bubblegum
  • Se recomienda realizar una llamada a la API de DAS para leer cualquier información sobre un NFT comprimido

Curiosamente, necesitamos usar la API de DAS para obtener información sobre un NFT comprimido. ¿Por qué? Y, aún más importante, ¿qué es?

Lectura de metadatos de NFT comprimidos con la API de DAS

Necesitamos la ayuda de indexadores porque los metadatos de un cNFT se almacenan en el libro mayor, no en una cuenta tradicional. Aunque puedes derivar el estado actual de un NFT comprimido reproduciendo las transacciones relevantes, proveedores como Helius lo hacen por ti. Los desarrolladores pueden usar la API del estándar de activos digitales (DAS), una especificación y un sistema de código abierto para obtener información sobre un activo. La API de DAS admite tanto NFT comprimidos como tradicionales o sin comprimir. Por lo tanto, puedes usar el mismo endpoint para ambos tipos de NFT.

Actualmente, Helius admite los siguientes métodos de la API de DAS:

  • getAsset - obtiene un activo específico por su ID
  • getAssetBatch - obtiene varios activos por sus ID
  • getAssetProof - obtiene una prueba de Merkle para un activo comprimido mediante su ID
  • getAssetProofBatch - obtiene varias pruebas de activos por sus ID
  • getAssetsByOwner - obtiene una lista de activos que pertenecen a una dirección
  • getAssetsByAuthority - obtiene una lista de activos con una autoridad específica
  • getAssetsByCreator - obtiene una lista de activos creados por una dirección
  • getAssetsByGroup - obtiene una lista de activos por clave y valor de grupo
  • searchAssets - busca activos mediante diversos parámetros
  • getSignaturesForAsset - obtiene una lista de firmas de transacciones relacionadas con un activo comprimido
  • Paginación: compatibilidad con paginación por páginas y por conjunto de claves para obtener más de 1000 registros a la vez

Consulta la documentación de la API de DAS de Helius para obtener más información sobre cada método. Por ejemplo, si quisieras obtener una lista de todos los activos que pertenecen a una dirección, puedes realizar la siguiente solicitud POST con 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();

Obtener activos comprimidos es práctico, pero ¿qué ocurre si queremos crear los nuestros? Antes de comenzar el proceso de acuñación, es fundamental calcular el tamaño y el costo asociado de crear un árbol de Merkle concurrente que almacene estos activos.

Dimensionamiento y costos de crear un árbol de Merkle concurrente

Cálculo del tamaño

Al crear un árbol de Merkle concurrente en la cadena, hay tres métricas importantes que determinan el tamaño y el costo de creación del árbol, así como la cantidad de cambios concurrentes que se pueden realizar mientras la raíz de Merkle sigue siendo válida:

  • Profundidad máxima
  • Tamaño máximo del búfer
  • Profundidad del dosel

La profundidad máxima se refiere a la cantidad máxima de saltos necesarios para llegar desde cualquier hoja hasta la raíz del árbol. Cada hoja está conectada a una sola hoja más y forma con ella un par para calcular el hash. Puedes calcular la cantidad máxima de nodos hoja que admite un árbol con la fórmula: numberOfNodes = 2 ^ maxDepth. La profundidad del árbol debe establecerse al crearlo, por lo que debes usar esta fórmula para determinar la menor profundidad máxima posible que permita almacenar tus datos. Por ejemplo, si quieres almacenar alrededor de 100 NFT comprimidos en un árbol, un maxDepth de 7 sería suficiente, ya que 2^7 = 128 y 2^6 = 64. La profundidad máxima es un factor importante para determinar el costo de crear un árbol de Merkle concurrente en la cadena. Estos costos se pagan por adelantado durante la creación del árbol y aumentan con valores mayores de maxDepth.

El tamaño máximo del búfer se refiere a la cantidad máxima de cambios que pueden realizarse en un árbol sin que su raíz de Merkle deje de ser válida. En los árboles de Merkle concurrentes, el tamaño del búfer del registro de cambios se define durante la creación del árbol mediante el valor maxBufferSize. Así, cuando un validador recibe varias solicitudes de cambio para un árbol en el mismo slot, puede usar el registro de cambios y permitir hasta maxBufferSize cambios sin que la raíz deje de ser válida.

Es fundamental señalar que solo existe una cantidad específica de pares maxDepth e maxBufferSize válidos para crear una cuenta nueva de árbol de Merkle concurrente. El paquete @solana/spl-account-compression exporta la constante ALL_DEPTH_SIZE_PAIRS, que es un arreglo de arreglos numéricos con todas las combinaciones válidas. El mínimo es un maxDepth de 3 y un maxBufferSize de 8, mientras que el máximo es un maxDepth de 30 y un maxBufferSize de 2048.

La profundidad del dosel se refiere a un subconjunto del árbol de Merkle almacenado en una cuenta. Estas pruebas almacenadas en caché se usan para complementar las pruebas transmitidas por la red, ya que están sujetas a los límites de las transacciones. Se debe usar la ruta completa para verificar la propiedad original de una hoja cuando se intenta modificar sus datos, por ejemplo, al transferir un NFT. Cuanto mayor sea la profundidad máxima del árbol, más nodos de prueba se necesitarán para la verificación. El dosel permite reducir el tamaño de la prueba y evita tener que usar un tamaño de prueba de maxDepth para verificar el árbol.

La profundidad del dosel puede calcularse restando el tamaño de prueba deseado a la profundidad máxima. Por ejemplo, si tuvieras una profundidad máxima de 14 y quisieras un tamaño de prueba de 4, la profundidad del dosel sería de 10. Esto significa que solo tendrías que enviar 4 nodos de prueba por cada transacción de actualización. La profundidad del dosel también es un factor importante para determinar el costo de crear un árbol de Merkle concurrente en la cadena. Estos costos se pagan por adelantado durante la creación del árbol y aumentan con valores mayores de canopyDepth. Aunque un canopyDepth menor reduce el costo inicial, un canopyDepth bajo puede limitar la componibilidad. Esto se debe a que cada transacción de actualización requerirá un tamaño de prueba mayor, lo que impone restricciones debido a los límites de tamaño de las transacciones. Por ejemplo, si tu árbol con un canopyDepth bajo se usa para NFT comprimidos, un marketplace de NFT podría admitir únicamente transferencias simples para tu colección. En general, maxDepth - canopyDepth debe ser menor o igual a 10 para maximizar la componibilidad. Esto se detalla en la especificación de Tensor sobre la longitud máxima de prueba para los cNFT de Tensor.

Cálculo de costos

Existen distintos métodos para determinar el tamaño y el costo de un árbol de Merkle concurrente. El enfoque más sencillo es usar la calculadora de NFT comprimidos e ingresar la cantidad de NFT comprimidos que se almacenarán en ese árbol:

El sitio ofrece un desglose detallado de la profundidad óptima del árbol necesaria para almacenar la cantidad deseada de activos, junto con distintas opciones de costo según la componibilidad. La figura, por ejemplo, muestra que crear un árbol altamente componible capaz de almacenar 10 millones de NFT comprimidos costaría solo ~7.67 SOL. Si se consideran los costos de transacción de ~50 SOL para acuñar 10 millones de NFT, el costo total sería de alrededor de ~57.67 SOL.

Los desarrolladores también pueden usar el paquete @solana/spl-account-compression para calcular el espacio necesario para un árbol de un tamaño determinado y el costo de asignar ese espacio al árbol en la cadena. Esto puede hacerse con el siguiente 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();

Aquí importamos los módulos necesarios de @solana/web3.js y @solana/spl-account-compression. Necesitamos una conexión a mainnet, que podemos establecer mediante una clave de API de Helius. La función calculateCosts muestra en la consola el maxDepth, el maxBufferSize, el canopy, la cantidad de NFT que podrían almacenarse en este árbol y el costo de rent en SOL. Por lo tanto, cuando llamamos a calculateCosts con el tamaño de prueba deseado, podemos ver en la consola todas las combinaciones posibles de árboles.

Ten en cuenta que algunos registros pueden mostrar: Unable to fetch minimum balance for rent exemption. Esto se debe a que la cuenta con el maxProofSize especificado sería demasiado grande para crearla y, por lo tanto, no podemos obtener un saldo mínimo que exima a la cuenta del pago de renta.

Crear un árbol de Merkle concurrente

Debemos crear dos cuentas al crear un árbol de Merkle concurrente:

  • Una cuenta de árbol de Merkle concurrente
  • Una cuenta de configuración del árbol de Merkle concurrente

La cuenta del árbol contiene el árbol de Merkle que se usa para verificar datos. La creamos con la profundidad máxima, el tamaño máximo del búfer y la profundidad del dosel que queramos, como se explicó en la sección anterior. Esta cuenta pertenece al programa Account Compression, que Solana crea y mantiene. Se usa para verificar la autenticidad de los NFT comprimidos.

La cuenta de configuración del árbol es una PDA derivada de la dirección de la cuenta del árbol de Merkle concurrente. Se usa para almacenar configuraciones adicionales, como el creador del árbol y la cantidad de NFT comprimidos acuñados.

Metaplex denomina «árbol de Bubblegum» a los árboles de Merkle concurrentes que tienen una cuenta de configuración del árbol asociada.

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

Desglose del código

Esta es una función de ejemplo para crear un árbol de Merkle concurrente en Solana. Para invocar esta función de ejemplo, createTree, debes pasar los siguientes parámetros:

  • connection: una conexión a un endpoint JSON RPC de un nodo completo, de tipo Connection
  • payer: la cuenta que pagará la transacción, de tipo Keypair
  • treeKeypair: la dirección del par de claves del árbol, de tipo Keypair
  • maxDepthSizePair: el par válido de maxDepth e maxBufferSize, de tipo ValidDepthSizePair
  • canopyDepth: la profundidad del dosel del árbol, de tipo number y con un valor predeterminado de 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";

Primero, importamos @solana/web3.js, @solana/spl-account-compression y @metaplex-foundation/mpl-bubblegum con los módulos necesarios.

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

Aquí definimos la función createTree con los parámetros mencionados.

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

createAllocTreeIx es una función auxiliar que usamos para crear la cuenta del árbol de Merkle concurrente. El paquete SPL Account Compression recomienda usar este método para inicializar una cuenta de árbol de Merkle concurrente porque estas cuentas suelen ser bastante grandes y pueden superar el límite de lo que se puede asignar mediante CPI. Aquí creamos la instrucción para asignar la cuenta del árbol on-chain. Esto también calcula el espacio necesario para almacenar el árbol on-chain y su costo, por lo que no tendremos que ocuparnos de ello más adelante.

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

Debemos derivar la cuenta de configuración del árbol con su autoridad en manos del programa Bubblegum. Esto es necesario para createCreateTreeInstruction, la instrucción que crea el árbol, porque debemos pasar treeAuthority como argumento. Aquí derivamos la PDA con el método findProgramAddressSync usando la clave pública del árbol y el ID del programa Bubblegum. Debemos desestructurar treeAuthority porque se devuelven tanto la autoridad como el bump. Omití el bump porque nuestra función no lo necesita. Si necesitas guardarlo, cambia la desestructuración a [treeAuthority, 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 del SDK de Bubblegum para construir la instrucción que crea el árbol de Merkle concurrente. Esto crea el árbol on-chain con el programa Bubblegum como propietario. createCreateTreeInstruction tiene tres parámetros. El primero es un objeto que contiene cuentas para configurar propiedades como el creador del árbol. El segundo objeto corresponde a la profundidad máxima y al tamaño máximo del búfer. También incluye un parámetro public de tipo boolean. Configurar public como true permitirá que cualquiera acuñe NFT comprimidos desde el árbol. De lo contrario, solo el creador o el delegado del árbol podrán hacerlo. Una cuenta delegada puede realizar acciones en nombre del propietario del árbol, como transferir o quemar un NFT comprimido. Además, puedes asignar un delegado del árbol usando createSetTreeDelegateInstruction del paquete @metaplex-foundation/mpl-bubblegum de esta forma:

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

También pasamos el ID del programa Bubblegum. Ahora volvamos al resto del 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}`);
}

Agregamos las dos instrucciones que acabamos de crear a una transacción y la enviamos. Nos aseguramos de que tanto treeKeypair como payer firmen la transacción. Después, registramos en la consola la firma de la transacción exitosa. Encapsulamos este proceso en un bloque try-catch para que, si ocurre un error por cualquier motivo, se registre en la consola mediante console.error.

Crear un árbol de Merkle concurrente con Umi

Usar el SDK de Bubblegum, el programa de compresión de cuentas de Solana y los paquetes web3.js de Solana puede ser bastante confuso para los desarrolladores nuevos y tedioso de configurar cada vez. Por suerte, el SDK de Bubblegum ofrece una operación createTree que se encarga de todo y funciona muy bien con Umi. El código es el siguiente:

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 es un framework modular para crear y usar clientes JavaScript para programas de Solana. Proporciona una biblioteca sin dependencias con un conjunto de interfaces principales que otras bibliotecas pueden usar sin quedar limitadas a una implementación específica. Metaplex proporciona Umi y puedes consultar su documentación aquí.

Usamos nuestra instancia de Umi para generar un firmante, crear el árbol de Merkle, y enviar y confirmar la transacción construida. De forma predeterminada, el creador del árbol se establece como la identidad de Umi y el parámetro public se establece en false. Puedes personalizar estos parámetros para pasar también un creador del árbol personalizado y un valor público de true. Esta es una forma mucho más rápida de crear un árbol de Merkle concurrente on-chain.

Ten en cuenta que Bubblegum es independiente del tamaño del dosel. Esto se debe a que el programa Account Compression de Solana determina el tamaño del dosel según el espacio disponible en la cuenta. Solo necesitas asignar suficiente espacio para que el programa pueda determinar con precisión el tamaño adecuado del dosel.

Acuñar cNFT mediante una interacción directa con Bubblegum

Crear una colección

Tradicionalmente, los NFT se agrupan en una colección mediante el estándar de Metaplex. Esto se aplica tanto a los NFT comprimidos como a los «normales». Para crear una colección:

  • Crea una nueva «mint» de token
  • Crea una cuenta de token asociada para la mint
  • Acuña un solo token
  • Almacena los metadatos de la colección en una cuenta on-chain

Aunque esto no se relaciona directamente con la compresión de estado o los NFT comprimidos y, por lo tanto, queda fuera del alcance de este artículo, proporcionamos un script como referencia para crear tu propia colección. Puedes acceder al script aquí.

Acuñar un NFT en nuestra colección

Con tu colección recién creada, necesitarás lo siguiente para comenzar a acuñar:

  • collectionMint: la dirección de mint de la colección
  • collectionAuthority: la cuenta con autoridad sobre la colección
  • collectionMetadata: la cuenta de metadatos de la colección
  • editionAccount: la cuenta que contiene atributos adicionales, como una cuenta de edición maestra

Código completo para acuñar en una colección

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

Desglose del proceso de acuñación

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

Primero, importamos @solana/web3.js, @solana/spl-account-compression, @metaplex-foundation/mpl-bubblegum y @metaplex-foundation/mpl-token-metadata con los módulos necesarios.

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 recibe bastantes parámetros:

  • connection: el objeto de conexión usado para interactuar con Solana
  • payer: la cuenta que pagará las comisiones de la transacción
  • treeAddress: la cuenta del árbol de Merkle concurrente
  • collectionMint: la dirección de mint de la colección
  • collectionMetadata: la cuenta de metadatos de la colección
  • collectionMasterEditionAccount: la cuenta de edición maestra
  • compressedNFTMetadata: los metadatos específicos del cNFT que se acuñará
  • receiverAddress: una dirección de clave pública opcional a la que se enviará el cNFT recién acuñado
Código
const [treeAuthority, ] = PublicKey.findProgramAddressSync([treeAddress.toBuffer()], BUBBLEGUM_PROGRAM_ID);

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

Aquí buscamos las PDA necesarias e ignoramos sus bumps. Primero derivamos la PDA para la autoridad del árbol y después derivamos una PDA que actuará como firmante de la acuñación comprimida. Debemos incluir collection_cpi porque es un prefijo personalizado que exige el programa Bubblegum.

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

Establecemos mintInstructions como un array vacío de TransactionInstruction. Esto nos permite acuñar varios cNFT al mismo tiempo si lo deseamos.

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

metadataArgs garantiza que compressedNFTMetadata tenga el formato correcto. Para acuñar un NFT en una colección con createMintToCollectionV1Instruction, el campo verified debe establecerse en false para que la transacción se complete correctamente, aunque la colección se verifique de forma automática.

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

Agregamos una sola acuñación a nuestra instrucción. Podríamos agregar varias acuñaciones a la misma transacción siempre que esta se mantenga dentro de los límites de tamaño en bytes. Aquí usamos createMintToCollectionV1Instruction para acuñar nuestro NFT comprimido desde la colección. Esta instrucción recibe dos objetos: uno con las cuentas necesarias para procesarla y otro con los datos de instrucción para el programa. La mayoría de estos parámetros deberían resultarte familiares por las secciones anteriores. Ten en cuenta que puedes establecer cualquier dirección de delegado al acuñar, pero normalmente debería ser la misma que leafOwner. En cualquier caso, el delegado se elimina automáticamente al transferir el cNFT. Establecemos al pagador como delegado, ya que también recibirá el cNFT si no se proporciona receiverAddress.

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

Luego construimos la transacción, establecemos payer como feePayer y enviamos la transacción. Encapsulamos esta lógica en un bloque try-catch por si ocurre algún error al enviar y confirmar la transacción. Si ocurre un error, lo registramos en la consola con console.error.

Acuñar cNFT con Umi

El programa Bubblegum ofrece dos procesos de acuñación mediante Umi:

  • Acuñar un NFT sin asociarlo a una colección
  • Acuñar un NFT en una colección determinada.

Acuñar sin una colección

La instrucción MintV1 de Bubblegum permite acuñar NFT comprimidos desde un árbol de Bubblegum sin una colección. Si el árbol es público, cualquiera puede acuñar en él. De lo contrario, solo el creador o el delegado del árbol pueden usar esta instrucción. Así puedes acuñar un NFT comprimido sin una colección:

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 fragmento de código proviene de la documentación de Metaplex sobre cómo acuñar cNFT con Bubblegum. Aquí usamos una instancia de Umi para acuñar un cNFT. Los demás parámetros de la instrucción mintV1 son los siguientes:

  • leafOwner es el propietario del cNFT que se acuñará
  • merkleTree es la dirección de la cuenta del árbol de Merkle concurrente desde el que se acuñará el cNFT
  • metadata es un objeto que contiene los metadatos del cNFT que se acuñará. Esto incluye el nombre del cNFT, su URI, su colección —que hemos establecido como none— y sus creadores. Es posible proporcionar un objeto de colección, pero debes establecer el campo verified de los creadores en false porque la autoridad de la colección no se solicita en la instrucción. Los creadores también pueden verificarse a sí mismos estableciendo el campo verified en true y proporcionando al creador como firmante en las cuentas restantes.

La instrucción mintV1 también contiene varios campos opcionales, ya que la entrada de la función es de tipo MintV1InstructionAccounts & MintV1InstructionArgs. Estos tipos se definen de la siguiente manera:

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 es un tipo difícil de identificar entre otros tipos que, en esencia, es un objeto con un campo metadata. Este campo metadata es de tipo MetadataArgsArgs y se define de la siguiente manera:

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

Puedes encontrar la definición completa de la función mintV1 y todos sus tipos asociados aquí. Pero, como mínimo, con una instancia de Umi puedes acuñar un cNFT sin una colección si proporcionas los metadatos requeridos, el propietario de la hoja y la cuenta del árbol de Merkle concurrente.

Acuñar con una colección

Bubblegum proporciona mintToCollectionV1 como una forma práctica de acuñar directamente un cNFT en una colección determinada. La entrada de esta instrucción es de los tipos MintToCollectionV1InstructionAccounts e MintToCollectionV1InstructionArgs, que en última instancia forman un objeto de tipo MetadataArgsArgs. La definición del tipo MintToCollectionV1InstructionAccounts es la siguiente:

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

Los parámetros clave son la mint de la colección, la autoridad de la colección y la PDA del registro de autoridad de la colección. Debes proporcionar una PDA de registro de delegado al usar una autoridad de colección delegada para garantizar que la autoridad tenga permiso para administrar el NFT de la colección. El parámetro metadata debe contener un objeto de colección cuyo campo address coincida con el parámetro de mint de la colección y cuyo campo verified esté establecido en false. Los creadores también pueden verificarse a sí mismos firmando la transacción y agregándose como cuentas restantes.

Así puedes acuñar un NFT comprimido con una colección:

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

Puedes encontrar este fragmento de código en la documentación de Metaplex sobre cómo acuñar cNFT con Bubblegum. De nuevo, usamos una instancia de Umi para acuñar el NFT comprimido. Al igual que con mintV1, pasamos leafOwner e merkleTree. Sin embargo, esta vez pasamos collectionMint. En el campo metadata pasamos un objeto collection cuya clave coincide con collectionMint y cuyo campo verified está establecido en false. Ten en cuenta que la identidad de Umi se establece como la autoridad predeterminada de la colección. Puedes cambiarla estableciendo el campo opcional collectionAuthority en una autoridad de colección personalizada.

Acuñación de cNFT con Helius

En Helius, ofrecemos una API de acuñación que te permite acuñar NFT comprimidos sin complicaciones adicionales. Cubrimos las comisiones de Solana y la creación del árbol de Merkle, y subimos tus metadatos off-chain a Arweave. También verificamos que la transacción se haya enviado correctamente y que la red la haya confirmado, por lo que no tienes que consultarla de forma periódica. Además, extraemos el ID del activo de la transacción para que puedas usarlo de inmediato con la API de DAS.

Para que Helius acuñe un NFT en tu colección, debes delegarle la autoridad de la colección. La autoridad debe delegarse a una de las siguientes cuentas, según tu clúster:

  • Devnet: 2LbAtCJSaHqTnP9M5QSjvAMXk79RNLusFspFN5Ew67TC
  • Mainnet: HnT5KVAywGgQDhmh6Usk4bxRg4RwKxCK4jmECyaDth5R

Así puedes acuñar un cNFT con la API de acuñación de 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();

Puedes encontrar este fragmento de código y un desglose más detallado del esquema de la solicitud en nuestra documentación.

Ten en cuenta que, si no completas el campo uri, crearemos un archivo JSON y lo subiremos a Arweave por ti. El archivo cumplirá con el estándar JSON v1.0 de Metaplex y se subirá mediante Irys (antes conocido como Bundlr).

Transferencia de cNFT

Los pasos generales para transferir un NFT comprimido son los siguientes:

  • Obtén los datos del activo del cNFT desde el indexador
  • Obtén la prueba del cNFT desde el indexador
  • Obtén la cuenta del árbol de Merkle concurrente desde Solana
  • Prepara la prueba del activo
  • Crea y envía la transacción de transferencia

Umi y Metaplex simplifican mucho este proceso, pero esta sección mostrará lo que ocurre en segundo plano. A continuación, explicaremos cómo transferir un NFT comprimido mediante web3.js y Metaplex.

Transferencia mediante la interacción directa con Bubblegum

Antes de usar nuestro script para ejecutar la transferencia, necesitamos obtener cierta información sobre nuestro NFT comprimido. Primero, debemos usar el método getAsset de la API de DAS para recuperar los metadatos del NFT comprimido. En este caso, buscamos data_hash, creator_hash, owner, delegate y 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();

Así se verá una parte de la respuesta correcta:

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

Una vez que tengamos la información necesaria, debemos usar el método getAssetProof para recuperar proof e tree_id (la dirección del árbol). Este es un ejemplo de llamada:

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

Así se verá la respuesta correcta:

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

Ahora que tenemos la raíz, la prueba y tree_id, podemos pasar a nuestro script de transferencia.

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

Desglose del 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 y @solana/spl-account-compression con los módulos necesarios.

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 nuestra función transferCompressedNFT, en la que analizaremos la ruta de la prueba, crearemos la instrucción de transferencia y la ejecutaremos.

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

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

Obtenemos la cuenta del árbol de Merkle concurrente desde la blockchain y extraemos la autoridad del árbol y la profundidad del dosel. Estos valores son necesarios para crear la instrucción de transferencia.

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

En pocas palabras, analizamos la lista de direcciones de la prueba para convertirla en un arreglo válido de tipo AccountMeta. AccountMeta son los metadatos de cuenta utilizados para definir transacciones. Incluyen la clave pública de la cuenta, si una instrucción requiere una firma de transacción que coincida con la clave pública y si la clave pública puede cargarse como una cuenta de lectura y escritura.

Tomamos una porción de la prueba completa desde el principio del arreglo y nos aseguramos de tener solo proof.length - canopyDepth valores de prueba. Esto elimina la parte del árbol que ya está almacenada en caché en el dosel on-chain. Después, estructuramos cada valor de prueba restante como un AccountMeta válido. Lo hacemos porque la prueba se envía on-chain como «cuentas adicionales» dentro de la instrucción de transferencia.

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

Luego, establecemos leafOwner en el parámetro owner e leafDelegate en el 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
  );

Creamos transferInstruction mediante la función auxiliar createTransferInstruction del SDK de Bubblegum. Ten en cuenta que root, dataHash e creatorHash se devuelven desde la API de DAS como una cadena, por lo que debemos convertirlos al tipo PublicKey y, después, a un arreglo 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}`);
  }

Una vez creada la instrucción, la agregamos a una nueva transacción y la enviamos a Solana. Si ocurre algún error, lo registramos en la consola mediante console.error.

Si encuentras errores relacionados con el árbol de Merkle concurrente, es posible que tu RPC esté proporcionando datos obsoletos o incorrectos para la prueba del árbol de Merkle concurrente. Esto puede ocurrir ocasionalmente debido a problemas de caché. Para solucionarlo, puedes intentar verificar en el cliente la prueba proporcionada por el 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());

Ten en cuenta que también necesitarás usar el valor leaf devuelto por nuestra llamada getAssetProof a la API de DAS. Esto no es obligatorio porque la validación real de la prueba se realiza on-chain. Sin embargo, puede ayudarte a gestionar errores.

Con esto, puedes hacer otra llamada a getAsset para comprobar que leafDelegate tiene un valor vacío y que la hoja tiene un nuevo propietario.

Transferencia con 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 proviene de la documentación de Metaplex sobre la transferencia de NFT comprimidos.

Bubblegum proporciona una instrucción transfer muy fácil de usar. Primero, recibe una instancia de Umi. Después, acepta un objeto que contiene el activo, junto con información sobre su prueba, el propietario de la hoja y el nuevo propietario de la hoja. Para obtener el activo con la prueba requerida, podemos usar el método getAssetWithProof, que también proporciona Bubblegum. Ten en cuenta que puedes usar el delegado de la hoja en lugar de su propietario: solo se necesita una cuenta con autoridad para autorizar la transferencia. Con el método .sendAndConfirm(), enviamos la transacción que inicia la transferencia y luego la confirmamos con nuestra instancia de Umi.

Conclusión

¡Felicidades! Exploramos de forma exhaustiva la compresión de estado y los NFT comprimidos en Solana. Abordamos las complejidades de los árboles de Merkle concurrentes, aclaramos conceptos erróneos comunes y profundizamos en el ledger de Solana. Más allá de la teoría, aprendimos a obtener, acuñar y transferir cNFT aprovechando la potencia de web3.js, Metaplex y Helius en Solana.

La compresión de estado de Solana es revolucionaria en un entorno donde los costos de transacción y almacenamiento pueden ser restrictivos. La compresión reduce drásticamente los costos sin comprometer la seguridad ni la descentralización. Este cambio de paradigma abre posibilidades sin precedentes para artistas, coleccionistas y desarrolladores.

Si llegaste hasta aquí, ¡gracias, anon! Estás bien preparado para contribuir a esta nueva y emocionante frontera. Adelante: acuña una colección de diez millones de NFT para tu MMORPG on-chain, crea una aplicación descentralizada que aproveche la potencia del ledger o simplemente comparte tus nuevos conocimientos con la comunidad. La mejor forma de predecir el futuro es crearlo.

Recursos adicionales y lecturas complementarias

Suscríbete a Helius

Mantente al día con las novedades del desarrollo en Solana y recibe actualizaciones cuando publiquemos

Imagen ampliada