Quando estamos começando a estudar desenvolvimento de programas para a blockchain Solana, uma das primeiras coisas que temos de entender é sobre Accounts. Resumidamente accounts são carteiras na blockchain (par de chaves) que podem conter dados ou um programa executável. Isso porque a arquitetura da Solana separa essas duas responsabilidades. Não obstante, as accounts precisam ser inicializadas e na sua inicialização o seu espaço total precisa ser definido. E aí começam os problemas.
Problema #1: quanto de espaço meu programa vai precisar?
No exemplo do tutorial anterior, de CRUD de livros, eu aloquei espaço para um cálculo aproximado de 50 livros. Mas e se eu quisesse ter milhares de livros? Teoricamente era só ajustar o espaço de acordo, mas lembre-se que a Solana possui o conceito de rent/aluguel, logo, quanto mais espaço você inicializar na sua account, mais caro vai se tornar para colocar seu programa na blockchain.
Problema #2: quanto de espaço uma account suporta?
Ainda que você tenha recursos para alocar quantidades imensas de espaço, temos problemas ligados à capacidade das accounts. Uma account jamais pode consumir mais de ~10MB de espaço na blockchain. Ou seja, mesmo que dinheiro não seja um problema na inicialização do projeto, vai acabar esbarrando neste outro limit e se a sua account chegar nele, simplesmente o programa não vai conseguir salvar mais dados, gerando erros constantes.
Em virtude disso, em programas onde se deseje adicionar dados indefinidamente, como um CRUD para todos os livros que existem e que ainda vão existir, faz-se necessário mudar de estratégia. Ao invés de um imenso vetor de livros, a estratégia mais profissional (e correta) é um vetor de accounts. Assim, cada livro terá o seu próprio endereço na blockchain, todos indexados na account principal do programa, usando um conceito chamado PDA ou Program Derived Address, que falaremos neste tutorial.
Vamos lá!
#1 – Setup do Projeto
Vou partir do pressuposto que você já está habituado a criar projetos Solana com Anchor e que talvez até já tenha feito meu tutorial anterior de CRUD de livros pois esse aqui é um estudo derivado. Digamos então que você tenha a seguinte estrutura para livros no lib.rs:
|
1 2 3 4 5 6 7 8 9 |
#[account] pub struct Book { pub id: u32, pub title: String, pub author: String, pub year: u16, } |
Nada demais, certo? O id dos livros será autoincremental, definido a partir de outra estrutura que guarda metadados da biblioteca, como abaixo.
|
1 2 3 4 5 6 |
#[account] pub struct Library { pub next_id: u32, } |
Assim, sempre que a gente for cadastrar um novo livro, podemos usar o next_id da library para gerar um novo.
Como toda account, a Library precisa ser inicializada, enquanto que Book somente será usado mais tarde, quando adicionarmos livros. Então abaixo segue a struct para o contexto da função initialize e a dito-cuja.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
#[derive(Accounts)] pub struct InitializeContext<'info> { #[account(init, payer = user, space = 8 + 4, seeds = [b"library"], bump)] pub library: Account<'info, Library>, #[account(mut)] pub user: Signer<'info>, pub system_program: Program<'info, System>, } #[program] pub mod pda_solana_anchor { use super::*; pub fn initialize(ctx: Context<InitializeContext>) -> Result<()>{ ctx.accounts.library.next_id = 1; Ok(()) } //... |
O InitializeContext diz respeito somente à library, que como tem apenas um u32 como propriedade, precisa apenas 8+4 bytes de espaço (8 é para o descritor da account). Sendo um contexto de criação de account, temos ainda o user que vai pagar o rent e o programa de sistema que fará a criação. Já a função initialize apenas inicializa o next_id como 1.
Mas e aqueles seeds e bump ali? Eles são Constraint Seeds, um conceito diretamente relacionado à PDA. Resumidamente eles ditam as regras para o endereço válido da account de library e vou entrar mais detalhes a seguir, mas hora, repare que nossa seed é fixa, a string “library” em bytes (b). Pense nisso em termos de hashing: a mesma palavra sempre trará a mesma saída, certo? Assim, com essa constraint fixa, eu garanto que nosso programa terá somente uma account de library, para todo o sempre, o que chamamos de PDA Singleton. Em situações que queremos permitir várias accounts, podemos ter Constraint Seeds dinâmicos (como faremos logo mais) ou nem usar Constraint Seeds, mas neste último caso apenas útil para situações em que o estado realmente não importa.
Em nossos testes, teremos uma etapa preparatória como abaixo.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
describe("pda-solana-anchor", () => { const provider = anchor.AnchorProvider.env(); anchor.setProvider(provider); const user = provider.wallet.publicKey; const program = anchor.workspace.PdaSolanaAnchor as Program<PdaSolanaAnchor>; const [libraryPda] = anchor.web3.PublicKey.findProgramAddressSync( [Buffer.from("library")], program.programId ); beforeEach(async () => { await program.methods .initialize() .accounts({ library: libraryPda, user, systemProgram: anchor.web3.SystemProgram.programId }) .rpc(); }); |
Aqui carregamos o provider (nó local de Solana), a carteira default do usuário, o programa que construímos e calculamos o endereço fixo da account library que nosso programa usa. Antes de cada teste, chamamos a initialize para alocar o espaço da account library na blockchain. Como cada teste tem de ser independente e estamos usando um endereço único, teremos uma função de limpeza mais à frente, para evitar colisões.
Mas onde entra PDA nisso?
#2 – Introdução a PDA
Quando queremos adicionar dados indefinidamente precisamos aprender a gerar accounts indefinidamente, pensando nelas como “caixinhas” para guardar os dados individualmente. Quer guardar três livros? Vai precisar de três accounts com o espaço de um book cada.
Mas se cada account tem o seu próprio endereço na blockchain, como saber quais accounts são de quais programas? Aí que entram os endereços derivados do programa, ou PDAs (Program Derived Address). Através de um algoritmo determinístico que nós vamos disparar usando seeds definidas pela gente, serão gerados endereços para cada livro que vamos adicionar no programa, de modo que depois consigamos acessar esses endereços sem precisar tomar nota de todos eles, usando apenas o id do livro como referência. No entanto, existem algumas diferenças da geração de accounts PDA de accounts comuns.
A primeira delas é que accounts comuns possuem par de chaves (keypair), ou seja, podem assinar transações, enquanto que PDAs não. PDAs servem apenas para guardar dados mesmo, como se fossem contas “burras”. A segunda diferença é que os endereços PDAs não podem colidir com endereços de accounts comuns e aí entra um outro conceito chamado “bump”, que combinados com as “seeds” vão gerar endereços únicos. Falarei mais a seguir, mas antes disso, vamos dar uma olhada no tipo do contexto de adição de livros.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
#[derive(Accounts)] pub struct AddBookContext<'info> { #[account(mut, seeds = [b"library"], bump)] pub library: Account<'info, Library>, #[account( init, payer = user, space = 8 + 4 + 4 + 32 + 4 + 32 + 2, // derive PDA using library key and next_id to ensure uniqueness per library seeds = [ b"book", library.key().as_ref(), &library.next_id.to_le_bytes() ], bump )] pub book: Account<'info, Book>, #[account(mut)] pub user: Signer<'info>, pub system_program: Program<'info, System>, } |
Primeiro, temos a account de Library, necessária pois usamos o next_id dela e vamos incrementar o mesmo após a adição (motivo dela ser mut/mutável). Repare que usamos aqui a mesma constraint seed da inicicialização, o que garante que estamos falando sempre da mesma library.
Segundo, temos a account de book, que terá de ser inicializada (init) na adição, como o payer sendo o user (Signer), o space sendo calculado com base nos campos de Book e finalmente as seeds que serão usadas na geração dos endereços e a sinalização do uso de bump.
Sobre as seeds, estamos usando três binários como seeds (quanto mais, mais forte, mas mais custo computacional a cada cadastro de livro):
- b”book”: isso quer dizer para pegar os bytes da palavra book (igual fizemos com library);
- library.key().as_ref(): isso quer dizer para pegar os bytes do endereço da account library;
- &library.next_id.to_le_bytes(): isso quer dizer para pegar os bytes do id que será cadastrado nesse livro;
Essas sementes garantem que cada endereço seja único, mas não garantem que ele seja válido pois não pode colidir com um endereço de um keypair (mesmo que ele seque exista, não pode gerar endereço de keypair). Assim, a instrução bump diz para incluir no cálculo uma seed adicional de 1 byte que funciona como “nonce”, ou seja, um número arbitrário de 0 a 255 que é adicionado às seeds para aumentar a força da PDA. Se com o bump 255 gerar um endereço inválido, será tentando com bump 254 e assim sucessivamente.
Mas voltando à struct AddBookContext, os demais campos não são novidades, então vamos ver a função add_book abaixo, que usa uma struct BookInput que também reproduzo abaixo.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
#[derive(AnchorSerialize, AnchorDeserialize, Clone)] pub struct BookInput { pub title: String, pub author: String, pub year: u16, } //... pub fn add_book(ctx: Context<AddBookContext>, book: BookInput) -> Result<()> { let library = &mut ctx.accounts.library; let new_book = &mut ctx.accounts.book; new_book.id = library.next_id; new_book.title = book.title; new_book.author = book.author; new_book.year = book.year; library.next_id += 1; Ok(()) } |
Começamos pegando cópias mutáveis da library e do livro que vamos cadastrar. Com o next_id da library, populamos o campo id do novo livro e com as demais informações fazemos o resto. Antes de concluir a função, incrementamos o next_id para garantir que o próximo livro ganhe um novo id.
Mas o grande “pulo do gato” do uso de PDA vem no client-side, ou neste caso, em nossos unit tests.
#3 – Testando PDAs
Quando vamos nos comunicar com programas que precisem de accounts para guardar dados, precisamos carregar as mesmas e passá-las junto às chamadas, certo? Mas e no caso de PDAs, ou seja, de accounts geradas indefinidamente e deterministicamente? Neste caso precisamos replicar o mesmo algoritmo de geração dos endereços no lado do client-side, para garantir que consigamos carregar as accounts corretas.
Veja abaixo um exemplo de função TS que usaremos em nossos testes, que gera o PDA da account de um livro arbitrário.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function getBookPda(libraryPubkey: anchor.web3.PublicKey, bookId: number) { const [bookPda] = anchor.web3.PublicKey.findProgramAddressSync( [ Buffer.from("book"), libraryPubkey.toBuffer(), Buffer.from( new anchor.BN(bookId).toArray("le", 4) ), ], program.programId ); return bookPda; } |
A função findProgramAddressSync permite que a gente passe um array de seeds binárias e o id do programa para geração de um PDA. Como nossas seeds no programa são a palavra book, o endereço da library e o id do livro, aqui fazemos exatamente a mesma coisa, mas usando TS. Logo, se você passar o endereço da account de library x e o id do livro y, você receberá como retorno o PDA z.
Veja abaixo um exemplo de função TS que cadastra um livro, usando um endereço PDA gerado com a função anterior.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
async function addBook() { const libraryAccount = await program.account.library.fetch(libraryPda); const expectedId = libraryAccount.nextId; const bookPda = getBookPda(libraryPda, expectedId); await program.methods .addBook({ title: "Teste", author: "LuizTools", year: 2024 }) .accounts({ library: libraryPda, book: bookPda, user, systemProgram: anchor.web3.SystemProgram.programId }) .rpc(); } |
Começamos carregando a account de library usando o endereço PDA dela que deixamos preparado no nosso setup inicial do tutorial, lembra? Com essa account a gente consegue descobrir qual será o id que nosso novo livro vai receber e que consequentemente vai influenciar seu PDA, como definido na instrução seguinte, que chama a getBookPda. A variável bookPda agora possui o endereço da account do novo livro que VAI SER cadastrado, ela não existe ainda.
Na linha seguinte chamamos a addBook (variação TS da add_book do Rust), passando os campos do livro e os endereços das accounts envolvidas: library e book, bem como o payer (user) e o system program.
Agora o teste em si dessa adição de livro pode ser visto abaixo:
|
1 2 3 4 5 6 7 8 9 10 11 12 |
it("should add book", async () => { await addBook(); const bookPda = getBookPda(libraryPda, 1); const book = await program.account.book.fetch(bookPda); const books = await program.account.book.all(); assert.ok(books.length === 1); assert.strictEqual(book.id, 1); }); |
Cadastramos o livro, depois geramos o PDA do livro de id 1, carregamos a account a partir do PDA e vemos se ela foi cadastrada com o id certo, se a quantidade total de livros está correta, etc.
#4 – Limpando os Testes
O problema com a abordagem PDA Singleton é que como o Anchor sobe apenas um validador de Solana para os testes, ele acaba gerando dependência estrutural entre os testes, o que não é algo desejável na maioria das vezes. Assim, quando rodo o teste de addBook, ao término dele temos um livro cadastrado na blockchain que pode afetar o teste seguinte. E não, não adianta o initialize no beforeEach porque ele vai dar erro na segunda execução uma vez que o endereço do PDA library é singleton e já estará ocupado.
Enfim, é necessário nesse tipo de cenário que a gente tenha uma função para limpeza entre os testes, que é útil também para o cenário em que eu queira “destruir” os dados do programa ou resetá-lo completamente. Para isso, volte ao lib.rs e crie os seguintes contextos.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
#[derive(Accounts)] pub struct CloseLibraryContext<'info> { #[account( mut, seeds = [b"library"], bump, close = user )] pub library: Account<'info, Library>, #[account(mut)] pub user: Signer<'info>, } #[derive(Accounts)] pub struct CloseBookContext<'info> { #[account(mut, close = user)] pub book: Account<'info, Book>, #[account(mut)] pub user: Signer<'info>, } |
O primeiro é o contexto de fechamento da account library, que necessita da constraint seed para saber o endereço certo e o usuário que fará o fechamento e receberá de volta o rent pago na inicialização. A instrução close na macro é especial para fazer isso, ela indica que a função desse contexto irá fechar a account associada e vai devolver o rent ao user informado.
Já o segundo faz o mesmo, mas para uma account de book qualquer e sem constraint seeds, o que vai permitir que a gente tenha de confiar no endereço passado pelo client-side. Infelizmente como nossos PDAs de book possuem o id deles na seed, não temos verificar isso em tempo de compilação (e as macros rodam em tempo de compilação, lembra?).
Agora precisamos das funções que vão usar esses contextos, que não precisam fazer nada na verdade pois todo o trabalho será feito exclusivamente pelos contextos que já definimos.
|
1 2 3 4 5 6 7 8 9 |
pub fn close_library(_ctx: Context<CloseLibraryContext>) -> Result<()> { Ok(()) } pub fn close_book(_ctx: Context<CloseBookContext>) -> Result<()> { Ok(()) } |
Com as funções prontas, agora vamos no arquivo de testes para criar a função de limpeza que vai ser chamada após cada teste realizado, automaticamente, para fechar as accounts de book e de library, deixando o nó validador de teste “zerado”, o que permitirá um novo initialize antes do próximo teste (before each).
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
afterEach(async () => { const books = await program.account.book.all(); for (const { publicKey } of books) { await program.methods .closeBook() .accounts({ book: publicKey, user, }) .rpc(); } await program.methods .closeLibrary() .accounts({ library: libraryPda, user, }) .rpc(); }); |
Aqui eu começo pegando todas as accounts de livro do programa e para cada uma delas, eu chamo a closeBook. Depois, chamo uma vez o closeLibrary e pronto, nosso validador não tem mais as accounts antigamente populadas.
Para provar que isso está funcionando, vamos escrever um segundo teste, que cobre o cenário de tentar retornar um livro com o PDA errado.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
it("shouldn't get the book (not found)", async () => { await addBook(); const bookPda = getBookPda(libraryPda, 2); try { await program.account.book.fetch(bookPda); assert.fail("Expected an error but did not receive one"); } catch (err) { assert.match(err.message, /Account does not exist/); } }); |
Eu mando gerar o PDA de um livro de id 2, só que não existe um livro com este id já que mandamos adicionar apenas um livro, que provavelmente vai receber id 1 (isso porque o livro do teste anterior foi excluído na limpeza). Isso vai gerar um erro que capturamos no catch e conferimos a mensagem no assert do test.
Experimente rodar com anchor test para ver tudo funcionando como deveria antes de avançar.
#5 – Editando com PDA
A edição de livros é muito mais simples, já que nela não temos a criação de accounts. Veja abaixo o lib.rs:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
#[derive(Accounts)] pub struct EditBookContext<'info> { #[account(mut)] pub book: Account<'info, Book>, } pub fn edit_book(ctx: Context<EditBookContext>, book: BookInput) -> Result<()> { let book_account = &mut ctx.accounts.book; if book.title != "" && book_account.title != book.title {book_account.title = book.title;} if book.author != "" && book_account.author != book.author {book_account.author = book.author;} if book.year > 0 && book_account.year != book.year {book_account.year = book.year;} Ok(()) } |
O contexto é bem simples já que precisamos mexer apenas na account do livro a ser alterado. Já a função em si, pega a cópia mutável de book_account e altera os campos dela que foram passados via BookInput. Nada demais na verdade.
Já o teste fica assim:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
it("should edit book title", async () => { await addBook(); const bookPda = getBookPda(libraryKp.publicKey, 1); const bookBefore = await program.account.book.fetch(bookPda); await program.methods .editBook({ title: "Updated Title", author: "", year: 0 }) .accounts({ book: bookPda }) .rpc(); const bookAfter = await program.account.book.fetch(bookPda); assert.strictEqual(bookAfter.title, "Updated Title"); assert.strictEqual(bookAfter.author, bookBefore.author); assert.strictEqual(bookAfter.year, bookBefore.year); }); |
Começamos adicionando um livro, gerando seu PDA, verificando os dados dele antes da edição e depois mandando fazer a edição (repare como estou alterando apenas o título). Após a edição, fazemos nova consulta no endereço do mesmo PDA para verificar se somente o título mudou.
#6 – Excluindo com PDA
E para finalizar, temos a exclusão. Enquanto que no CRUD anterior a gente excluiu removendo elementos do array, aqui nós temos de encerrar as accounts de livros que não queremos mais, com o bônus de que quando encerramos uma account, temos a devolução do rent pago pela mesma, motivo pelo qual temos um Signer no contexto dessa função, como abaixo.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
#[derive(Accounts)] pub struct DeleteBookContext<'info> { #[account(mut)] pub book: Account<'info, Book>, #[account(mut)] pub authority: Signer<'info>, } pub fn delete_book(ctx: Context<DeleteBookContext>) -> Result<()> { let book_account = &mut ctx.accounts.book; book_account.close(ctx.accounts.authority.to_account_info())?; Ok(()) } |
A função delete_book precisa via context do book a ser removido e do authority que vai receber o rent de volta. A função close da account faz o fechamento da mesma e transfere para o endereço passado por parâmetro os lamports restantes do rent. Atenção ao “?” no final da chamada que significa: “se der erro, pára tudo aqui”.
Note que esta é outra abordagem para o fechamento de contas, mais manual. Outra opção seria no DeleteBookContext, na macro da account book incluirmos um close=authority, assim o corpo da função delete_book poderia estar vazio como fizemos com close_book antes.
Agora o teste do delete é bem simples como abaixo, onde começo adicionando o livro e pegando o saldo do usuário antes da exclusão para conferência posterior.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
it("should delete book and refund lamports to authority", async () => { await addBook(); const bookPda = getBookPda(libraryKp.publicKey, 1); const beforeBal = await provider.connection.getBalance(user); await program.methods .deleteBook() .accounts({ book: bookPda, authority: user, }) .rpc(); const afterBal = await provider.connection.getBalance(user); assert.ok(afterBal > beforeBal); try { await program.account.book.fetch(bookPda); assert.fail("Expected book account to be closed"); } catch (err) { assert.match(err.message, /Account does not exist/); } }); |
Após a exclusão, além de conferir se o saldo do usuário aumentou em virtude da devolução do rent, eu tento consultar novamente aquele PDA que agora deve dar erro porque não existe mais. Roda os testes e você verá algo como abaixo.

E com isso terminamos este nosso tutorial de PDA que espero que tenha lhe ajudado a compreender esse conceito tão importante de programação para Solana.
Até a próxima!
Olá, tudo bem?
O que você achou deste conteúdo? Conte nos comentários.



