Cómo crear una dApp CRUD en Solana

En esta guía, aprenderás a crear y desplegar tanto un programa en Solana como la interfaz de usuario para una dApp CRUD básica. Esta dApp te permitirá crear entradas de diario, actualizarlos, leerlos y eliminarlos mediante transacciones en la blockchain.

Lo que aprenderás #

  • Configurar su entorno
  • Usar npx create-solana-dapp
  • Desarrollo de programas en Anchor
  • Anchor PDAs y cuentas
  • Desplegar un programa en Solana
  • Probar un programa en la cadena de bloques
  • Conectar un programa en la cadena de bloques con una interfaz de React

Prerrequisitos #

Para esta guía, necesitarás tener configurado tu entorno de desarrollo local con algunas herramientas:

Configurar el proyecto #

npx create-solana-dapp

Este comando CLI permite la creación rápida de una Solana dApp. Puedes encontrar el código fuente aquí.

Responde a las preguntas de la siguiente manera:

  • Enter project name: my-journal-dapp
  • Select a preset: Next.js
  • Select a UI library: Tailwind
  • Select an Anchor template: counter program

Al seleccionar counter para la plantilla de Anchor, un programa con un contador, escrito en Rust usando el framework Anchor, será generado para ti. Antes de empezar a editar este programa generado, asegurémonos de que todo funciona como se espera:

cd my-journal-dapp
 
npm install
 
npm run dev

Escribir un programa de Solana con Anchor #

Si eres nuevo con Anchor, El Libro de Anchor y Los Ejemplos de Anchor son excelentes referencias para aprender.

En my-journal-dapp, navega hasta anchor/programs/journal/src/lib.rs. Ya habrá código de la plantilla generado en esta carpeta. Eliminémoslo y empecemos de cero para poder seguir cada paso.

Define tu programa de Anchor #

use anchor_lang::prelude::*;
 
// Esta es la llave pública de tu programa y se actualizará automáticamente cuando construyas el proyecto.
declare_id!("7AGmMcgd1SjoMsCcXAAYwRgB9ihCyM8cZqjsUqriNRQt");
 
#[program]
pub mod journal {
    use super::*;
}

Define el estado del programa #

El estado es la estructura de datos utilizada para definir la información que desea guardar en la cuenta. Since Solana onchain programs do not have storage, the data is stored in accounts that live on the blockchain.

Cuando se utiliza Anchor, la macro de atributo #[account] se utiliza para definir su estado del programa.

#[account]
#[derive(InitSpace)]
pub struct JournalEntryState {
    pub owner: Pubkey,
    #[max_len(50)]
    pub title: String,
     #[max_len(1000)]
    pub message: String,
}

Para esta dApp diario, vamos a almacenar:

  • el propietario del diario
  • el título de cada entrada, y
  • el mensaje de cada entrada

Nota: El espacio debe definirse al inicializar una cuenta. La macro InitSpace utilizada en el código anterior ayudará a calcular el espacio necesario al inicializar una cuenta. Para más información sobre el espacio, lea aquí.

Crea una entrada en el diario #

Ahora, vamos a añadir un manejador de instrucciones a este programa que crea una nueva entrada en el diario. Para ello, actualizaremos el código dentro del #[program] que ya definimos anteriormente para incluir una instrucción para create_journal_entry.

Al crear una entrada, el usuario deberá indicar el title y el message de la entrada. Así que tenemos que añadir esas dos variables como argumentos adicionales.

Al llamar a esta función, queremos guardar el owner de la cuenta, el title de la entrada, y el message de la entrada en el JournalEntryState de la cuenta.

#[program]
mod journal {
    use super::*;
 
    pub fn create_journal_entry(
        ctx: Context<CreateEntry>,
        title: String,
        message: String,
    ) -> Result<()> {
        msg!("Journal Entry Created");
        msg!("Title: {}", title);
        msg!("Message: {}", message);
 
        let journal_entry = &mut ctx.accounts.journal_entry;
        journal_entry.owner = ctx.accounts.owner.key();
        journal_entry.title = title;
        journal_entry.message = message;
        Ok(())
    }
}

