Dev (Back & Front)ARTIGO

Manipulando tokens em Solana com Rust e Anchor

Manipulando tokens em Solana com Rust e Anchor
Imagem: Luiz Fernando Duarte Junior

Recentemente escrevi um tutorial aqui no blog ensinando como criar um novo token na blockchain Solana utilizando a Solana Program Library (SPL), que é a forma oficial de fazer isso. No tutorial de hoje quero ensinar como você usa tokens já existentes na criação de protocolos DeFi neste mesmo ecossistema, usando programação RustRust7 conteúdosDesmistificando Rust: a linguagem segura e rápida que você precisa conhecerDev (Back & Front) · out 2024Como criar seu primeiro Programa em Rust com Solana PlaygroundDev (Back & Front) · mai 2026Rust no ranking: a segurança de memória perdeu a guerra corporativa?Marketing Tech · abr 2026Ver tudo em Dev (Back & Front) com o framework Anchor.

Para que você consiga aproveitar e entender este tutorial, é necessário que você já entenda os fundamentos de programação DeFi em Solana, além de entender os fundamentos do funcionamento de tokens nesta mesma rede.

Vamos lá!

#1 – Estrutura geral do protocolo

Pegando como base o tutorial anterior de programação DeFi e adaptando-o para que use um spl-token ao invés de SOL, podemos iniciar o desenvolvimento mantendo algumas estruturas básicas que nos servem também na nova configuração. Para uma explicação detalhada delas, procure o tutorial anterior.

r


#[account]
pub struct Vault {
    pub owner: Pubkey,
    pub balance: u64,
}


#[error_code]
pub enum VaultError {
    #[msg("Amount must be greater than zero")]
    InvalidAmount,


    #[msg("Insufficient balance")]
    InsufficientBalance,
}


#[event]
pub struct DepositEvent {
    pub user: Pubkey,
    pub amount: u64,
    pub new_balance: u64,
}


#[event]
pub struct WithdrawEvent {
    pub user: Pubkey,
    pub amount: u64,
    pub new_balance: u64,
}
Nosso protocolo fará as mesmas duas operações de depósito e de saque, mas agora de spl-token, padrão 2022 (mais recente). Para isso, vamos usar a biblioteca anchor-spl que nos fornece algumas dependências que vão facilitar muito o nosso desenvolvimento. Importe-as no topo do arquivo lib.rs.
r
use anchor_spl::associated_token::get_associated_token_address_with_program_id;
use anchor_spl::token_interface::{self, Mint, TokenAccount, TokenInterface, TransferChecked}

Mas para que ela esteja disponível, você deve ir no Cargo.toml mais próximo (que fica na pasta do programa) e ajustar as configurações abaixo, sendo que as demais configurações devem permanecer inalteradas:

javascript
[features]
idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"]


[dependencies]
anchor-lang = { version = "0.32.1", features = ["init-if-needed"] }
anchor-spl = "0.32.1"

E no Cargo.toml mais distante, que fica na raiz do projeto, eu precisei fazer esse ajuste:

javascript
[patch.crates-io]
blake3 = { git = "https://github.com/BLAKE3-team/BLAKE3", tag = "1.8.2" }

Agora falando dos unit tests, vamos precisar instalar a biblioteca @solana/spl-token via NPM.

aspnet
pm install @solana/spl-token

E depois carregar ela e a assert no módulo de testes.

javascript

import assert from "assert";
import {
  TOKEN_2022_PROGRAM_ID,
  createMint,
  getAssociatedTokenAddress,
  createAssociatedTokenAccount,
  mintTo,
} from "@solana/spl-token";

Já nas variáveis globais dos testes, teremos algumas bem conhecidas e outras específicas dessa bateria de testes.

javascript
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);
const user = provider.wallet.publicKey;


const program = anchor.workspace.tokenProtocolAnchor as Program<TokenProtocolAnchor>;


let vaultPda: anchor.web3.PublicKey;


let mint: anchor.web3.PublicKey;
let userTokenAccount: anchor.web3.PublicKey;

