Dev (Back & Front)ARTIGO

Como integrar ReactJS com programa Solana – Parte 2

Como integrar ReactJS com programa Solana – Parte 2
Imagem: Luiz Fernando Duarte Junior

Na primeira parte deste tutorial, que saiu recentemente aqui no site, a gente fez o setup inicial da aplicação e a primeira funcionalidade que era de inicialização do programa Solana na blockchain. Nesta segunda etapa, eu vou implementar o CRUD de livros através do frontend, conforme combinado.

Vamos lá!

#1 – Cadastrando um Livro

Para conseguir cadastrar um livro nós temos alguns desafios antes. Um deles nós solucionamos na primeira etapa, onde tínhamos de descobrir o PDA da library, lembra? O segundo é descobrir qual o ID do próximo livro a ser cadastrado. Isso porque os PDAs de livros usam do seu id como seed do endereço.

O próximo ID é uma característica gerenciada pela account de library, então vamos no Web3Service.ts criar uma função que traz essa informação pra gente.

javascript
async function getNextBookId(connection: Connection) {
    const libraryPda = getLibraryPda();
    const account = await connection.getAccountInfo(libraryPda);
    if (!account) throw new Error("Library account not found, the program might not be initialized");
 
    const libraryData = decodeLibraryAccountData(account.data);
    return libraryData.nextId;
}

Na getNextBookId nós pegamos o PDA da library, depois retornamos a account diretamente da blockchain, disparando um erro caso ela não exista. Só que essa account vem codificada em binário, um formato não muito útil para o frontend, certo? Neste caso precisamos de uma função de decodificação, que criaremos como abaixo.

javascript
function decodeLibraryAccountData(data: Uint8Array): DecodedLibraryAccount {
    const decoded = accountsCoder.decode("Library", Buffer.from(data)) as {
        next_id: number | BN;
        authority: PublicKey;
    };
 
    return {
        nextId: BN.isBN(decoded.next_id) ? decoded.next_id.toNumber() : decoded.next_id,
        authority: decoded.authority.toBase58(),
    };
}

Essa função usa o objeto accountsCoder para decodificar os bytes seguindo a lógica que a struct se chama Library e que dentro dela tem next_id e authority.

O próximo desafio para criação de um novo livro, considerando que cada um deles é salvo em seu próprio PDA Account, é derivar seu endereço usando as seeds. Coisa que a função getBookPda vai fazer pra gente.

javascript
function getBookPda(bookId: number) {
    return getPda([
        encoder.encode("book"),
        getLibraryPda().toBytes(),
        Uint8Array.from(new BN(bookId).toArray("le", 4)),
    ]);
}

Atenção especial aqui à passagem do bookId, que é um BN (bignumber) mas que deve ser passado como array de bytes little endian (le). Como nossos bookIds são u32, ou seja usigned integers de 32-bit, o toArray deve usar 4 no parâmetro length pois são 4 bytes por ID.

Agora sim podemos criar a função addBook no Web3Service.ts pois resolvemos todas as dependências de outras funções.

