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 necesariorealloc::payer
- define la cuenta que pagará o será reembolsada en función de los nuevos lamports requeridosrealloc::zero
- define que la cuenta puede ser actualizada múltiples veces cuando se establece entrue
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 #
- Journal dApp: solana-journal-eight.vercel.app
- Código de ejemplo: https://github.com/solana-foundation/CRUD-dApp