WEB3DEV

Cover image for Explicação de um Programa Simples de Token Vesting
Fatima Lima
Fatima Lima

Posted on

Explicação de um Programa Simples de Token Vesting

Image description
Solana Beach, California

Introdução

Ao longo do último ano, tenho aprendido Desenvolvimento em Solidity/Ethereum e me tornei relativamente proficiente nisso (para fins de aprendizado, construí uma DEX, um bot de arbitragem nessa DEX, entre outros). Embora eu realmente goste de Solidity, tenho desejado ampliar meus horizontes há um tempo, e as férias de inverno foram uma ótima oportunidade para fazer isso.

Nas últimas semanas, tenho aprendido como a Solana funciona e tenho criado contratos no framework Anchor. Embora tenha sido uma curva de aprendizado acentuada, acabei criando um contrato simples de Token Vesting que será explicado neste artigo. Entrei nesse projeto sem saber como escrever uma única linha de Rust e saí com uma ótima visão geral de como Solana e Anchor funcionam, além de experiência em escrever contratos inteligentes em uma linguagem diferente de Solidity.

Por que estou escrevendo este artigo?

Estou escrevendo este artigo por vários motivos, mas o principal deles é fornecer mais explicações sobre os programas Anchor. Ao longo do tempo em que tentei aprender Solana, encontrei alguns problemas, mas um dos principais foi o fato de não haver muitos recursos disponíveis para ajudar a entender o desenvolvimento de Solana além de simples tutoriais de introdução. Eu entendia o básico, mas quando passava disso, ficava muito vago. Este artigo pretende fornecer uma explicação para um programa Solana mais complicado, mantendo-o, ao mesmo tempo, amigável para iniciantes.

Por que criei este projeto?

O desenvolvimento de Solana é um nicho fantástico. Quando alguém está tentando entrar em qualquer tipo de desenvolvimento on-chain, geralmente a primeira coisa que aparece é aprender Solidity - essa não é uma escolha ruim, pois pode ser aplicada a todas as cadeias compatíveis com EVM, tem muitos recursos para aprender, é bastante simples de entender e foi onde eu comecei também. Minha opinião sobre a situação é que ser um desenvolvedor de Solana/Anchor competente é muito mais atraente do que ser um desenvolvedor de Solidity competente, porque há muito mais desenvolvedores de Solidity. Essencialmente, quando se olha para os desenvolvedores de Solana, há pouca oferta, o que leva a uma alta demanda, e se eu puder me posicionar corretamente nessa curva, minhas habilidades serão muito mais requisitadas.

Por fim, acho que Solana é incrivelmente legal e realmente acredito na tecnologia. A capacidade de processar transações em velocidades extremamente rápidas, as taxas de transação muito baixas em comparação com outras cadeias e o fato de a proof-of-history (prova de histórico) ser muito inovadora são motivos que me levam a acreditar que Solana é algo realmente único e é um projeto com o qual quero me familiarizar.

Mergulhando no Código

No restante do artigo, vou me aprofundar no código e ajudá-lo a entender conceitualmente como ele funciona.

Entendendo o Básico

Token vesting é um mecanismo no qual o proprietário de um token/DAO pode distribuir ou “fazer um vest" de uma determinada quantidade de tokens para quaisquer beneficiários que desejar quando determinadas condições predefinidas forem atendidas. Existem alguns padrões diferentes que são comumente usados no vesting de tokens (o vesting baseado em tempo é muito comum), mas esse contrato está emulando um padrão no qual metas off-chain são atingidas e, em seguida, o proprietário pode liberar uma determinada porcentagem de seus tokens para os beneficiários.

Neste artigo, assumirei que você tem experiência com o desenvolvimento de Anchor e de Solana. Você pode ver o código-fonte completo aqui, mas vamos analisar função por função para entender como o contrato funciona.

Há três partes principais do código que funcionam juntas (Accounts, Contexts e Functions) e compreender como cada uma delas funciona em conjunto é essencial para entender como um programa Solana funciona. Examinaremos as Accounts (contas) como uma seção e, em seguida, mergulharemos em cada Function (função) e examinaremos o Context (contexto) correspondente. Ao longo de minhas explicações, evitarei explicar as coisas muito simples e tentarei apontar apenas as partes exclusivas do código.

Accounts

Neste contratos temos 2 contas:

#[derive(Default, Copy, Clone, AnchorSerialize, AnchorDeserialize)]
pub struct Beneficiary {
    pub key: Pubkey,           
    pub allocated_tokens: u64, 
    pub claimed_tokens: u64,   
}