A saber:

  • provider: comunicação com a blockchain de testes;
  • user: carteira que vai rodar os testes;
  • program: o programa a ser tetado (TokenProtocolAnchor neste caso);
  • vaultPda: o PDA do vault que usaremos nos testes;
  • mint: o endereço do spl-token que vamos usar nos testes;
  • userTokenAccount: a token account do user que vamos usar nos testes;

Algumas dessas variáveis, bem como outros preparativos, nós faremos na função before, que executará antes do primeiro teste.

javascript
before(async () => {
  [vaultPda] = anchor.web3.PublicKey.findProgramAddressSync(
    [Buffer.from("vault"), user.toBuffer()],
    program.programId
  );


  // create Token2022 mint and give user some tokens
  mint = await createMint(
    provider.connection,
    provider.wallet.payer,
    provider.wallet.publicKey,
    null,
    9, // decimals
    undefined,
    undefined,
    TOKEN_2022_PROGRAM_ID
  );


  await createAssociatedTokenAccount(
    provider.connection,
    provider.wallet.payer,
    mint,
    user,
    undefined,
    TOKEN_2022_PROGRAM_ID
  );


  // mint some tokens to user account
  userTokenAccount = await getAssociatedTokenAddress(mint, user, false, TOKEN_2022_PROGRAM_ID);
  await mintTo(
    provider.connection,
    provider.wallet.payer,
    mint,
    userTokenAccount,
    provider.wallet.publicKey,
    10_000_000_000, // 10 tokens with decimals 9
    undefined,
    undefined,
    TOKEN_2022_PROGRAM_ID
  );
});

Começamos a before calculando o endereço PDA do vault e guardando na respectiva variável.

Depois, criamos um novo spl-token mint account para os testes, a fim de simular um token real. A createMint espera a conexão com a blockchain, a carteira que vai pagar pelo rent, a pubkey que vai ser a authority de emissão da nova moeda, de freeze (null), o número de casas decimais, mas dois parâmetros opcionais e por último o program id a ser utilizado na sua criação, sendo que aqui estamos trabalhando com o padrão mais recente que é o spl-token-2022.

Na sequência, criamos o ATA para o usuário de testes com a função createAssociatedTokenAccount, que espera a conexão, o pagador do rent, o token mint account, o usuário dono dessa account e por último o program id a ser usado.

Por fim, usamos a getAssociatedTokenAddress para calcular o ATA do nosso usuário dos testes e com essa informação mintamos algumas moedas para que ele tenha saldo nos testes (10 tokens em lamports).

Com isso fizemos os preparativos iniciais.

#2 – Depósito de SPL-Token

Para conseguirmos fazer o depósito de um spl-token, precisamos primeiro criar o contexto para o mesmo, como abaixo.

r
#[derive(Accounts)]
pub struct DepositContext<'info> {
    #[account(
        init_if_needed,
        payer = user,
        space = 8 + 32 + 8, // discriminator + Pubkey + u64
        seeds = [b"vault", user.key().as_ref()],//one per user
        bump
    )]
    pub vault: Account<'info, Vault>,


    /// mint of the token being deposited (must correspond to both token accounts)
    #[account(mint::token_program = token_program)]
    pub mint: InterfaceAccount<'info, Mint>,


    /// user token account holding the custom SPL token
    #[account(
        mut,
        token::mint = mint,
        token::authority = user,
        token::token_program = token_program,
        constraint = user_token_account.key() == get_associated_token_address_with_program_id(&user.key(), &mint.key(), &token_program.key())
    )]
    pub user_token_account: InterfaceAccount<'info, TokenAccount>,


    /// token account owned by the vault PDA, initialized if needed
    #[account(
        init_if_needed,
        payer = user,
        seeds = [b"vault-token", user.key().as_ref()],
        bump,
        token::mint = mint,
        token::authority = vault,
        token::token_program = token_program
    )]
    pub vault_token_account: InterfaceAccount<'info, TokenAccount>,


    #[account(mut)]
    pub user: Signer<'info>,


    pub token_program: Interface<'info, TokenInterface>,


    pub system_program: Program<'info, System>,


    pub rent: Sysvar<'info, Rent>,
}

