Sempre parece impossível até que seja feito. -Nelson Mandela
Introdução
Qualquer aplicação financeira com um mínimo de complexidade precisará de algum suporte decimal e multiplicações para calcular coisas como juros. No caso de CementDAO precisávamos de logaritmos para implementar a curva de taxas de transação que direciona sua cesta de criptomoedas para a configuração desejada.
O Solidity suporta números inteiros, mas não decimais, por isso codificamos um contrato aritmético de ponto fixo, tornamos-no seguro contra overflow (estouro) e o testamos extensivamente. Ele sustenta a Implantação do CementDAO em Ropsten.
O contrato Fixidity está disponível no site CementDAO github com uma licença do MIT, sinta-se à vontade para usá-la e desenvolvê-la.
Implementação
Para codificar isso, passamos das funções aritméticas mais simples às mais complexas, implementando asserções de estouro, encontrando matematicamente os limites das funções e testando quaisquer casos especiais como zero, um, n, n mais um, limite menos um, limite e limite mais um. Funções de ordem superior foram implementadas para reutilizar funções de ordem inferior.
Dígitos
O número inteiro representável máximo em 256 bits possui 76 dígitos, e decidimos arbitrariamente alocar 24 dígitos para a parte decimal e os outros 52 para a parte inteira. Consideramos que esta é uma escolha justa em termos de capacidades, pois permite a multiplicação de números maiores em troca da perda de precisão de partes fracionárias muito pequenas. Alterar o número de decimais é possível apenas recalculando as constantes do contrato.
Conversão
Se você tivesse um número decimal ou flutuante e quisesse convertê-lo em uma representação de ponto fixo, você deslocaria a vírgula para a direita em um número fixo de posições, ou “dígitos”. Como uma operação matemática, isso é newFixed(x) = x * 10**dígitos. Ao fazer isso com um número decimal, você deve descartar quaisquer decimais restantes após a conversão, mas como estamos trabalhando com Solidity (que não possui decimais), isso não é um problema para nós.
Para converter de volta do ponto fixo, você faz a operação inversa, fromFixed(x) = x / 10**dígitos, tendo em mente que números de ponto fixo muito pequenos seriam arredondados para zero.
O contrato oferece algumas funções úteis para converter entre diferentes representações de ponto fixo, por exemplo, de 24 a 18 dígitos.
Estouro na conversão
A conversão para ponto fixo irá estourar se o número que está sendo convertido tiver mais dígitos inteiros que caibam nos bits reservados para eles na representação de ponto fixo. Se max_int256 == 2*255–1, então max_newFixed = max_int256/10*dígitos.
Da mesma forma, se min_int256 == (-1)2255, min_newFixed = min_int256/10*dígitos. Observe que há mais um número negativo do que números positivos no int256.
No FixidityLib as funções de conversão serão revertidas se você tentar converter um número acima desses limites, mas você deve implementar suas próprias salvaguardas em seus contratos para avisar os usuários com antecedência suficiente de que eles estão lidando com números muito grandes para o contrato.
function newFixed(int256 x)
public
pure
returns (int256)
{
assert(x <= max_newFixed());
assert(x >= min_newFixed());
return x * fixed_1();
}
Um número que será útil em muitos casos é fixo_1, a representação de ponto fixo de 1, que pode ser expresso como fixo_1 = 10**dígitos. Como você pode ver, converter de e para representação de ponto fixo é o mesmo que multiplicar e dividir por fixo_1.
Para extrair as partes inteiras e decimais de um número de ponto fixo você pode usar o mesmo atalho. Integer(x) = (x/fixed_1)*fixed_1 — Esteja ciente de que integer(x) retorna a parte inteira de x, mas ainda na representação de ponto fixo, portanto é o mesmo que substituir todos os dígitos decimais por zeros. Para recuperar apenas a parte decimal podemos usar Fractional(x) = x %fixed_1.
function integer(int256 x) public pure returns (int256) {
return (x / fixed_1()) * fixed_1(); // Não pode estourar
}
function fractional(int256 x) public pure returns (int256) {
return x - (x / fixed_1()) * fixed_1(); // Não pode estourar
}
Usando uma estrutura ao invés de int256
Um dos primeiros comentários que recebemos é que usar uma estrutura para nossos valores de ponto fixo ao invés de int256 seria muito mais seguro, e concordamos. Tal estrutura seria algo como:
Struct Fixed {
int256 value;
uint8 digits; // Ineficiente mas possivelmente necessário
}
Usar uma estrutura ajudaria qualquer aplicativo que usa a biblioteca Fixidity a fazer aplicativos muito mais robustos. Certamente é confuso que um int256 possa ser um número inteiro normal ou um número de ponto fixo, sem nenhuma maneira de saber qual, exceto analisando o fluxo do programa. No entanto, ainda não o codificamos, pois adicionar a estrutura à biblioteca Fixidity criaria um pouco de complexidade interna e podemos gerenciar para nosso caso de uso específico. Aceitamos contribuições neste aspecto como em qualquer outro. Independentemente disso, poderemos fazer esta alteração na próxima vez que implementarmos algo com aritmética de ponto fixo.
Adição e subtração
As operações de adição entre números de ponto fixo são idênticas aos números inteiros normais e nenhum cuidado adicional é necessário para controlar as partes inteiras e decimais. A única parte complicada é que eles podem estourar e max_int + 1 == min_int. A adição de números negativos significa que você também precisa ter cuidado para não estourar na extremidade inferior.
O mecanismo usual de proteção contra estouro é x + y = z; assert(z — y = x), mas isso não funciona no complemento de duas representações de Solidity e me deu um pouco de dor de cabeça.
No final você pode se proteger contra overflow com um pouco de lógica:
- Somente a adição de dois positivos pode estourar.
- Somente a adição de dois negativos pode estourar pelo fundo.
- A adição de um negativo e um positivo nunca pode estourar.
- A adição de um negativo ou de um positivo a zero nunca pode estourar.
- A adição de dois positivos não pode ultrapassar um valor maior que abs(min_int).
- A adição de dois negativos diferentes não pode ultrapassar um valor maior que abs(max_int).
- Se ocorrer um estouro o resultado será zero ou terá sinal oposto ao que seria esperado.
Com essas regras, só precisamos afirmar o seguinte:
function add(int256 x, int256 y) public pure returns (int256) {
int256 z = x + y;
if(x > 0 && y > 0) assert(z > x && z > y);
if(x < 0 && y < 0) assert(z < x && z < y);
return t;
}
function subtract(int256 x, int256 y) public pure returns (int256) {
return add(x,-y);
}
Multiplicação e divisão
A operação de multiplicação é condicionada pelo fato de que um número em representação de ponto fixo é na verdade a seguinte combinação:
fixo(x) = números_àesquerda_da_vírgula(x)10*dígitos() + númerosà_direita_da_vírgula(x)
Nas fórmulas a seguir, isso é escrito como x = x1*fixo1 + x2.
Ao multiplicar dois números de ponto fixo, precisamos estar conscientes de que a vírgula precisa ficar no lugar certo, e, se simplesmente multiplicarmos os números obteremos o resultado errado e muito provavelmente vão estourar ao mesmo tempo:
x = x1*fixed_1 + x2
y = y1*fixed_1 + y2
x * y = (x1*fixed_1 + x2) * (y1*fixed_1 + y2)
x * y = ((x1*fixed_1) * (y1*fixed_1)) + ((x1*fixed_1) * (y2)) + ((y1*fixed_1 * x2)) + (x2 + y2)
Para manter a vírgula no lugar certo, o primeiro termo da decomposição precisa ser trocado de (x1*fixed_1) * (y1*fixed_1) para (x1*y1)*fixed_1, o que ao mesmo tempo torna menos provável estourar.
Ainda precisamos testar se estoura. Isso ocorre porque x1*y1 será maior que max_int se, por exemplo, x1 e y1 forem maiores que sqrt(max_int). Também estamos multiplicando o resultado por fixo_1, o que apresenta outra chance de estouro.
Para o caso de multiplicação, a verificação tradicional de estouro funciona assim:
x * y = z; afirmar (z / y = x)
O que deixa o código de Solidity como:
int256 x1 = integer(x) / fixed_1();
int256 x2 = fractional(x);
int256 y1 = integer(y) / fixed_1();
int256 y2 = fractional(y);
int256 x1y1 = x1 * y1;
if (x1 != 0) assert(x1y1 / x1 == y1);
int256 fixed_x1y1 = x1y1 * fixed_1();
if (x1y1 != 0) assert(fixed_x1y1 / x1y1 == fixed_1());
x1y1 = fixed_x1y1;
Os demais termos da multiplicação não precisam de ajuste para a vírgula, mas ainda precisamos verificar se há estouro em cada operação interna de multiplicação ou adição, pois todos eles podem estourar.
function multiply(int256 x, int256 y) public pure returns (int256) {
if(x == 0 || y == 0) return 0;
if(y == fixed_1()) return x;
if(x == fixed_1()) return y;
// Separar em parte integral e fracionada
// x = x1 + x2, y = y1 + y2
int256 x1 = integer(a) / fixed_1();
int256 x2 = fractional(a);
int256 y1 = integer(b) / fixed_1();
int256 y2 = fractional(b);
// (x1+x2) * (y1+y2) = (x1*y1) + (x1*y2) + (x2*y1) + (x2*y2)
int256 x1y1 = x1 * y1;
if (x1 != 0) assert(x1y1 / x1 == y1); // Overflow x1y1
// x1y1 precisa ser multiplicada novamente por fixed_1
int256 fixed_x1y1 = x1y1 * fixed_1();
if (x1y1 != 0) assert(fixed_x1y1 / x1y1 == fixed_1());
x1y1 = fixed_x1y1;
int256 x2y1 = x2 * y1;
if (x2 != 0) assert(x2y1 / x2 == y1); // Overflow x2y1
int256 x1y2 = x1 * y2;
if (x1 != 0) assert(x1y2 / x1 == y2); // Overflow x1y2
x2 = x2 / mul_precision();
y2 = y2 / mul_precision();
int256 x2y2 = x2 * y2;
if (x2 != 0) assert(x2y2 / x2 == y2); // Overflow x2y2
// resultado = fixed_1()*x1*y1 + x1*y2 + x2*y1 + x2*y2/fixed_1();
int256 result = x1y1;
result = add(result, x2y1); // adicionar checagem para estouro
result = add(result, x1y2); // adicionar checagem para estouro
result = add(result, x2y2); // adicionar checagem para estouro
return result;
}
Assim que a operação de multiplicação estiver pronta, podemos simplesmente codificar a divisão como x / y = x * (1/y); sabendo que 1/y > y e, portanto, não pode estourar. Ainda precisamos manter a vírgula no lugar certo:
function reciprocal(int256 x) public pure returns (int256) {
assert(x != 0);
return (fixed_1()*fixed_1()) / x; // Não pode estourar
}
Introduzimos uma afirmação para interromper divisões por números acima de fixa_1()*fixed_1() pois isso faria reciprocal(x) = 0 e reverteria a divisão.
function divide(int256 x, int256 y) public pure returns (int256) {
if(y == fixed_1()) return x;
assert(y != 0);
assert(y <= max_fixed_divisor());
return multiply(x, reciprocal(y));
}
Como testamos isso?
O teste correto foi a parte mais importante desta implementação. A maioria de nossas suposições e implementações iniciais de funções estavam erradas e só descobrimos isso por meio de testes. É notoriamente difícil ter em mente todas as regras sobre o gerenciamento da vírgula e ainda mais difícil entender quando e como as operações estouram. No total, os testes produziram três vezes mais código que a própria biblioteca.
Ao implementar testes, nossa política era escrever um teste para cada afirmação, um teste para os limites assumidos logo antes de uma função estourar e outro teste para verificar o comportamento em caso de estouro. Achamos que escrever constantes claras para uma operação segura era fundamental e, de certa forma, esses testes tratavam dos limites de operação segura. Um bom exemplo são os testes para a função add:
/**
* @dev a+b. Se algum operador é maior do que max_fixed_add() ela
* pode estourar.
* In solidity max_int256 + 1 = min_int256 and viceversa.
* Test add(max_fixed_add(),max_fixed_add()) returns max_int256()-1
* Test add(max_fixed_add()+1,max_fixed_add()+1) fails
* Test add(-max_fixed_sub(),-max_fixed_sub()) returns min_int256()
* Test add(-max_fixed_sub()-1,-max_fixed_sub()-1) fails
* Test add(max_int256(),max_int256()) fails
* Test add(min_int256(),min_int256()) fails
*/
Para a função de multiplicação tivemos testes adicionais de código, pois um estouro pode acontecer em cada uma das operações internas, então precisávamos depurar todas as combinações de inteiro e fracionário versus o outro inteiro e fracionário. Veja abaixo, esse aqui era um bastardo.
/**
* @dev a*b. Se algum dos operadores é maior do que
* max_fixed_mul() ela pode estourar.
* Test multiply(0,0) returns 0
* Test multiply(max_fixed_mul(),0) returns 0
* Test multiply(0,max_fixed_mul()) returns 0
* Test multiply(max_fixed_mul(),fixed_1()) returns max_fixed_mul()
* Test multiply(fixed_1(),max_fixed_mul()) returns max_fixed_mul()
* Test combinations of (2,-2), (2,2.5), (2,-2.5) and (0.5, -0.5)
* Test multiply(
* fixed_1()/mul_precision(),
* fixed_1()*mul_precision()
* )
* Test multiply(max_fixed_mul()-1,max_fixed_mul())
* equals multiply(max_fixed_mul(),max_fixed_mul()-1)
* Test multiply(max_fixed_mul(),max_fixed_mul())
* returns max_int256()
* Test multiply(max_fixed_mul()+1,max_fixed_mul()) fails
* Test multiply(max_fixed_mul(),max_fixed_mul()+1) fails
*/
Conclusão
Não sabíamos o quão difícil seria, então fizemos. E nos divertimos muito fazendo isso.
Agora temos um contrato que podemos usar e confiar para codificar aplicações financeiras no Solidity, e estamos orgulhosos e confiantes o suficiente para divulgá-lo ao público, sabendo que outros também o acharão útil.
Sinta-se à vontade para fornecer qualquer feedback sobre esta biblioteca, tenho certeza de que ainda deve haver algum bug ou caso extremo escondido em algum lugar onde não conseguimos encontrá-lo.
CementDAO agora está ativo no Ropsten, então Junte-se e dê uma volta no Fixidity!
Agradecimentos
Para Cara Gadi por codificar a biblioteca inicial em ttps://github.com/extraterrestrial-tech/fixidity
Para a equipe CementDAO: Brendan Quinn (1A1Z), Edan Yago (1A1Z), David Carvalhão (Vigília365), Pierre Martin (Vigília365), Bernardo Vieira (TechHQ), e Sergio Pereira (TechHQ).
Este artigo foi escrito por Alberto Cuesta Cañada, e traduzido por Diogo Jorge. O artigo original pode ser encontrado aqui.
Top comments (0)