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.
|
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 |
#[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.
|
1 2 3 4 |
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:
|
1 2 3 4 5 6 7 8 |
[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:
|
1 2 3 4 |
[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.
|
1 2 3 |
npm install @solana/spl-token |
E depois carregar ela e a assert no módulo de testes.
|
1 2 3 4 5 6 7 8 9 10 |
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.
|
1 2 3 4 5 6 7 8 9 10 11 12 |
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.
|
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 |
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.
|
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 |
#[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:
|
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 |
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.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
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.
|
1 2 3 4 5 6 7 8 9 10 |
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.
|
1 2 3 4 5 6 7 8 9 10 11 12 |
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.
|
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 |
#[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:
|
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 |
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.
|
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 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.
|
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 |
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.
|
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 |
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!

Olá, tudo bem?
O que você achou deste conteúdo? Conte nos comentários.