Con el marco de trabajo de Anchor, cada instrucción recibe el tipo Context como primer argumento. La macro Context se utiliza para definir un struct que encapsula las cuentas que se pasarán a un determinado manejador de instrucciones. Por lo tanto, cada Context debe tener un tipo especificado con respecto al manejador de instrucciones. En nuestro caso, necesitamos definir una estructura de datos para CreateEntry:

#[derive(Accounts)]
#[instruction(title: String, message: String)]
pub struct CreateEntry<'info> {
    #[account(
        init_if_needed,
        seeds = [title.as_bytes(), owner.key().as_ref()],
        bump,
        payer = owner,
        space = 8 + JournalEntryState::INIT_SPACE
    )]
    pub journal_entry: Account<'info, JournalEntryState>,
    #[account(mut)]
    pub owner: Signer<'info>,
    pub system_program: Program<'info, System>,
}

En el código anterior, hemos utilizado las siguientes macros:

  • La macro #[derive(Accounts)] se utiliza para deserializar y validar la lista de cuentas especificada en la estructura
  • La macro de atributo #[instruction(...)] se utiliza para acceder a los datos de instrucción pasados al instruction handler
  • La macro de atributo #[account(...)] especifica restricciones adicionales en las cuentas

Each journal entry is a Program Derived Address ( PDA) that stores the entries state on-chain. Since we are creating a new journal entry here, it needs to be initialized using the init_if_needed constraint.

Con Anchor, un PDA se inicializa con las restricciones seeds, bumps e init_if_needed. La restricción init_if_needed también requiere las restricciones payer y space para definir quién paga la renta para mantener los datos de esta cuenta en la cadena de bloques y cuánto espacio debe asignarse para esos datos.

Nota: Al utilizar la macro InitSpace en el JournalEntryState, podemos calcular el espacio utilizando la constante INIT_SPACE y añadiendo 8 a la restricción de espacio para el discriminador interno de Anchor.

Actualizar una entrada en el diario #

Ahora que podemos crear una nueva entrada en el diario, vamos a añadir un manejador de instrucciones update_journal_entry con un contexto que tenga un tipo UpdateEntry.

Para ello, la instrucción deberá reescribir/actualizar los datos de una PDA específica que se guardó en el JournalEntryState de la cuenta cuando el propietario de la entrada llama a la instrucción update_journal_entry.

#[program]
mod journal {
    use super::*;
 
    ...
 
    pub fn update_journal_entry(
        ctx: Context<UpdateEntry>,
        title: String,
        message: String,
    ) -> Result<()> {
        msg!("Journal Entry Updated");
        msg!("Title: {}", title);
        msg!("Message: {}", message);
 
        let journal_entry = &mut ctx.accounts.journal_entry;
        journal_entry.message = message;
 
        Ok(())
    }
}
 
#[derive(Accounts)]
#[instruction(title: String, message: String)]
pub struct UpdateEntry<'info> {
    #[account(
        mut,
        seeds = [title.as_bytes(), owner.key().as_ref()],
        bump,
        realloc = 8 + 32 + 1 + 4 + title.len() + 4 + message.len(),
        realloc::payer = owner,
        realloc::zero = true,
    )]
    pub journal_entry: Account<'info, JournalEntryState>,
    #[account(mut)]
    pub owner: Signer<'info>,
    pub system_program: Program<'info, System>,
}

En el código anterior, debería notar que es muy similar a la creación de una entrada de diario, pero hay un par de diferencias clave. Dado que update_journal_entry está editando una PDA ya existente, no necesitamos inicializarla. Sin embargo, el mensaje que se pasa a la función podría tener un tamaño distinto (es decir, el message podría ser más corto o más largo), por lo que tendremos que utilizar algunas restricciones específicas realloc para reasignar el espacio para la cuenta:

  • realloc - establece el nuevo espacio necesario
  • realloc::payer - define la cuenta que pagará o será reembolsada en función de los nuevos lamports requeridos
  • realloc::zero - define que la cuenta puede ser actualizada múltiples veces cuando se establece en true

Las restricciones seeds y bump siguen siendo necesarias para poder encontrar la PDA concreta que queremos actualizar.