#[account]
#[derive(Default)]
pub struct DataAccount {
    pub percent_available: u8, 
    pub token_amount: u64,     
    pub initializer: Pubkey,   
    pub escrow_wallet: Pubkey,
    pub token_mint: Pubkey, 
    pub beneficiaries: Vec<Beneficiary>,
    pub decimals: u8
}
Enter fullscreen mode Exit fullscreen mode

Primeiramente, temos a DataAccount que armazena valores que contêm o estado do contrato. Haverá apenas uma DataAccount dentro de sua instância do programa, o que significa que haverá apenas uma de cada dessas variáveis.

Dentro da DataAccount, temos um Vector (vetor) de contas Beneficiary. Essencialmente, o criador do contrato criará uma struct Beneficiary para cada conta para a qual deseja obter tokens com o campo allocated_tokens (claimed_tokens será sempre 0 (zero) na inicialização). Em seguida, ele inicializará uma DataAccount com um array de todas as estruturas, juntamente com o restante dos valores necessários, o que nos permite implementar quantos beneficiários quisermos (até atingirmos o tamanho máximo de 10 KB para PDAs).

Funções

Há três funções dentro desse programa que fornecem funcionalidade completa: initialize, release e claim. Analisaremos cada função e veremos o Context (contas que ela receberá) e a implementação real da função.

Initialize 💻
Essa função faz toda a configuração para o vesting. Para usar essa função, você deve gerar os PDAs fora da cadeia usando as sementes como abaixo. A função initialize basicamente define todas as variáveis necessárias para uso futuro e, em seguida, transfere tokens da ATA (conta de token de endereço) do remetente para o PDA escrow_walletdo contrato em que somente o programa tem controle sobre eles.

Contas Initialize

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(
        init,
        payer = sender,
        space = 8 + 1 + 8 + 32 + 32 + 32 + 1 + (4 + 50 * (32 + 8 + 8) + 1), // Pode levar 50 contas para  vest
        seeds = [b"data_account", token_mint.key().as_ref()], 
        bump
    )]
    pub data_account: Account<'info, DataAccount>,

    #[account(
        init,
        payer = sender,
        seeds=[b"escrow_wallet".as_ref(), token_mint.key().as_ref()],
        bump,
        token::mint=token_mint,
        token::authority=data_account,
    )]
    pub escrow_wallet: Account<'info, TokenAccount>,

    #[account(
        mut,
        constraint=wallet_to_withdraw_from.owner == sender.key(),
        constraint=wallet_to_withdraw_from.mint == token_mint.key()
    )]
    pub wallet_to_withdraw_from: Account<'info, TokenAccount>,

    pub token_mint: Account<'info, Mint>,

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

    pub system_program: Program<'info, System>,

    pub token_program: Program<'info, Token>,
}
Enter fullscreen mode Exit fullscreen mode

A função initialize está recebendo várias contas, mas há alguns pontos que quero destacar. Em primeiro lugar, a data_account e escrow_wallet não são contas normais, mas sim PDAs - isso porque ambas contêm valores que só devem ser alterados pelo próprio programa e não pelo implementador do programa.

Além disso, podemos examinar a conta wallet_to_withdraw_from que é a ATA do remetente. Observando as restrições dadas a ela, podemos ver que o remetente da transação deve ser o proprietário da ATA para permitir que o remetente transfira os tokens para a escrow wallet e o endereço de cunhagem deve ser o da mesma cunhagem que estamos enviando como a conta token_mint. Com isso, também podemos falar sobre a segurança dos programas Solana e tirar algumas conclusões a partir disso:

Há dois motivos pelos quais os programas da Solana são mais seguros do que os da Ethereum. Um deles é que poucas pessoas conhecem o desenvolvimento da Solana e, portanto, não sabem quais são os vetores de ataque comuns e como quebrar os contratos. O segundo, porém, é que a forma como os programas Solana são escritos oferece muito pouca margem em termos do que você pode inserir neles. Quando uma função é chamada, cada conta é verificada para garantir que ela siga o que foi determinado e, se algo não estiver correto, a instrução é revertida. Isso significa que Anchor e Solana fazem muito do trabalho pesado e permitem que você se concentre em escrever o que é importante, em comparação com Solidity, em que você tem uma infinidade de instruções require em todo o seu código.

Função Initialize

