NOVO: Helius adquire a Light Protocol
aritmética na Solana — como fazer cálculos com programas ou contratos inteligentes da Solana
Blog/Fundamentos

Aritmética na Solana: práticas recomendadas para criar aplicações financeiras

Educação para desenvolvedoresMike MacCana no XMike MacCana no LinkedIn
9 min de leitura

Agradecemos a 0xIchigo e Lostin pela edição e contribuição para este artigo.

A Solana é um ambiente competitivo — seja trabalhando em um novo protocolo de empréstimos, agregador, mercado de previsões, tokenização de RWA ou qualquer outra solução, pode ser tentador correr para a mainnet.

No entanto, é fundamental lembrar que aplicações blockchain, por gerenciarem valor, são inerentemente aplicações financeiras.

Se você nunca criou aplicações financeiras — seja em uma blockchain ou nas finanças tradicionais — precisa entender a importância da matemática financeira.

Há muitos recursos excelentes sobre temas de programação específicos da Solana — verificar quais contas precisam assinar cada instrução, quais programas são proprietários das contas usadas, evitar ataques de reabertura de contas e assim por diante. As restrições de conta do Anchor facilitam a implementação de muitas dessas verificações, e o Rust oferece alguns padrões inteligentes, como detectar overflows e underflows de inteiros no modo Debug.

Mas a programação financeira segura vai além dos temas específicos da Solana. Um único erro na aritmética de tokens pode causar perdas, inflação não intencional e usuários insatisfeitos. Na Solana, onde o volume de transações é maior do que em outras blockchains, falhas podem ser exploradas ainda mais rapidamente.

Muitas técnicas de programação financeira das finanças tradicionais não têm relação com blockchain — por isso, não recebem a atenção que merecem —, mas ainda são essenciais para proteger os tokens dos seus usuários.

Neste artigo, abordaremos especificamente:

  • O uso de números inteiros e unidades menores
  • Como evitar perda de precisão — multiplicar e depois dividir
  • Políticas consistentes de arredondamento
  • Uso de cálculos de juros sem números de ponto flutuante

Use números inteiros e unidades menores

A falta de precisão é uma vulnerabilidade comum em contratos inteligentes. Quando as operações matemáticas não são precisas, elas podem ficar vulneráveis. Precisamos usar números inteiros e unidades menores para realizar operações matemáticas seguras em aplicações da Solana.

Vamos analisar um exemplo básico.

Nas finanças tradicionais, todas as interações que você já fez “em USD” ocorreram, na verdade, em centavos. Se estivesse usando GBP, elas teriam ocorrido em pence. 

Da mesma forma, suas transações em SOL devem ser processadas em lamports, e suas transações em USDC devem ser processadas em milionésimos de USDC.

Centavos, pence e lamports são tipos de unidades menores (também chamadas de unidades básicas), e o motivo para fazer tudo em unidades menores é simples: computadores não conseguem processar números de ponto flutuante.

Aqui está o número um em binário:

Trinta e doisDezesseisOitoQuatroDoisUm
000001

Aqui está o número nove em binário:

Trinta e doisDezesseisOitoQuatroDoisUm
001001

Mas como representar, por exemplo, 0,3 USDC? 

A resposta correta é: não é possível: 

Código
let answer = 0.1 + 0.2;
msg!("0.1 + 0.2 = {}", answer);

O resultado é:

Registro do programa: "0.1 + 0.2 = 0.30000000000000004"

Em vez disso, trate dólares, GBP, SOL, USDC e qualquer outra “moeda” como uma quantidade inteira de sua unidade menor.

Por exemplo, para somar 0,1 e 0,2 usando USDC:

Código
let answer_ints: u128 = 100000 + 200000;
msg!("100000 + 200000 = {}", answer_ints);

O resultado é:

Registro do programa: "100000 + 200000 = 300000"

Usar unidades menores evita perdas.

Os desenvolvedores devem usar as casas decimais de cada token, lembrando que nem todos os tokens têm o mesmo número de casas decimais.

Usar números inteiros para quantidades de tokens pode parecer óbvio, mas lembre-se de que você precisa usá-los em todos os lugares, não apenas nas quantidades de tokens.

Porcentagens

As porcentagens devem ser representadas por números inteiros. A maioria das pessoas usa pontos-base (às vezes chamados de “bips”), representados como bps.

Por exemplo, 4,74% equivale a 474 bps.

Juros compostos

Os juros compostos não devem ser calculados usando e (o número de Euler), que representa o limite dos juros compostos à medida que a frequência de capitalização se aproxima do infinito.

Embora o uso de e permita a “capitalização contínua”, essencialmente uma linha suave para os juros compostos, o próprio e é representado como um número de ponto flutuante no Rust.