javascript
export async function addBook(book: DecodedBookAccount, wallet: WalletContextState, connection: Connection) {
    if (!wallet.publicKey) throw new Error("Wallet not connected");
 
    const libraryPda = getLibraryPda();
    const nextId = await getNextBookId(connection);
    console.log("Next book ID:", nextId);
 
    const bookPda = getBookPda(nextId);
    console.log("Book PDA:", bookPda.toBase58());
 
    return sendTransaction("add_book", {
        book: {
            title: book.title,
            author: book.author,
            year: book.year,
        },
    }, wallet, connection, [
        { pubkey: libraryPda, isSigner: false, isWritable: true },
        { pubkey: bookPda, isSigner: false, isWritable: true },
        { pubkey: wallet.publicKey, isSigner: true, isWritable: true },
        { pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
    ]);
}

A função addBook espera o livro a ser cadastrado, a carteira do usuário (previamente conectada) e a conexão.

Ela começa pegando o PDA da library e com ele o próximo ID do livro a ser cadastrado. Com essas informações é possível derivar o endereço da PDA Account de livro e com isso enviar a transação de criação do mesmo.

A transação de criação de livro é uma chamada à função “add_book” contendo um book no objeto de instruções. Depois passa-se a wallet, a connection e o array de accounts a serem usadas no contexto da transação, conform exigido pelo programa.

Com a função pronta no Web3Service.ts, podemos partir para a criação de um componente BookForm.tsx para o form de cadastro/edição, a começar pelos imports.

javascript
import { useState } from "react";
import { useConnection, useWallet } from "@solana/wallet-adapter-react";
import type { DecodedBookAccount } from "./Web3Service";
import { addBook } from "./Web3Service";

E depois os states e uso de hooks.

javascript
xport default function BookForm() {
    const { connection } = useConnection();
    const wallet = useWallet();
 
    const [book, setBook] = useState<DecodedBookAccount>({
        id: 0,
        title: "",
        author: "",
        year: 0,
    });
    const [error, setError] = useState("");
    const [isLoading, setIsLoading] = useState(false

O formulário em si é bem simples e não tem nada demais. Apenas vai pegando os valores dos inputs e jogando no state de book.

javascript
return (
    <div style={{ margin: 20 }}>
        <h2>Book Form</h2>
        <p>
            <label>ID: <input type="number" value={book.id} onChange={(evt) => setBook({ ...book, id: parseInt(evt.target.value) })} disabled={isLoading} /></label>
        </p>
        <p>
            <label>Title: <input type="text" value={book.title} onChange={(evt) => setBook({ ...book, title: evt.target.value })} disabled={isLoading} /></label>
        </p>
        <p>
            <label>Author: <input type="text" value={book.author} onChange={(evt) => setBook({ ...book, author: evt.target.value })} disabled={isLoading} /></label>
        </p>
        <p>
            <label>Year: <input type="number" value={book.year} onChange={(evt) => setBook({ ...book, year: parseInt(evt.target.value) })} disabled={isLoading} /></label>
        </p>
        <p>
            <button type="button" onClick={onSaveClick} disabled={isLoading}>
                {isLoading ? "Saving..." : "Save"}
            </button>
        </p>
        <p style={{ color: error.includes("successfully") ? "green" : "red" }}> {error} </p>
        <hr />
    </div>
)

Quando o botão é clicado, dispara uma função onSaveClick como abaixo.

javascript
async function onSaveClick() {
    if (!book.id && (!book.title || !book.author || book.year <= 0)) {
        setError("Please fill in all fields with valid values");
        return;
    }


    if (!wallet.publicKey) {
        setError("Wallet not connected");
        return;
    }


    setError("");
    setIsLoading(true);
    console.log("Saving book:", book);


    try {
        if (book.id === 0) {
            await addBook(book, wallet, connection);
            setError("Book added successfully!");
        }
        setBook({
            id: 0,
            title: "",
            author: "",
            year: 0,
        });
    } catch (err) {
        setError("Save failed: " + (err as Error).message);
    } finally {
        setIsLoading(false);
    }
}

Essa função se encarrega de algumas validações em caso de cadastro de novo livro e de carteira já conectada. Tudo estando certo, ela verifica se é um novo cadastro e chama a função correspondente. A aparência do form pode ser vista abaixo e ele já deve estar funcional para ser usado.

#2 – Pesquisa e Edição de Livro

Antes de fazer a implementação para edição acho importante implementarmos a pesquisa primeiro, já que sem ela não temos como saber se de fato o cadastro e, mais tarde, a edição, estão funcionando.

Para a pesquisa, vamos voltar no Web3Service.ts para criar uma função que decodifica os bytes de um livro para um objeto legível.

javascript
function decodeBookAccountData(data: Uint8Array): DecodedBookAccount {
    const decoded = accountsCoder.decode("Book", Buffer.from(data)) as {
        id: number | BN;
        title: string;
        author: string;
        year: number | BN;
    };


    return {
        id: BN.isBN(decoded.id) ? decoded.id.toNumber() : decoded.id,
        title: decoded.title,
        author: decoded.author,
        year: BN.isBN(decoded.year) ? decoded.year.toNumber() : decoded.year,
    };
}

Já fizemos isso antes, mas para library, lembra? A lógica aqui é a mesma mas ajustando à realidade da struct de livro.

Na sequência, vamos criar a função getBookData, que servirá como função de pesquisa, pois recebe um ID de livro e procura a account dele na blockchain, decodificando os bytes encontrados ou um erro caso não encontre.

javascript
export async function getBookData(bookId: number, connection: Connection) {
    const bookPda = getBookPda(Number(bookId));


    const account = await connection.getAccountInfo(bookPda);
    if (!account) throw new Error("Book not found");


    return decodeBookAccountData(account.data);
}

Agora para a UI, crie um componente BookSearch.tsx com os seguintes imports:

javascript
import { useState } from "react";
import { useConnection, useWallet } from "@solana/wallet-adapter-react";
import { getBookData } from "./Web3Service";

E os seguintes states e uso de hooks.

javascript
export default function BookSearch() {
    const { connection } = useConnection();
    const { publicKey } = useWallet();


    const [bookId, setBookId] = useState("0");
    const [result, setResult] = useState("");
    const [error, setError] = useState("");

Com isso tudo preparado, criar o form de pesquisa é bem simples, como abaixo.

javascript
return (
    <div style={{ margin: 20 }}>
        <h2>Book Search</h2>
        <p>
            <label>Book ID: <input type="number" value={bookId} onChange={(evt) => setBookId(evt.target.value)} /></label>
        </p>
        <p>{result}</p>
        <p>
            <button type="button" onClick={onSearchClick}>Search</button>
        </p>
        <p style={{ color: "red" }}> {error} </p>
        <hr />
    </div>
);

E a função que o botão de pesquisar chama, pode ser vista a seguir.

javascript
async function onSearchClick() {
    try {
        if (!publicKey) {
            setError("Wallet not connected");
            return;
        }


        const book = await getBookData(Number(bookId), connection);
        setResult(JSON.stringify(book, null, 2));
        setError("");
    } catch (err) {
        console.error(err);
        setError("Book not found");
    }
}

Isso te dará uma aparência como abaixo, bem como a funcionalidade de informar um ID de livro e ver se ele existe ou não, o que você deve testar antes de prosseguir.

Terminada a funcionalidade de pesquisar, agora podemos ver se os livros estão sendo cadastrados corretamente. Eles estando, é hora de fazermos a edição, primeiramente adicionando uma nova função no Web3Service.ts para esta finalidade, que usa diversas outras funções que já criamos anteriormente.

javascript
export async function editBook(book: DecodedBookAccount, wallet: WalletContextState, connection: Connection) {
    if (!wallet.publicKey) throw new Error("Wallet not connected");


    const libraryPda = getLibraryPda();
    const bookPda = getBookPda(book.id);


    return sendTransaction("edit_book", {
        id: Number(book.id),
        new_data: {
            title: book.title,
            author: book.author,
            year: book.year,
        },
    }, wallet, connection, [
        { pubkey: libraryPda, isSigner: false, isWritable: true },
        { pubkey: bookPda, isSigner: false, isWritable: true },
    ]);
}

Com a função pronta, basta alguns ajustes no componente BookForm.tsx para que ele funcione para edições também. A primeira é importar a função editBook do Web3Service.ts no topo do componente e a outra é ajustar a função de click do botão para, em caso do id ser preenchido, chamar a edição ao invés de criação, como abaixo.

javascript
if (book.id === 0) {
    await addBook(book, wallet, connection);
    setError("Book added successfully!");
} else {
    await editBook(book, wallet, connection);
    setError("Book updated successfully!");
}

Com isso, matamos agora mais duas funcionalidades do CRUD, faltando apenas a última, de exclusão.

#3 – Exclusão de Livro

A última funcionalidade que vamos adicionar no nosso dapp é de excluir livros previamente cadastrados. Para isso vamos criar uma nova função no Web3Service.ts chamada deleteBook, como abaixo.

javascript
export async function deleteBook(bookId: number, wallet: WalletContextState, connection: Connection) {
    if (!wallet.publicKey) throw new Error("Wallet not connected");


    const libraryPda = getLibraryPda();
    const bookPda = getBookPda(bookId);


    return sendTransaction("delete_book", {}, wallet, connection, [
        { pubkey: libraryPda, isSigner: false, isWritable: true },
        { pubkey: bookPda, isSigner: false, isWritable: true },
        { pubkey: wallet.publicKey, isSigner: true, isWritable: true },
    ]);
}

Esta função não tem absolutamente nenhuma novidade frente à anteriores, então vamos para a criação do componente BookDelete.tsx, a começar pelos seus imports.

javascript
import { useState } from "react";
import { useConnection, useWallet } from "@solana/wallet-adapter-react";
import { deleteBook } from "./Web3Service";

Depois os seus states e uso de hooks.

javascript
export default function BookDelete() {


    const { connection } = useConnection();
    const wallet = useWallet();


    const [bookId, setBookId] = useState("0");
    const [error, setError] = useState("");

Na sequência sua UI (que é quase idêntica à de pesquisa):

javascript
return (
    <div style={{ margin: 20 }}>
        <h2>Book Delete</h2>
        <p>
            <label>Book ID: <input type="number" value={bookId} onChange={(evt) => setBookId(evt.target.value)} /></label>
        </p>
        <p>
            <button type="button" onClick={onDeleteClick}>Delete</button>
        </p>
        <p style={{ color: error.includes("successfully") ? "green" : "red" }}> {error} </p>
        <hr />
    </div>
);

E por último a função de click do botão de exclusão.

aspnet
async function onDeleteClick() {
    try {
        if (!wallet.publicKey) {
            setError("Wallet not connected");
            return;
        }


        await deleteBook(Number(bookId), wallet, connection);
        setError("Book deleted successfully!");
    } catch (err) {
        console.error(err);
        setError("Book not found");
    }
}

Nenhuma novidade nestas etapas. Importe este componente no App.tsx, posicione-o e terá algo como abaixo.

Agora você pode testar todas as operações básicas do CRUD com perfeição, finalizando a nossa implementação de um primeiro dapp web3 React + Solana.

Um abraço e até a próxima!

Pós-graduado em computação, trabalha com software desde 2006 nas mais variadas tecnologias. Empreendedor, autor e professor, quando não está ocupado programando, está escrevendo ou gravando sobre programação para seu canal e blog LuizTools.

Ver perfil