Las restricciones mut nos permiten mutar/cambiar los datos dentro de la cuenta. Debido a que Solana maneja de manera diferente la lectura de cuentas y la escritura en cuentas, debemos definir explícitamente qué cuentas serán mutables para que el tiempo de ejecución de Solana pueda procesarlas correctamente.

Nota: En Solana, cuando se realiza una reasignación que cambia el tamaño de la cuenta, la transacción debe cubrir la renta del nuevo tamaño de la cuenta. El atributo realloc::payer = owner indica que la cuenta del propietario pagará la renta. Para que una cuenta pueda cubrir la renta, normalmente necesita firmar (para autorizar la deducción de fondos), y en Anchor, también necesita ser mutable para que el tiempo de ejecución pueda deducir de la cuenta los lamports para cubrir la renta.

Eliminar una entrada del diario #

Por último, añadiremos un manejador de instrucciones delete_journal_entry con un contexto que tenga un tipo DeleteEntry.

Para ello, simplemente tendremos que cerrar la cuenta de la entrada especificada.

#[program]
mod journal {
    use super::*;
 
    ...
 
    pub fn delete_journal_entry(_ctx: Context<DeleteEntry>, title: String) -> Result<()> {
        msg!("Journal entry titled {} deleted", title);
        Ok(())
    }
}
 
#[derive(Accounts)]
#[instruction(title: String)]
pub struct DeleteEntry<'info> {
    #[account(
        mut,
        seeds = [title.as_bytes(), owner.key().as_ref()],
        bump,
        close = owner,
    )]
    pub journal_entry: Account<'info, JournalEntryState>,
    #[account(mut)]
    pub owner: Signer<'info>,
    pub system_program: Program<'info, System>,
}

En el código anterior, utilizamos la restricción close para cerrar la cuenta en la cadena y devolver la renta al propietario de la entrada.

Las restricciones seeds y bump son necesarias para validar la cuenta.

Compilar y desplegar tu programa de Anchor #

npm run anchor build
npm run anchor deploy

Conectar un programa de Solana con una UI #

create-solana-dapp automáticamente configura una UI con un adaptador de billeteras para ti. Lo único que debemos hacer ahora es modificar el código para que se ajuste al programa recién creado.

Dado que nuestro programa tiene tres instrucciones, necesitaremos componentes en la interfaz de usuario que serán capaces de llamar a cada una de estas instrucciones:

  • crear entrada
  • actualizar entrada
  • eliminar entrada

Dentro del repositorio de tu proyecto, abre el archivo web/components/journal/journal-data-access.tsx para añadir código y poder llamar a cada una de nuestras instrucciones.

Actualiza la función useJournalProgram para poder crear una entrada:

const createEntry = useMutation<string, Error, CreateEntryArgs>({
  mutationKey: ["journalEntry", "create", { cluster }],
  mutationFn: async ({ title, message, owner }) => {
    const [journalEntryAddress] = await PublicKey.findProgramAddress(
      [Buffer.from(title), owner.toBuffer()],
      programId,
    );
 
    return program.methods
      .createJournalEntry(title, message)
      .accounts({
        journalEntry: journalEntryAddress,
      })
      .rpc();
  },
  onSuccess: signature => {
    transactionToast(signature);
    accounts.refetch();
  },
  onError: error => {
    toast.error(`Failed to create journal entry: ${error.message}`);
  },
});

Actualiza la función useJournalProgramAccount para poder actualizar y eliminar entradas:

const updateEntry = useMutation<string, Error, CreateEntryArgs>({
  mutationKey: ["journalEntry", "update", { cluster }],
  mutationFn: async ({ title, message, owner }) => {
    const [journalEntryAddress] = await PublicKey.findProgramAddress(
      [Buffer.from(title), owner.toBuffer()],
      programId,
    );
 
    return program.methods
      .updateJournalEntry(title, message)
      .accounts({
        journalEntry: journalEntryAddress,
      })
      .rpc();
  },
  onSuccess: signature => {
    transactionToast(signature);
    accounts.refetch();
  },
  onError: error => {
    toast.error(`Failed to update journal entry: ${error.message}`);
  },
});
 