Com os campos sendo:

  • vault: controle do nosso protocolo sobre qual é o saldo depositado de cada usuário. Conta com init_if_needed para ser criado se ainda não existir (que deve estar habilitado no Cargo.toml) e usa como seed a pubkey do usuário para garantir que cada um tenha apenas um vault;
  • mint: a Mint Token Account, que representa a moeda que estamos trabalhando com a constraint de que o programa que a criou deve ser o mesmo que está sendo passado mais embaixo. Repare também que uso o tipo InterfaceAccount ao invés de Account, pois é para spl-token-2022;
  • user_token_account: a Token Account que vai depositar, que deve ser a ATA do usuário e ter sido criada com o programa de token presente no contexto;
  • vault_token_account: a Token Account do vault, que irá receber o depósito e, mais tarde, irá fazer as transferências de saque. Essa conta é criada automaticamente se necessário, é única por usuário (usa a pubkey na seed) e tem como constraints adicionais que o token mint dessa nova account seja o mesmo do campo mint do contexto, que o authority dessa nova account seja o vault e que o program seja o mesmo sendo passado;
  • user: usuário que vai fazer/assinar o depósito;
  • token_program: o programa SPL-Token que vai fazer a transferência de depósito, sendo que aqui estamos usando o tipo TokenInterface pois estamos usando spl-token-2022;
  • system_program: o programa de sistema que vai criar as accounts;
  • rent: propriedade exigida internamente pela spl-token para conseguir pagar pela token account criada (se houver);

Atenção aqui à dinâmica entre user_token_account e vault_token_account. No depósito, o saldo sai da user_token… e vai para vault_token…, enquanto que no saque é o contrário.

Agora vamos à função de depósito, que vai usar esse contexto, como abaixo:

r
pub fn deposit(ctx: Context<DepositContext>, amount: u64) -> Result<()> {
    require!(amount > 0, VaultError::InvalidAmount);
    require!(ctx.accounts.user_token_account.amount >= amount, VaultError::InsufficientBalance);


    // Transfer SPL tokens from user → vault token account
    let cpi_accounts = TransferChecked {
        from: ctx.accounts.user_token_account.to_account_info(),
        mint: ctx.accounts.mint.to_account_info(),
        to: ctx.accounts.vault_token_account.to_account_info(),
        authority: ctx.accounts.user.to_account_info(),
    };
    let cpi_ctx = CpiContext::new(ctx.accounts.token_program.to_account_info(), cpi_accounts);
    token_interface::transfer_checked(cpi_ctx, amount, ctx.accounts.mint.decimals)?;


    // Update vault state
    let vault = &mut ctx.accounts.vault;
    vault.owner = ctx.accounts.user.key();
    vault.balance = vault.balance.checked_add(amount).unwrap();


    emit!(DepositEvent {
        user: ctx.accounts.user.key(),
        amount,
        new_balance: vault.balance,
    });


    Ok(())
}

Começamos testando se a quantia é válida com a macro require! e jogando o custom error se necessário. O mesmo para a verificação de saldo na token account do usuário.

Depois, como a transferência ocorre com nosso programa chamando o programa spl-token, temos de fazer uma Cross-Program Invocation (CPI). Iniciamos a mesma configurando um objeto TransferChecked onde informamos a account de from (user), to (vault), o token (mint) e a authority que vai assinar a transferência (user).

Na sequência carregamos um novo CpiContext com o token program e cpi_accounts e mandamos realizar a transferência com ele.

Terminada a transferência, é hora de atualizar o vault, para controle próprio do nosso programa, o que fazemos setando o owner correto do mesmo e o novo saldo, incrementado de maneira segura, com checked_add e unwrap. Ao término da atualização dos controles, emitimos o evento de depósito.

#3 – Testes de Depósito

