Muitas vezes quando criamos nossos programas Solana nós precisamos incluir funções administrativas, para uso do administrador ou dono (authority) do programa. Às vezes é uma função de mint de um token, às vezes é uma função para atualizar alguma informação no programa ou ainda uma função de sacar o saldo da aplicação para outra conta. Não importa, o fato é que mesmo funções públicas muitas vezes não são para uso do público em geral, mas apenas para uma ou mais pessoas definidas pelo dono do programa.
Neste tutorial vou lhe mostrar duas formas de fazer esse tipo de controle. Uma primeira bem simples e eficaz quando o acesso só possui dois perfis (comum e administrador) e outra com maior granularidade, permitindo roles/perfis.
Primeiro vamos determinar um programa de exemplo sobre o qual vamos aplicar os dois cenários de implementação. Abaixo uma sugestão.
|
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 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
use anchor_lang::prelude::*; declare_id!("HrSMrxddaQQG6A624Lha6dVqqYaf4L8m7QgRx2wGAuX"); #[program] pub mod access_control_patterns { use super::*; pub fn initialize(ctx: Context<InitializeContext>) -> Result<()> { let message_account = &mut ctx.accounts.message; message_account.content = String::from(""); Ok(()) } pub fn set_message(ctx: Context<SetMessageContext>, message: String) -> Result<()> { let message_account = &mut ctx.accounts.message; message_account.content = message; Ok(()) } pub fn clear_message(ctx: Context<ClearMessageContext>) -> Result<()> { let message_account = &mut ctx.accounts.message; message_account.close(ctx.accounts.authority.to_account_info())?; Ok(()) } } #[account] pub struct Message { pub content: String, } #[derive(Accounts)] pub struct InitializeContext<'info> { #[account(init, payer = user, space = 8 + 36)] pub message: Account<'info, Message>, #[account(mut)] pub user : Signer<'info>, pub system_program: Program<'info, System>, } #[derive(Accounts)] pub struct SetMessageContext<'info> { #[account(mut)] pub message: Account<'info, Message>, } #[derive(Accounts)] pub struct ClearMessageContext<'info> { #[account(mut)] pub message: Account<'info, Message>, } |
E os testes:
|
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 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
import assert from "assert"; import * as anchor from "@coral-xyz/anchor"; import { Program } from "@coral-xyz/anchor"; import { AccessControlPatterns } from "../target/types/access_control_patterns"; describe("access-control-patterns", () => { const provider = anchor.AnchorProvider.env(); anchor.setProvider(provider); const user = provider.wallet.publicKey; const program = anchor.workspace.accessControlPatterns as Program<AccessControlPatterns>; let messageKp: anchor.web3.Keypair; beforeEach(async () => { // Cria uma nova conta antes de cada teste messageKp = anchor.web3.Keypair.generate(); await program.methods .initialize() .accounts({ message: messageKp.publicKey, user, systemProgram: anchor.web3.SystemProgram.programId }) .signers([messageKp]) .rpc(); }); it("should set message", async () => { await program.methods.setMessage("Hello, World!") .accounts({ message: messageKp.publicKey }) .rpc(); const messageAccount = await program.account.message.fetch(messageKp.publicKey); assert.strictEqual(messageAccount.content, "Hello, World!"); }); it("should clear message", async () => { await program.methods.setMessage("Hello, World!") .accounts({ message: messageKp.publicKey }) .rpc(); await program.methods.clearMessage() .accounts({ message: messageKp.publicKey }) .rpc(); try { await program.account.message.fetch(messageKp.publicKey); assert.fail("Expected message account to be closed"); } catch (err) { assert.match(err.message, /Account does not exist/); } }); }); |
Imagine que você tem o programa acima onde a função set_message é de acesso geral, mas a função clear_message não, você quer ter mais controle sobre quem pode acessá-la.
Atenção: este não é um tutorial iniciante em Anchor/Rust, ele requer que você já conheça o básico da linguagem, o que pode ser aprendido aqui.
# Opção 1: Macro Require
Você pode validar na chamada de uma função se a account que está assinando a mesma é a mesma account que foi usada na criação da mensagem. Para isso você pode usar a macro require. Primeiro, adicione um CustomError no programa e um campo authority na struct Message. Atenção: o nome tem de ser authority pois ele será referenciado em outros códigos a seguir.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
#[error_code] pub enum CustomError { #[msg("Unauthorized")] Unauthorized, } #[account] pub struct Message { pub content: String, pub authority: Pubkey, } |
Agora que temos um campo para guardar o dono da account de message, vamos inicializar esse dono na função initialize.
|
1 2 3 4 5 6 7 8 |
pub fn initialize(ctx: Context<InitializeContext>) -> Result<()> { let message_account = &mut ctx.accounts.message; message_account.content = String::from(""); message_account.authority = ctx.accounts.user.key(); Ok(()) } |
Ou seja, além de zerar a string, a gente pega o user que assinou o initialize e define ele como authority. Agora com esta informação armazenada, precisamos ajustar o ClearMessageContext para que ele exija o authority também.
|
1 2 3 4 5 6 7 8 9 |
#[derive(Accounts)] pub struct ClearMessageContext<'info> { #[account(mut)] pub message: Account<'info, Message>, pub authority: Signer<'info>, } |
Com o contexto atualizado, podemos usar a macro require na chamada da clear_message para fazer a validação.
|
1 2 3 4 5 6 7 8 9 10 11 12 |
pub fn clear_message(ctx: Context<ClearMessageContext>) -> Result<()> { require!( ctx.accounts.message.authority == ctx.accounts.authority.key(), CustomError::Unauthorized ); let message_account = &mut ctx.accounts.message; message_account.close(ctx.accounts.authority.to_account_info())?; Ok(()) } |
A macro require (atenção à exclamação após o nome, pois é uma macro) exige dois parâmetros:
- a condição booleana de sucesso (neste caso que a authority da mensagem seja a mesma autority que assinou o clear_message);
- o erro que será disparado caso a condição retorne false;
Para testar essa nova versão do clear_message, vamos ajustar o unit test para que inclua o user authority, caso contrário o teste não passará.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
it("should clear message", async () => { await program.methods.setMessage("Hello, World!") .accounts({ message: messageKp.publicKey }) .rpc(); await program.methods.clearMessage() .accounts({ message: messageKp.publicKey, authority: user }) .rpc(); try { await program.account.message.fetch(messageKp.publicKey); assert.fail("Expected message account to be closed"); } catch (err) { assert.match(err.message, /Account does not exist/); } }); |
Agora, para realmente ver que uma outra conta qualquer não conseguirá chamar a clear_message, vamos criar outro unit test.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
it("should NOT clear message", async () => { await program.methods.setMessage("Hello, World!") .accounts({ message: messageKp.publicKey }) .rpc(); const otherUser = anchor.web3.Keypair.generate(); try { await program.methods.clearMessage() .accounts({ message: messageKp.publicKey, authority: otherUser.publicKey }) .signers([otherUser]) .rpc(); assert.fail("Expected message account throw error"); } catch (err) { assert.match(err.message, /Unauthorized/); } }); |
Aqui a gente está passando otherUser como autority, o que fará com que aconteça o disparo do Custom Error Unauthorized em nosso código.
# Opção 2: Constraint has_one
A macro require é interessante principalmente em cenários de validações mais complexas do que mera comparação de endereços. Outra opção, com ainda menos código é usando a constraint has_one, que é completamente focada em ver se um endereço é autoridade/dono sobre outro endereço.
Para isso, volte no ClearMessageContext e modifique-o para que fique como abaixo.
|
1 2 3 4 5 6 7 8 9 |
#[derive(Accounts)] pub struct ClearMessageContext<'info> { #[account(mut, has_one = authority)] pub message: Account<'info, Message>, pub authority: Signer<'info>, } |
Repare ali na macro account como adicionei uma constraint has_one, que diz que esta account message é propriedade do usuário authority, que é definido logo abaixo, no mesmo contexto. Isso simplifica o nosso clear_message novamente, que não precisa mais ter a validação com require e também não precisaremos mais ter Custom Error pois o erro enviado será nativo do has_one. Falando nisso, isso afeta o nosso unit test do cenário de erro, que deve ficar como abaixo pois a mensagem de erro mudará.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
it("should NOT clear message", async () => { await program.methods.setMessage("Hello, World!") .accounts({ message: messageKp.publicKey }) .rpc(); const otherUser = anchor.web3.Keypair.generate(); try { await program.methods.clearMessage() .accounts({ message: messageKp.publicKey, authority: otherUser.publicKey }) .signers([otherUser]) .rpc(); assert.fail("Expected message account throw error"); } catch (err) { assert.match(err.message, /ConstraintHasOne/); } }); |
Mas e se eu precisar de mais de um papel que não somente o de autoridade/administrador?
# Opção 3: Roles
Outra opção muito popular é usando roles: definimos alguns roles/funções em nosso programa e quem possui aquelas roles, além de definir maneiras de validar a permissão de acesso a determinadas funções facilmente através de constraint macros. Vamos começar criando o enum com as roles do programa:
|
1 2 3 4 |
#[derive(AnchorSerialize, AnchorDeserialize, Clone, PartialEq, Eq)] pub enum Role { NONE, OWNER, MANAGER, CUSTOMER } |
Atenção aos parâmetros do Attribute Macro derive, pois eles são necessários para serializari, desserializari, clonar e comparar por igualdade parcial ou completa. Sem esses parâmetros o seu enum vai falhar já na etapa de compilação se ele tiver de ser serializado, desserializado, clonado ou comparado.
Agora vamos criar a struct de um usuário do nosso programa, com a sua chave pública e role:
|
1 2 3 4 5 6 7 |
#[account] pub struct User { pub address: Pubkey, pub role: Role, } |
Para adição de um usuário na aplicação, algo permitido somente para o owner da mesma (definido na inicialização), precisamos de um context.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
#[derive(Accounts)] pub struct AddUserContext<'info> { /// CHECK: não há necessidade de validação aqui, pois a conta do usuário é criada dentro do programa e não é usada para autenticação pub user_wallet: AccountInfo<'info>, #[account(init, payer = payer, space = 8 + 32 + 1, seeds = [b"user", user_wallet.key().as_ref()], bump)] pub user_account: Account<'info, User>, #[account(mut)] pub payer: Signer<'info>, pub system_program: Program<'info, System>, } |
Nesse context recebemos a wallet account do usuário a ser adicionada como usuária do sistema (necessária para a constraint a seguir), a PDA account do usuário no formato wallet + role e usando a wallet ele na seed, o payer e o system program.
Agora o código de adição fica como abaixo:
|
1 2 3 4 5 6 7 8 |
pub fn add_user(ctx: Context<AddUserContext>, role: Role) -> Result<()> { let user_account = &mut ctx.accounts.user_account; user_account.address = ctx.accounts.user_wallet.key(); user_account.role = role; Ok(()) } |
Note como pegamos a wallet do usuário para adicionar o endereço na PDA account, bem como a role passada por parâmetro. Um teste para esta função pode ser visto abaixo. Atenção especial à troca de snake case para camel case feita pelo Anchor automaticamente e que caso você escreva errado, provoca erros silenciosos.
|
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 27 28 29 30 31 32 33 34 35 |
function getUserPda(newUser: anchor.web3.PublicKey) { const [userAccountPda] = anchor.web3.PublicKey.findProgramAddressSync( [Buffer.from("user"), newUser.toBuffer()], program.programId ); return userAccountPda; } async function addUser(newUser: anchor.web3.Keypair, role: "manager" | "customer") { const userAccountPda = getUserPda(newUser.publicKey); await program.methods .addUser({ [role]: {} }) .accounts({ userWallet: newUser.publicKey, userAccount: userAccountPda, payer: user, systemProgram: anchor.web3.SystemProgram.programId }) .rpc(); } it("should add an user", async () => { const newUser = anchor.web3.Keypair.generate(); await addUser(newUser, "manager"); const userAccountPda = getUserPda(newUser.publicKey); const userAccount = await program.account.user.fetch(userAccountPda); assert.strictEqual(userAccount.address.toBase58(), newUser.publicKey.toBase58()); assert.deepStrictEqual(userAccount.role, { manager: {} }); }) |
Agora que vimos como adicionar usuários com roles específicas, vamos falar de exclusão exclusiva para gerentes e donos do programa. Primeiro, vamos criar o contexto.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
#[derive(Accounts)] pub struct RemoveUserContext<'info> { #[account(mut, close = authority_signer)] pub user_account: Account<'info, User>, #[account( mut, seeds = [b"user", authority_signer.key().as_ref()], bump, constraint = authority_user.address == authority_signer.key(), constraint = authority_user.role == Role::OWNER || authority_user.role == Role::MANAGER )] pub authority_user: Account<'info, User>, #[account(mut)] pub authority_signer : Signer<'info>, } |
Aqui precisamos da PDA account do usuário a ser removido, já com a macro close para que ele seja encerrado nessa execução e o rent seja devolvido para authority_signer, que falaremos a seguir.
A wallet account que vai assinar a remoção (authority_signer) deve atender a alguns critérios específicos em sua PDA account tais como:
- o PDA deve seguir a regra de ter o endereço gerado com a mesma chave pública de authority_signer;
- o campo address do PDA deve ser igual à chave pública de authority_signer;
- o PDA deve ter a role OWNER ou MANAGER;
Isso garante que somente usuários previamente cadastrados com roles específicas e que assinem a transação consigam excluir outros usuários. Agora vamos à função de exclusão, que é super simples já que a remoção acontece no próprio context. Atenção ao “_” no ctx para evitar warnings do Rust na compilação por não estarmos usando o context.
|
1 2 3 4 5 |
pub fn remove_user(_ctx: Context<RemoveUserContext>) -> Result<()> { Ok(()) } |
Agora o teste de remoção fica assim e aqui tem algumas pegadinhas.
|
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 27 28 29 30 31 32 33 |
it("should remove user", async () => { const newUser = anchor.web3.Keypair.generate(); await addUser(newUser, "manager"); const managerPda = getUserPda(newUser.publicKey); const newCustomer = anchor.web3.Keypair.generate(); await addUser(newCustomer, "customer"); const customerPda = getUserPda(newCustomer.publicKey); const beforeBal = await provider.connection.getBalance(newUser.publicKey); await program.methods .removeUser() .accounts({ userAccount: customerPda, authorityUser: managerPda, authoritySigner: newUser.publicKey, }) .signers([newUser]) .rpc(); const afterBal = await provider.connection.getBalance(newUser.publicKey); assert.ok(afterBal > beforeBal); try { await program.account.user.fetch(customerPda); assert.fail("Expected user account to be closed"); } catch (err) { assert.match(err.message, /Account does not exist/); } }) |
Primeiro, precisamos adicionar o usuário gerente, depois o usuário cliente. Não esqueça de obter o PDA deles. Como a remoção vai impactar no saldo de quem mandar excluir, pegamos o saldo antes da exclusão também.
Na chamada do removeUser, temos de passar o PDA account do usuário a ser excluído, o PDA account de quem está excluindo (para verificação de role) e o endereço público do signer da transação (quem está excluindo também). Atenção aqui que o Anchor vai trocar o snake case do Rust por camel case e isso pode causar confusão. não esqueça também de assinar a transação com a mesma carteira gerente.
Após a exclusão, verificamos se o saldo do manager foi aumentado (por causa da devolução de rent) e se o PDA do usuário excluído dá erro quando fazemos um fetch.
Outro cenário válido de ser testado é o de não permitir a exclusão quando o usuário requisitante não for um manager ou owner, 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 26 |
it("should NOT remove user", async () => { const newUser = anchor.web3.Keypair.generate(); await addUser(newUser, "customer"); const newUserPda = getUserPda(newUser.publicKey); const newCustomer = anchor.web3.Keypair.generate(); await addUser(newCustomer, "customer"); const customerPda = getUserPda(newCustomer.publicKey); try { await program.methods .removeUser() .accounts({ userAccount: customerPda, authorityUser: newUserPda, authoritySigner: newUser.publicKey, }) .signers([newUser]) .rpc(); assert.fail("Expected user account should NOT to be closed"); } catch (err) { assert.match(err.message, /ConstraintRaw/); } }) |
Não tem muita novidade aqui, exceto que ao criarmos o suposto manager, dei a permissão de customer pra ele, o que fará com que dê erro se ele tentar assinar transações de exclusão de outros usuários.
E com isso finalizamos mais este tutorial de programação Solana aqui no blog.
Até a próxima!
Olá, tudo bem?
O que você achou deste conteúdo? Conte nos comentários.