const deleteEntry = useMutation({
  mutationKey: ["journal", "deleteEntry", { cluster, account }],
  mutationFn: (title: string) =>
    program.methods
      .deleteJournalEntry(title)
      .accounts({ journalEntry: account })
      .rpc(),
  onSuccess: tx => {
    transactionToast(tx);
    return accounts.refetch();
  },
});

A continuación, actualiza la interfaz de usuario en web/components/journal/journal-ui.tsx para tomar los valores de entrada del usuario para el title y message cuando se crea una entrada del diario:

export function JournalCreate() {
  const { createEntry } = useJournalProgram();
  const { publicKey } = useWallet();
  const [title, setTitle] = useState("");
  const [message, setMessage] = useState("");
 
  const isFormValid = title.trim() !== "" && message.trim() !== "";
 
  const handleSubmit = () => {
    if (publicKey && isFormValid) {
      createEntry.mutateAsync({ title, message, owner: publicKey });
    }
  };
 
  if (!publicKey) {
    return <p>Connect your wallet</p>;
  }
 
  return (
    <div>
      <input
        type="text"
        placeholder="Title"
        value={title}
        onChange={e => setTitle(e.target.value)}
        className="input input-bordered w-full max-w-xs"
      />
      <textarea
        placeholder="Message"
        value={message}
        onChange={e => setMessage(e.target.value)}
        className="textarea textarea-bordered w-full max-w-xs"
      />
      <br></br>
      <button
        type="button"
        className="btn btn-xs lg:btn-md btn-primary"
        onClick={handleSubmit}
        disabled={createEntry.isPending || !isFormValid}
      >
        Create Journal Entry {createEntry.isPending && "..."}
      </button>
    </div>
  );
}

Por último, actualiza UI en journal-ui.tsx para tomar los valores de entrada del usuario para el message cuando se actualiza una entrada del diario:

function JournalCard({ account }: { account: PublicKey }) {
  const { accountQuery, updateEntry, deleteEntry } = useJournalProgramAccount({
    account,
  });
  const { publicKey } = useWallet();
  const [message, setMessage] = useState("");
  const title = accountQuery.data?.title;
 
  const isFormValid = message.trim() !== "";
 
  const handleSubmit = () => {
    if (publicKey && isFormValid && title) {
      updateEntry.mutateAsync({ title, message, owner: publicKey });
    }
  };
 
  if (!publicKey) {
    return <p>Connect your wallet</p>;
  }
 
  return accountQuery.isLoading ? (
    <span className="loading loading-spinner loading-lg"></span>
  ) : (
    <div className="card card-bordered border-base-300 border-4 text-neutral-content">
      <div className="card-body items-center text-center">
        <div className="space-y-6">
          <h2
            className="card-title justify-center text-3xl cursor-pointer"
            onClick={() => accountQuery.refetch()}
          >
            {accountQuery.data?.title}
          </h2>
          <p>{accountQuery.data?.message}</p>
          <div className="card-actions justify-around">
            <textarea
              placeholder="Update message here"
              value={message}
              onChange={e => setMessage(e.target.value)}
              className="textarea textarea-bordered w-full max-w-xs"
            />
            <button
              className="btn btn-xs lg:btn-md btn-primary"
              onClick={handleSubmit}
              disabled={updateEntry.isPending || !isFormValid}
            >
              Update Journal Entry {updateEntry.isPending && "..."}
            </button>
          </div>
          <div className="text-center space-y-4">
            <p>
              <ExplorerLink
                path={`account/${account}`}
                label={ellipsify(account.toString())}
              />
            </p>
            <button
              className="btn btn-xs btn-secondary btn-outline"
              onClick={() => {
                if (
                  !window.confirm(
                    "Are you sure you want to close this account?",
                  )
                ) {
                  return;
                }
                const title = accountQuery.data?.title;
                if (title) {
                  return deleteEntry.mutateAsync(title);
                }
              }}
              disabled={deleteEntry.isPending}
            >
              Close
            </button>
          </div>
        </div>
      </div>
    </div>
  );
}

Recursos #