Agora que temos tudo programado para realizar depósitos, vamos voltar ao nosso arquivo de testes. Vamos começar criando uma função que faz somente o depósito em si, que vamos usar em vários testes.

javascript
async function deposit(depositAmount: anchor.BN) {
  const [vaultTokenPda] = anchor.web3.PublicKey.findProgramAddressSync(
    [Buffer.from("vault-token"), user.toBuffer()],
    program.programId
  );


  await program.methods
    .deposit(depositAmount)
    .accounts({
      vault: vaultPda,
      mint,
      userTokenAccount,
      vaultTokenAccount: vaultTokenPda,
      user,
      tokenProgram: TOKEN_2022_PROGRAM_ID,
      systemProgram: anchor.web3.SystemProgram.programId,
      rent: anchor.web3.SYSVAR_RENT_PUBKEY,
    })
    .rpc();
}

Essa função espera a quantidade a ser depositada e a primeira coisa que faz é calcular o PDA do token account do vault que irá receber o depósito, lembrando que é apenas um vault e um vault token account por usuário.

Aí chamamos a função deposit informando a amount e passando em accounts o vault PDA, o mint token account, o user token account, o vault token account, o usuário que está depositando, o id do programa spl-token, o id do programa system e a sysvar de rent para uso interno do spl-token.

Agora vamos escrever um primeiro cenário de depósito bem sucedido, onde o usuário deposita 1 token em nosso protocolo.

javascript
it("should successfully deposit tokens into vault", async () => {
  const depositAmount = new anchor.BN(1_000_000_000); // 1 token (9 decimals)


  await deposit(depositAmount);


  const vaultAccount = await program.account.vault.fetch(vaultPda);
  assert.equal(vaultAccount.balance.toNumber(), depositAmount.toNumber());
});

Começamos definindo a quantidade, na sequência chamamos a função de depósito e por fim verificamos o vault para ver se o saldo foi atualizado corretamente.

Agora vamos esrever outro teste, um com cenário de falha no depósito.

javascript
it("should fail when depositing zero or negative amount", async () => {
  const invalidAmount = new anchor.BN(0);


  try {
    await deposit(invalidAmount);
    assert.fail("Should have thrown an error");
  } catch (error) {
    assert.match(error.message, /InvalidAmount/);
  }
});

Aqui não tem nada de especial, é o mesmo teste que já tínhamos do protocolo anterior.

#4 – Saque de SPL-Token

Para conseguirmos fazer o saque de um spl-token, precisamos primeiro criar o contexto para o mesmo, como abaixo.

r
#[derive(Accounts)]
pub struct WithdrawContext<'info> {
    #[account(
        mut,
        seeds = [b"vault", user.key().as_ref()],
        bump,
        constraint = vault.owner == user.key()
    )]
    pub vault: Account<'info, Vault>,


    #[account(mint::token_program = token_program)]
    pub mint: InterfaceAccount<'info, Mint>,


    #[account(
        mut,
        token::mint = mint,
        token::authority = user,
        token::token_program = token_program,
        constraint = user_token_account.key() == get_associated_token_address_with_program_id(&user.key(), &mint.key(), &token_program.key())
    )]
    pub user_token_account: InterfaceAccount<'info, TokenAccount>,


    #[account(
        mut,
        seeds = [b"vault-token", user.key().as_ref()],
        bump,
        token::mint = mint,
        token::authority = vault,
        token::token_program = token_program
    )]
    pub vault_token_account: InterfaceAccount<'info, TokenAccount>,


    #[account(mut)]
    pub user: Signer<'info>,


    pub token_program: Interface<'info, TokenInterface>,
}