Você terá erros de arredondamento e um resultado diferente ao usar e em vez de outros mecanismos.

Demonstraremos ambos mais adiante neste artigo.

Overflows e underflows

O Rust armazena números inteiros como variáveis de tamanho fixo. Isso significa que, dependendo de a variável ter ou não sinal, ela só pode ocupar uma quantidade específica de espaço na memória.

Por exemplo, o tipo u8 pode armazenar qualquer valor de 0 a 255. No entanto, se armazenarmos um valor fora desse intervalo, ocorrerá um overflow ou underflow.

O que é um overflow?

Um overflow ocorre quando o valor excede a capacidade máxima que o tipo da variável pode armazenar e retorna ao valor mínimo.

Por exemplo, se tentássemos armazenar 256 em um u8, o valor retornaria a 0. O valor 257 retornaria a 1, 258 retornaria a 2, 511 retornaria a 255 e 512 retornaria novamente a 0.

O que é um underflow?

Um underflow ocorre quando o valor fica abaixo do mínimo possível e retorna ao valor máximo. Para um u8, -1 retornaria a 255, -2 retornaria a 254 e assim por diante. Em resumo, um underflow é semelhante a um overflow, mas o comportamento ocorre na direção oposta.

Embora o Rust tenha diversas verificações que fazem um programa entrar em pânico durante a execução em caso de overflow ou underflow, ele não inclui essas verificações quando você compila no modo release. Além disso, o toolchain integrado ao ambiente de desenvolvimento da Solana compila programas da Solana no modo release por padrão. 

Para saber mais sobre overflows e underflows, além de como mitigá-los, consulte nosso guia de segurança para programas da Solana.

Multiplique e depois divida

Muitas vezes, parece natural dividir antes de multiplicar — vejamos o exemplo de criação de um mercado de previsões. Quando um usuário aposta em um resultado vencedor, precisamos calcular o pagamento do prêmio. Os vencedores nos mercados de previsões recebem de acordo com sua participação nas apostas feitas no resultado vencedor. Mentalmente, isso se traduz de forma bastante intuitiva em:

prêmio total ÷ total de apostas no resultado vencedor × valor da aposta 

Isso parece muito natural.

Primeiro, dividimos o prêmio total em pequenas partes que representam uma “cota” dos ganhos e, depois, calculamos quantas cotas entregar a cada pessoa.

Mas, se multiplicarmos primeiro, obteremos um resultado intermediário maior. Isso nos permite reduzir o impacto dos erros de arredondamento da divisão seguinte. 

prêmio total × valor da aposta ÷ total de apostas no resultado vencedor

Exemplo de mercado de previsões

Aqui está uma demonstração rápida. Se fizéssemos o cálculo como pessoas, e não como computadores, a resposta correta seria 10,5.

Vamos tentar dividir primeiro: 

Código
let answer: u128 = 7 / 2 * 3;
msg!("7 / 2 * 3 = {}", answer);

O resultado é:

Registro do programa: "7 / 2 * 3 = 9"

Ao dividir primeiro, perderemos 1,5 unidade menor.

E se multiplicarmos primeiro?

Código
let answer: u128 = 7 * 3 / 2;
msg!("7 * 3 / 2 = {}", answer);

O resultado é:

Registro do programa: "7 * 3 / 2 = 10"

Ao multiplicar primeiro, temos uma perda de arredondamento de apenas 0,5 unidade menor. 

Esse conceito pode ser ampliado para significar: “execute primeiro qualquer operação que aumente a ordem de grandeza”. 

Por exemplo, se você estiver calculando uma expressão mais complexa que usa potências ou raízes, como em cálculos de risco, calcule primeiro as potências e depois as raízes. Isso ocorre porque, na aritmética de ponto fixo, realizar a divisão primeiro pode causar perda de precisão se o quociente for arredondado para baixo antes da multiplicação.

Use uma política de arredondamento consistente

Uma política de arredondamento inconsistente, que cria pequenas diferenças de um único token, pode dar a impressão de que tokens foram perdidos ou criados do nada. Com o tempo, isso pode esgotar a liquidez de um programa ou causar inflação não intencional de tokens.

Como Will Thieme, da Orca, destacou em sua palestra na Breakpoint sobre como evitar armadilhas comuns em programas da Solana, ganhar um token não parece muito.

No entanto, a Solana tem transações reais, no sentido das finanças tradicionais, que incluem várias instruções e taxas de transação baixas. Portanto, é possível incluir em uma transação muitas instruções individuais que exploram erros de diferença de uma unidade e executá-la por um custo muito baixo.

Erros de arredondamento são inevitáveis, mas podem ser controlados com uma política de arredondamento consistente. Decida antecipadamente se você arredondará para cima ou para baixo e como tratará valores intermediários, isto é, 0,5 — arredondar valores intermediários para cima é mais comum e obrigatório em algumas normas financeiras. O mais importante é aplicar essa política de maneira consistente em todo o código.