pub fn initialize(ctx: Context<Initialize>, beneficiaries: Vec<Beneficiary>, amount: u64, decimals: u8) -> Result<()> {
    let data_account = &mut ctx.accounts.data_account;
    data_account.beneficiaries = beneficiaries;
    data_account.percent_available = 0;
    data_account.token_amount = amount;
    data_account.decimals = decimals;
    data_account.initializer = ctx.accounts.sender.to_account_info().key();
    data_account.escrow_wallet = ctx.accounts.escrow_wallet.to_account_info().key();
    data_account.token_mint = ctx.accounts.token_mint.to_account_info().key();

    let transfer_instruction = Transfer {
        from: ctx.accounts.wallet_to_withdraw_from.to_account_info(),
        to: ctx.accounts.escrow_wallet.to_account_info(),
        authority: ctx.accounts.sender.to_account_info(),
    };

    let cpi_ctx = CpiContext::new(
        ctx.accounts.token_program.to_account_info(),
        transfer_instruction,
    );

    token::transfer(cpi_ctx, data_account.token_amount * u64::pow(10, decimals as u32))?;

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

A lógica real dessa função é muito simples: estamos apenas inicializando os valores de data_account corretamente e, em seguida, transferindo um amount de tokens da ATA do remetente (wallet_to_withdraw_from) para a escrow_wallet. O motivo pelo qual estamos multiplicando o token_amount por essa equação é que, ao transferir tokens, você precisa enviar o valor total + as casas decimais. Por exemplo, isso significa que enviar 100 tokens em um token com 6 casas decimais exigiria, na verdade, o envio de 100000000 tokens (100 com mais 6 zeros).

Para fazer a transferência, executaremos uma CPI para o programa de token. Uma CPI é uma Cross Program Invocation (Invocação entre Programas), que é essencialmente um programa interagindo com outro programa. Ao criar o contexto para uma CPI, você pode usar new ou new_with_signer,dependendo se estiver usando um PDA ou não. No nosso caso, como a autoridade NÃO é um PDA, podemos usar new para o Context da nossa CPI e, em seguida, chamar a função transfer no token com o contexto e a quantidade de tokens que queremos enviar.

Release 💻
Essa função libera uma determinada porcentagem de tokens para todos os beneficiários, para que eles possam reivindicá-los quando quiserem. Em essência, estamos apenas alterando a variável percent_available dentro da DataAccount.

Contas Release

#[derive(Accounts)]
#[instruction(data_bump: u8)]
pub struct Release<'info> {
    #[account(
        mut,
        seeds = [b"data_account", token_mint.key().as_ref()], 
        bump = data_bump,
        constraint=data_account.initializer == sender.key()
    )]
    pub data_account: Account<'info, DataAccount>,

    pub token_mint: Account<'info, Mint>,

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

    pub system_program: Program<'info, System>,
}
Enter fullscreen mode Exit fullscreen mode

A conta Release é incrivelmente simples. A primeira coisa que vale a pena observar é que, na verdade, não usamos token_mint na função, mas, em vez disso, estamos levando a conta porque o PDA data_account é gerado usando sua chave como semente e, toda vez que você quiser usar um PDA, precisará calcular o endereço novamente.

Em segundo lugar, desejamos observar a #[instruction()] acima da struct. Quando você chama uma macro instruction,ela irá examinar a função real que está sendo chamada e pegar esse valor dos parâmetros para que você possa usá-lo na struct. Nesse caso, ao chamar release, passamos o data_bump para que possamos obter o PDA para a data_account.

Função Release

pub fn release(ctx: Context<Release>, _data_bump: u8, percent: u8 ) -> Result<()> {
    let data_account = &mut ctx.accounts.data_account;

    data_account.percent_available = percent;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

A função release faz uma coisa: altera o valor percent_available dentro da data_account para que seja o que o proprietário deseja. Podemos ver que estamos passando a variável _data_bump, mas nunca a usamos dentro da função. Isso é o que eu disse anteriormente sobre a macro instruction dentro da struct. Ela não é necessária para a função, mas precisamos desse bump para obter o PDA para a conta de dados.

Claim 💻
Essa função deve ser chamada por um beneficiário que tenha tokens alocados para ele a fim de reivindicar esses tokens em sua carteira. Ela é bastante semelhante à função initialize em termos de código, mas há algumas diferenças importantes que as diferenciam bastante, sendo a principal delas o fato de o beneficiário estar chamando essa função e não o proprietário.

Contas Claim

#[derive(Accounts)]
#[instruction(data_bump: u8, wallet_bump: u8)]
pub struct Claim<'info> {
    #[account(
        mut,
        seeds = [b"data_account", token_mint.key().as_ref()], 
        bump = data_bump,
    )]
    pub data_account: Account<'info, DataAccount>,

    #[account(
        mut,
        seeds=[b"escrow_wallet".as_ref(), token_mint.key().as_ref()],
        bump = wallet_bump,
    )]
    pub escrow_wallet: Account<'info, TokenAccount>,

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

    pub token_mint: Account<'info, Mint>,

    #[account(
        init_if_needed,
        payer = sender,
        associated_token::mint = token_mint,
        associated_token::authority = sender,
    )]
    pub wallet_to_deposit_to: Account<'info, TokenAccount>,

    pub associated_token_program: Program<'info, AssociatedToken>, // Na verdade, não o utilizamos na instrução, mas o usamos para a conta wallet_to_deposit_to    
    pub token_program: Program<'info, Token>,

    pub system_program: Program<'info, System>,
}
Enter fullscreen mode Exit fullscreen mode