Com os campos sendo:

  • vault: controle do nosso protocolo sobre qual é o saldo depositado de cada usuário. Aqui ele espera que já tenha sido criado, através da chamada de depósito provavelmente, apenas usando seeds e bump como constraints, além de uma constraint específica para garantir que somente o owner do vault consiga chamar a função;
  • mint: a Mint Token Account, que representa a moeda que estamos trabalhando. Ela usa o tipo InterfaceAccount pois usa o padrão spl-token-2022 e aliás isso é reforçado através de constraint;
  • vault_token_account: a Token Account do vault, que irá transferir o saque para o usuário. Essa conta já foi criada e usa as constraints para garantir que somente o owner faça o saque e somente da moeda (mint) e programa corretos;
  • user_token_account: a Token Account do usuário que vai sacar;
  • user: usuário que vai fazer/assinar o saque;
  • token_program: o programa SPL-Token que vai fazer a transferência de saque, no padrão 2022 (TokenInterface);

Note que diferente do context de depósito, aqui não precisamos do system program pois não há criação de novas contas e também não precisamos da informação de rent pelo mesmo motivo.

Agora vamos à função de saque, que vai usar esse contexto, como abaixo:

r
pub fn withdraw(ctx: Context<WithdrawContext>, amount: u64) -> Result<()> {
    require!(amount > 0, VaultError::InvalidAmount);
    require!(ctx.accounts.vault.balance >= amount, VaultError::InsufficientBalance);


    let user_key = ctx.accounts.user.key();
    let user_key_ref = user_key.as_ref();
    let (_vault_key, vault_bump) = Pubkey::find_program_address(&[b"vault", user_key_ref], ctx.program_id);
    let vault_seeds: &[&[u8]] = &[b"vault", user_key_ref, &[vault_bump]];
    let signer_seeds: &[&[&[u8]]] = &[vault_seeds];


    let new_balance = ctx.accounts.vault.balance.checked_sub(amount).unwrap();
    ctx.accounts.vault.balance = new_balance;


    // perform CPI transfer from vault token account to user token account
    let cpi_accounts = TransferChecked {
        from: ctx.accounts.vault_token_account.to_account_info(),
        mint: ctx.accounts.mint.to_account_info(),
        to: ctx.accounts.user_token_account.to_account_info(),
        authority: ctx.accounts.vault.to_account_info(),
    };
    let cpi_ctx = CpiContext::new_with_signer(
        ctx.accounts.token_program.to_account_info(),
        cpi_accounts,
        signer_seeds);
    token_interface::transfer_checked(cpi_ctx, amount, ctx.accounts.mint.decimals)?;


    emit!(WithdrawEvent {
        user: ctx.accounts.user.key(),
        amount,
        new_balance: ctx.accounts.vault.balance,
    });


    Ok(())
}

Começamos testando se a quantia é válida com a macro require! e jogando o custom error se necessário, incluindo um segundo teste para ver se o vault tem saldo (balance) suficiente também.

Na sequência temos de fazer algo ligeiramente complexo que é gerar as signer seeds do vault PDA. Isso porque PDAs não podem assinar transações por padrão, pois eles não possuem chaves privadas. Assim, para que o próprio protocolo possa fazer a chamada em nome do vault PDA ele precisa ter as seeds dele, incluindo o bump. Isso é feito derivando seu endereço novamente e depois regerando os bytes de suas seeds + bump.

Antes de fazer a transferência em si, atualizamos o balance do vault, seguindo o padrão Checks-Effects-Interactions.

Como a transferência ocorre com nosso programa chamando o programa spl-token, temos de fazer uma Cross-Program Invocation (CPI). Iniciamos a mesma configurando um objeto TransferChecked onde informamos a account de from (vault), token (mint), to (user) e a authority que vai “assinar” a transferência (vault).

Na sequência carregamos um novo CpiContext com o token program e cpi_accounts e mandamos realizar a transferência com ele assinando a mesma com signer_seeds.

Terminada a transferência, o vault já foi atualizado lá no início da função, então apenas emitimos o evento de saque.

#5 – Testes de Saque

Agora que temos tudo programado para realizar saques, vamos voltar ao nosso arquivo de testes. Nossos testes de saque vão precisar usar a função de depósito, já que infelizmente os testes acabam compartilhando a mesma infraestrutura e estado dos programas e accounts. Vamos escrever um primeiro cenário de saque bem sucedido, onde o usuário saca meio token em nosso protocolo.

