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 Rust 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.
#[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,
}
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:
[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:
[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.
pm install @solana/spl-token
E depois carregar ela e a assert no módulo de testes.
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.
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.
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.
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:
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.
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.
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.
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.
#[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:
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.
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.
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.
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!