Não há muito nessa estrutura que não tenha sido demonstrado nas duas funções anteriores. Um aspecto a ser destacado é que não usamos a conta associated_token_program na instrução, mas ela é necessária para que seja possível usar as restrições de associated_token na ATA wallet_to_deposit_to que será o Beneficiary que está chamando a ATA da função.

Função Claim

pub fn claim(ctx: Context<Claim>, data_bump: u8, _escrow_bump: u8) -> Result<()> {
        let sender = &mut ctx.accounts.sender;
        let escrow_wallet = &mut ctx.accounts.escrow_wallet;
        let data_account = &mut ctx.accounts.data_account;
        let beneficiaries = &data_account.beneficiaries;
        let token_program = &mut ctx.accounts.token_program;
        let token_mint_key = &mut ctx.accounts.token_mint.key();
        let beneficiary_ata = &mut ctx.accounts.wallet_to_deposit_to;
        let decimals = data_account.decimals;

        let (index, beneficiary) = beneficiaries.iter().enumerate().find(|(_, beneficiary)| beneficiary.key == *sender.to_account_info().key)
        .ok_or(VestingError::BeneficiaryNotFound)?;

        let amount_to_transfer = ((data_account.percent_available as f32 / 100.0) * beneficiary.allocated_tokens as f32) as u64;
        require!(amount_to_transfer > beneficiary.claimed_tokens, VestingError::ClaimNotAllowed); // Permitido reivindicar tokens
        // Lógica da transferência:
        let seeds = &["data_account".as_bytes(), token_mint_key.as_ref(), &[data_bump]];
        let signer_seeds = &[&seeds[..]];

        let transfer_instruction = Transfer{
            from: escrow_wallet.to_account_info(),
            to: beneficiary_ata.to_account_info(),
            authority: data_account.to_account_info(),
        };

        let cpi_ctx = CpiContext::new_with_signer(
            token_program.to_account_info(),
            transfer_instruction,
            signer_seeds
        );

        token::transfer(cpi_ctx, amount_to_transfer * u64::pow(10, decimals as u32))?;
        data_account.beneficiaries[index].claimed_tokens = amount_to_transfer;

        Ok(())
}
Enter fullscreen mode Exit fullscreen mode

O conceito mais exclusivo a ser destacado nessa função é a maneira como estamos encontrando o endereço do beneficiário - como os beneficiários são armazenados em um vetor, a única maneira é fazer um loop no vetor e verificar cada chave para ver se ela corresponde à chave do remetente. Se corresponder, temos a correspondência e a execução continua; se não corresponder, lançamos o erro BeneficiaryNotFound. Isso parece bastante ineficiente, e a solução lógica para isso seria, em vez de usar um vetor, usar um HashMap ou uma estrutura de dados de pesquisa O(1) semelhante. Infelizmente, o Anchor suporta apenas algumas estruturas de dados, e o HashMap não é uma delas, portanto, somos forçados a fazer um loop por todos os beneficiários.

Outro aspecto a ser observado é que estamos usando new_with_signer em vez de new ao transferir os tokens. Isso ocorre porque a autoridade é um PDA e, nesse caso, é o nosso PDA data_account. Por esse motivo, precisamos passar nossas sementes e bump para poder assinar a transação.

Conclusão

Esse projeto me ajudou a aprender muito nas últimas semanas, e estou animado para ver que tipo de projetos poderei criar em um futuro próximo. Se alguma dessas informações não fizer sentido, sinta-se à vontade para entrar em contato comigo e eu poderei ajudar a explicar!

Referências

Abaixo estão algumas referências que me ajudaram muito durante todo o processo. Recomendo fortemente a leitura dessas fontes como uma introdução ao desenvolvimento da Solana.

The Anchor Book

Solana Cookbook

Learning How to Build on Solana

How to Write Your First Anchor Program in Solana

Solana 101

Soldev

Using PDAs and SPL Token in Anchor

Esse artigo foi escrito por Rohan Suri e traduzido por Fátima Lima. O original pode ser lido aqui.

Top comments (0)