javascript
it("should successfully withdraw tokens from vault", async () => {
  const withdrawAmount = new anchor.BN(500_000_000); // 0.5 token


  await deposit(withdrawAmount);


  const [vaultTokenPda] = anchor.web3.PublicKey.findProgramAddressSync(
    [Buffer.from("vault-token"), user.toBuffer()],
    program.programId
  );


  const beforeBal = await provider.connection.getTokenAccountBalance(vaultTokenPda);


  await program.methods
    .withdraw(withdrawAmount)
    .accounts({
      vault: vaultPda,
      mint,
      vaultTokenAccount: vaultTokenPda,
      userTokenAccount,
      user,
      tokenProgram: TOKEN_2022_PROGRAM_ID,
    })
    .rpc();


  const afterBal = await provider.connection.getTokenAccountBalance(vaultTokenPda);


  assert.ok(
    beforeBal.value.uiAmount ===
      afterBal.value.uiAmount! + withdrawAmount.toNumber() / 10 ** 9
  );
});

Logo após a variável de quantidade e o depósito inicial seream inicializados, nós derivamos o PDA do token account do vault e pegamos o saldo inicial usando a função getTokenAccountBalance que já vem pronta na @solana/web3.js especialmente para lidar com spl-tokens (injetada no objeto connection do Anchor).

A chamada em si para o saque dispensa grandes explicações, apenas atenção à passagem correta dos parâmetros, que são o vault PDA, o mint token account, o vault token account (PDA também), o user token account, o signer (user) e o programa spl-token (2022).

Após o saque, pega-se uma nova amostra do saldo e verifica-se se atualizou corretamente.

Agora vamos escrever outro cenário para cobrir a possibilidade de não haver saldo suficiente para o saque.

javascript
it("should fail when withdrawing more than available balance", async () => {
  const excessiveAmount = new anchor.BN(10_000_000_000_000); // way more tokens
  const [vaultTokenPda] = anchor.web3.PublicKey.findProgramAddressSync(
    [Buffer.from("vault-token"), user.toBuffer()],
    program.programId
  );


  try {
    await program.methods
      .withdraw(excessiveAmount)
      .accounts({
        vault: vaultPda,
        mint,
        vaultTokenAccount: vaultTokenPda,
        userTokenAccount,
        user,
        tokenProgram: TOKEN_2022_PROGRAM_ID,
      })
      .rpc();


    assert.fail("Should have thrown an error");
  } catch (error) {
    assert.match(error.message, /InsufficientBalance/);
  }
});

Aqui não tem novidades na programação em si, apenas na lógica do teste.

E por fim, vamos escrever também um unit test para cobrir o cenário de uma tentativa de saque de moedas que não são suas.

javascript

it("should fail when withdrawing from the wrong account", async () => {
  const depositAmount = new anchor.BN(1_000_000_000); // 1 token
  await deposit(depositAmount);


  let newUser = anchor.web3.Keypair.generate();
  const withdrawAmount = new anchor.BN(500_000_000); // 0.5 token
  const [vaultTokenPda] = anchor.web3.PublicKey.findProgramAddressSync(
    [Buffer.from("vault-token"), user.toBuffer()],
    program.programId
  );


  try {
    await program.methods
      .withdraw(withdrawAmount)
      .accounts({
        vault: vaultPda,
        mint,
        vaultTokenAccount: vaultTokenPda,
        userTokenAccount,
        user: newUser.publicKey,
        tokenProgram: TOKEN_2022_PROGRAM_ID,
      })
      .signers([newUser])
      .rpc();


    assert.fail("Should have thrown an error");
  } catch (error) {
    assert.match(error.message, /ConstraintSeeds/);
  }
});

Aqui a única diferença é a geração de uma nova wallet account apenas para ser usada na assinatura da transação, a fim de forçar que seja alguém diferente tentando fazer o saque das moedas do usuário padrão dos testes (que é o real dono delas).

Agora se você rodar um anchor build, deve ter o seguinte resultado.

Espero que tenha gostado de mais esse tutorial.

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