Ressalvas de arredondamento específicas do Rust

Os desenvolvedores devem conhecer várias funções específicas do Rust que podem causar problemas, pois as operações de arredondamento são uma fonte comum de perda de precisão. A escolha do método de arredondamento pode afetar significativamente a precisão e o comportamento do seu programa.

Arredondamento vs. piso

Por exemplo, a função try_round_u64() arredonda para o número inteiro mais próximo. Se quiséssemos criar um programa que convertesse garantias em liquidez, arredondar para cima poderia gerar mais tokens de liquidez do que o justificado pelas garantias fornecidas. Em vez disso, devemos usar a função try_floor_u64() para arredondar para baixo até o número inteiro mais próximo.

Funções aritméticas de saturação

Além disso, os desenvolvedores costumam usar funções aritméticas saturating_* (por exemplo, saturating_add) para limitar valores aos respectivos máximos e mínimos e evitar overflows e underflows. No entanto, essas funções podem causar perdas sutis de precisão.

Por exemplo, se sua função multiplicar o valor de uma transação por um multiplicador de recompensa e o resultado exceder o valor máximo do tipo da variável do produto, o usuário receberá uma recompensa menor do que deveria.

É essencial ter isso em mente, especialmente porque seu programa deve usar aritmética de ponto fixo.

Use cálculos de juros sem números de ponto flutuante

Uma técnica comum para calcular juros compostos usa o número de Euler, e, mas e é um número de ponto flutuante! Evite usar números de ponto flutuante em cálculos de juros. Em vez disso, use aritmética de ponto fixo.

A biblioteca spl-math da Solana tem PreciseNumber, que, como você pode imaginar, funciona no ambiente Rust mais limitado da Solana e pode representar frações decimais minúsculas, com até 12 casas decimais, mantendo a precisão exata.

Código
use spl_math::precise_number::PreciseNumber;

fn calculate_compound_interest(
   principal: u128,
   rate_basis_points: u128,
   time: u128,
   compounds_per_year: u128,
) -> u128 {
   // Formula: result = principal * (1 + rate/compounds_per_year)^(compounds_per_year * time)
   // Where rate_basis_points is expressed in basis points (500 for 5%)

   // Convert principal to PreciseNumber
   let principal = PreciseNumber::new(principal).unwrap();

   // Convert basis points to decimal percentage (divide by 10000)
   let rate = PreciseNumber::new(rate_basis_points)
       .unwrap()
       .checked_div(&PreciseNumber::new(10_000).unwrap())
       .unwrap();

   // Calculate rate/compounds_per_year
   let rate_per_period = rate
       .checked_div(&PreciseNumber::new(compounds_per_year).unwrap())
       .unwrap();

   // Calculate 'base', which is 1 + rate/compounds_per_year
   let one = PreciseNumber::new(1).unwrap();
   let base = rate_per_period.checked_add(&one).unwrap();

   // Calculate 'total_periods', which is compounds_per_year * time
   let total_periods = compounds_per_year.checked_mul(time).unwrap();

   // Calculate compound_factor, which is (1 + rate/compounds_per_year)^(compounds_per_year * time)
   let compound_factor = base.checked_pow(total_periods).unwrap();

   // Calculate result = principal * compound_factor
   principal
       .checked_mul(&compound_factor)
       .unwrap()
       .to_imprecise()
       .unwrap()
}

Você pode ver as diferenças executando o código:

  • Registro do programa: "Usando spl-math PreciseNumber"
  • Registro do programa: "Investimento de $1000 a 5% por 5 anos, capitalizado 1 vez(es) por ano:"
  • Registro do programa: "Valor final: $1276"

Em comparação, ao usar e:

  • Registro do programa: " Usando e (não faça isso)"
  • Registro do programa: "Investimento de $1000 a 5% por 5 anos, capitalizado 1 vez(es) por ano:"
  • Registro do programa: "Valor final: $1284"

Conclusão

A matemática de tokens é um assunto entediante, mas ignorá-la pode gerar o tipo de emoção que você não quer. Suas aplicações on-chain devem processar tokens com a precisão e a segurança que os usuários exigem.

Neste artigo, abordamos o uso de números inteiros e unidades menores, a multiplicação, como evitar perda de precisão, como manter uma política de arredondamento consistente e como calcular juros sem números de ponto flutuante.

Depois de aprender esses fundamentos de aritmética, peça para outra pessoa — especificamente, uma empresa de auditoria especializada em Solana — revisar seu código antes de lançá-lo na mainnet.

Recursos adicionais

Assine a Helius

Acompanhe as novidades mais recentes do desenvolvimento Solana e receba atualizações quando publicarmos