As primeiras criptomoedas surgiram, a exemplo de Bitcoin, como um meio de substituir as moedas fiduciárias (fiat) que quebraram a economia mundial em 2008. Mas não foi apenas culpa da gestão dos governos sobre o dólar e outras moedas governamentais, mas também culpa da irresponsabilidade e ganância dos bancos e instituições financeiras. A resposta a esse mercado em torno das fiats foi o surgimento do conceito de DeFi, ou Decentralized Finance, as Finanças Descentralizadas, onde temos serviços financeiros rodando de forma autônoma na blockchain usando criptomoedas como dinheiro e smart contracts como contratos.
Você pode por exemplo tomar um empréstimo, aplicar em um fundo de investimento, comprar bens digitais e físicos, jogar jogos, vender sua arte, trocar moedas e muito mais, tudo através dos chamados protocolos DeFi, bastando ter uma carteira cripto com saldo nela. Ah, e isso tudo sem qualquer fronteira, barreira, longos cadastros e outras burocracias inerentes ao sistema financeiro tradicional, ao mesmo tempo que de forma segura e barata.
Pois é, estamos no início de uma completa revolução nos serviços financeiros e para que ela seja viável tecnicamente precisamos de programadores capazes de construir tais protocolos. No tutorial de hoje eu quero te ensinar a construir um primeiro protocolo DeFi, bem simples, mas que servirá como exemplo para você fazer coisas mais interessantes mais tarde.
Em outra oportunidade eu ensinei como fazer isso para blockchains EVM, que usam Solidity, mas desta vez vamos fazer para Solana. Sendo assim, usaremos a linguagem Rust e este não deve ser o seu primeiro tutorial nesta linguagem. Se nunca programou em Rust para Solana antes, comece por este outro aqui.
Vamos lá!
#1 – Entendendo o Protocolo
O primeiro ponto a entender sobre protocolos financeiros (DeFi ou não) é que TODOS eles fazem algum “malabarismo” com seu dinheiro. Peguemos como exemplo os bancos: eles pegam dinheiro emprestado de investidores. Esse investidor pode ser o governo, pode ser empresas privadas (incluindo outros bancos) ou pode ser até mesmo eu e você (pessoas físicas). Ou seja, eles contraem uma dívida, a um juro X.
Mas porque eles fazem isso?
Porque com esse dinheiro que eles pegaram emprestado, eles emprestam para outras pessoas, governos e empresas, a um juro Y, sendo que o lucro dos bancos está na diferença entre X (juro baixo) e Y (juro alto). Esse seria o protocolo financeiro real mais simples possível de entender e quem dera os bancos operassem apenas dessa forma, pois assim não teríamos as crises financeiras mundiais.
Enfim, como simular comportamento semelhante através de um programa na blockchain, a fim de ter um protocolo DeFi de investimento?
Para simplificar, vamos pensar em só “guardar dinheiro”, ao invés de investir. Você já deve ter ouvido histórias ou até ter você mesmo guardado dinheiro embaixo do colchão, em caixas de sapato ou os famigerados cofrinhos em forma de porco, certo? Essa é uma modalidade de poupança, mas não de investimento, mas já servirá para ilustrar a principal mecânica envolvida: protocolos de depósito e saque.
Via de regra, toda account em um programa Solana é uma conta na blockchain, o que permitiria que uma única conta do nosso protocolo recebesse os fundos depositados, registrasse os saldos e permitisse os saques, certo? No entanto, essa abordagem traria diversas falhas fundamentais de segurança e a principal delas seria o Single Point of Failure em torno dessa super account. O “Solana way” de trabalhar esse tipo de protocolo é sempre descentralizar o armazenamento e registro das informações e moedas, para que somente os donos de cada registro possamo movê-los e sejam responsáveis pelos mesmos. Para isso, cada usuário do protocolo terá sua própria PDA account com a estrutura abaixo.
|
1 2 3 4 5 6 7 |
#[account] pub struct Vault { pub owner: Pubkey, pub balance: u64, } |
Em cada “cofre” temos um único dono, representado pelo seu endereço e o saldo do mesmo. O uso dessa struct vai ficar mais claro na prática, então vamos avançar definindo algumas estruturas para os eventos que serão emitidos e também para os erros que podem ser lançados.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
#[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, } |
Criamos dois erros personalizados (para quantia inválida e falta de saldo) e dois eventos (para avisar de depósitos e saques).
Como vamos precisar fazer trasferências de SOL, adicione o import ao use anchor_lang::system_program; no topo do arquivo lib.rs também.
A seguir, vamos falar do depósito.
#2 – Criando o Protocolo de Depósito
Vamos começar definindo o nosso contexto de depósito, que será usado na respectiva função.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
#[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>, #[account(mut)] pub user: Signer<'info>, pub system_program: Program<'info, System>, } |
Aqui estou dizendo que, ao depositar uma quantia, devemos criar uma account PDA para o vault dela se ainda não existir (init_if_needed). Essa opção pode ser insegura dependendo do uso, por isso você deve habilitá-la manualmente no seu cargo.toml da seguinte maneira.
|
1 2 3 4 |
[dependencies] anchor-lang = { version = "0.32.1", features = ["init-if-needed"] } |
Assim, no primeiro depósito, o vault será criado. Nos subsequentes, o usuário usará o mesmo que já tinha. Para garantir que só existirá um por usuário usamos como seeds a palavra “vault” e a chave-pública da carteira do usuário que assinou o depósito (user) e pagou o rent (payer). Já o space foi definido como 48 bytes que é o necessário para o descritor + os campos da struct Vault.
Agora vamos falar da função de depósito.
|
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 |
pub fn deposit(ctx: Context<DepositContext>, amount: u64) -> Result<()> { require!(amount > 0, VaultError::InvalidAmount); // Transfer SOL from user → vault PDA let transfer = system_program::Transfer { from: ctx.accounts.user.to_account_info(), to: ctx.accounts.vault.to_account_info(), }; let cpi_ctx = CpiContext::new(ctx.accounts.system_program.to_account_info(), transfer); system_program::transfer(cpi_ctx, amount)?; // Update stored balance 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(()) } |
Aqui começamos com o uso da macro require! para validar a quantia que está sendo depositada, disparando um erro caso seja inválida.
Depois, montamos o objeto de transferência, informando o from (origem) e to (destino) dos fundos. Com esse objeto + o system_program, criamos o contexto para o CPI (Cross-Program Invocation). Isso é necessário porque o nosso programa vai chamar o programa de sistema para que a transferência ocorra. Sempre que você tem programa chamando programa, ocorre o chamado CPI e exige esse tipo de configuração é necessária.
A transferência em si ocorre na linha system_program::transfer(cpi_tx, amount), onde informamos a quantia a ser transferida e a flag “?” indica que se der erro, a execução deve parar por aqui e retornar erro.
Caso não dê erro, pegamos o vault do contexto, atualizamos seu saldo (usando checked_add para aritmética segura) e emitimos o evento de depósito, que mais tarde pode ser usado para outras aplicações que monitorem nosso programa. Note o uso do unwrap aqui, que é similar ao “?” mas causando panic no sistema em caso de ausência de retorno (por overflow por exemplo).
#3 – Testes de Depósito
Agora vamos falar dos testes dessa função, abaixo os imports dos testes, com atenção ao assert, que vamos usar bastante.
|
1 2 3 4 5 6 |
import * as anchor from "@coral-xyz/anchor"; import { Program } from "@coral-xyz/anchor"; import { DefiProtocolAnchor } from "../target/types/defi_protocol_anchor"; import assert from "assert"; |
Depois, a preparação geral, com as variáveis globais e a instrução before, que vai rodar antes dos testes todos.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
anchor.setProvider(anchor.AnchorProvider.env()); const provider = anchor.AnchorProvider.env(); anchor.setProvider(provider); const user = provider.wallet.publicKey; const program = anchor.workspace.defiProtocolAnchor as Program<DefiProtocolAnchor>; let vaultPda: anchor.web3.PublicKey; before(async () => { [vaultPda] = anchor.web3.PublicKey.findProgramAddressSync( [Buffer.from("vault"), user.toBuffer()], program.programId ); }); |
Como os vaults serão PDA accounts, estamos guardando o seu endereço calculado em uma variável para poder usar mais facilmente nas demais funções. Ele é gerado com as mesmas constraints de seeds do contexto, com a palavra “vault” e os bytes do endereço do usuário dono do vault.
Agora vamos criar uma função que chama o depósito e que vai ser usada em vários testes.
|
1 2 3 4 5 6 7 8 9 10 11 12 |
async function deposit(depositAmount: anchor.BN) { await program.methods .deposit(depositAmount) .accounts({ vault: vaultPda, user, systemProgram: anchor.web3.SystemProgram.programId, }) .rpc(); } |
A chamada à função deposit exige por parâmetro a quantia a ser depositada e que vai ser transferida a partir da carteira de quem assinar esta transação (user). Lembrando que além da quantia a ser depositada, esta carteira vai ter de pagar pelo rent do vault dele e a taxa de transação de depósito.
No primeiro unit test, temos o cenário principal, de sucesso no depósito.
|
1 2 3 4 5 6 7 8 9 10 |
it("should successfully deposit SOL into vault", async () => { const depositAmount = new anchor.BN(1_000_000_000); // 1 SOL await deposit(depositAmount); const vaultAccount = await program.account.vault.fetch(vaultPda); assert.equal(vaultAccount.balance.toNumber(), depositAmount.toNumber()); }); |
Aqui definimos que vamos depositar 1 SOL (ou 1 bilhão de lamports), chamando a função deposit para isso. Para verificar se deu tudo certo, fazemos um fetch no PDA do vault do usuário e conferimentos se o saldo é igual ao valor que depositamos.
Agora vamos falar do cenário de teste de depósito inválido, que fica como abaixo.
|
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 muito o que explicar pois não há muita novidade, então vamos em frente.

#4 – Criando o Protocolo de Saque
Realizar o saque não é muito mais complexo do que fazer um depósito, então vamos começar pelo contexto.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
#[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(mut)] pub user: Signer<'info>, } |
Aqui temos o vault de onde vamos sacar os fundos e a conta do dono dos mesmos (user). Para garantir que a gente acesse o vault correto, as seeds usadas são as mesmas da inicialização (depósito). E para garantir que somente o dono do vault consiga sacar seus fundos, usamos a constraint comparando o vault.owner com o user.
Agora para fazer o saque do vault para o usuário, esbarramos em um problema da Solana. Isso porque não podemos fazer uma CPI como no depósito pois PDAs não podem ser o “from” de transferências. Sim, eles podem receber dinheiro via transferência mas não enviar. Felizmente, como o user é dono do PDA, ele pode simplesmente “pegar” os lamports dele e mover pra onde quiser, usando a função try_borrow_mut_lamports. Para facilitar o processo, vamos criar uma função auxiliar, que deve ficar FORA do escopo do programa (mod).
|
1 2 3 4 5 6 7 8 9 10 11 12 |
fn transfer_lamports(from: &AccountInfo, to: &AccountInfo, amount: u64) -> Result<()> { let mut from_lamports = from.try_borrow_mut_lamports()?; let mut to_lamports = to.try_borrow_mut_lamports()?; // Move SOL **from_lamports -= amount; **to_lamports += amount; Ok(()) } |
A função transfer_lamports vai esperar a referência de duas AccountInfo (atenção ao “&” no tipo), isso porque vamos ter de alterar os lamports delas. Com estas referências, podemos usar uma função nativa das accounts que é o try_borrow_mut_lamports. Essa função “tenta” (pode ser que o sistema rejeite, por falta de permissão) pegar “emprestado” o acesso aos lamports de uma account. Como pretendemos alterar eles, o acesso tem de ser mutável.
Como este acesso é uma referência em cima de outra referência que já tínhamos (recebida por parâmetro), na hora de alterar de fato os lamports nós temos de usar ** (um para cada referência), a fim de que a alteração reflita na sua origem. Ao término da função, os lamports terão sido atualizados no from e no to, desde que o cálculo resulte em uma expressão de “soma zero” (a + b = 0).
Agora falando da função de saque em si, que vai usar a transfer_lamports, nós temos.
|
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 |
pub fn withdraw(ctx: Context<WithdrawContext>, amount: u64) -> Result<()> { require!(amount > 0, VaultError::InvalidAmount); let vault = &mut ctx.accounts.vault; require!(vault.balance >= amount, VaultError::InsufficientBalance); // Update logical balance first vault.balance = vault.balance.checked_sub(amount).unwrap(); transfer_lamports( &vault.to_account_info(), &ctx.accounts.user.to_account_info(), amount, )?; emit!(WithdrawEvent { user: ctx.accounts.user.key(), amount, new_balance: vault.balance, }); Ok(()) } |
Aqui começamos garantindo que a quantia seja válida e que o saldo existente no vault seja suficiente, caso contrário, as macros require! vão garantir a interrupção da execução com os erros apropriados. Passando nas validações, por questões de segurança, atualizamos o saldo do vault em questão, subtraindo de maneira segura (checked_sub) e garantindo que caso aconteça underflow, que a transação seja derrubada e revertida (unwrap).
A transferência em si ficou super facilitada, bastando passar a referência do vault e do user convertidos para AccountInfo. Ao término da transferência, o evento de saque é emitido e tudo termina com sucesso.
#5 – Testes de Saque
Agora que temos a última função implementada, vamos escrever o teste de sucesso para ela, lembrando que, para poder sacar, primeiro temos de depositar.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
it("should successfully withdraw SOL from vault", async () => { const withdrawAmount = new anchor.BN(500_000_000); // 0.5 SOL await deposit(withdrawAmount); const beforeBal = await provider.connection.getBalance(vaultPda); await program.methods .withdraw(withdrawAmount) .accounts({ vault: vaultPda, user, systemProgram: anchor.web3.SystemProgram.programId, }) .rpc(); const afterBal = await provider.connection.getBalance(vaultPda); assert.ok(beforeBal === afterBal + withdrawAmount.toNumber()); }); |
O saque exige a quantia por parâmetro e o PDA do vault, o user como signer e o system_program como contexto. Para a conferência, basta pegar o saldo do vault antes e depois do saque para ver se a quantia bate com o esperado.
Agora, mais um teste com o cenário de falha por falta de 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 |
it("should fail when withdrawing more than available balance", async () => { const excessiveAmount = new anchor.BN(10_000_000_000_000); // 10,000 SOL try { await program.methods .withdraw(excessiveAmount) .accounts({ vault: vaultPda, user, systemProgram: anchor.web3.SystemProgram.programId, }) .rpc(); assert.fail("Should have thrown an error"); } catch (error) { assert.match(error.message, /InsufficientBalance/); } }); |
E um último onde falha porque uma conta está tentando sacar dinheiro de outra.
|
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 |
it("should fail when withdrawing from the wrong account", async () => { const depositAmount = new anchor.BN(1_000_000_000); // 1 SOL await deposit(depositAmount); let newUser = anchor.web3.Keypair.generate(); const withdrawAmount = new anchor.BN(500_000_000); // 0.5 SOL try { await program.methods .withdraw(withdrawAmount) .accounts({ vault: vaultPda, user: newUser.publicKey, systemProgram: anchor.web3.SystemProgram.programId, }) .signers([newUser]) .rpc(); assert.fail("Should have thrown an error"); } catch (error) { assert.match(error.message, /ConstraintSeeds/); } }); |
Com isso, temos um protocolo de “guardar dinheiro embaixo do colchão” funcional e que serve para múltiplos poupadores de SOL. Opcionalmente você poderia modificar este protocolo para permitir que ele suporte outros tokens que não a moeda SOL nativa, isso dificulta um pouco mais mas é perfeitamente possível de fazer.
Além disso, querendo evoluir este protocolo para algo mais completo, você poderia fazer ele se integrar com protocolos de staking, a fim de pegar o dinheiro guardado e aplicá-lo para render, compartilhando com os usuários parte desses dividendos, como acontece em contas remuneradas, por exemplo.
Enfim, o céu é o limite em relação às possibilidades do DeFi e estamos apenas começando.
Na próxima lição, eu vou te ensinar como manipular tokens na Solana, para criar protocolos DeFi ainda mais interessantes.
Olá, tudo bem?
O que você achou deste conteúdo? Conte nos comentários.


