Cuando trabajamos con Solidity, la pérdida de precisión es uno de esos problemas sutiles pero potencialmente devastadores que pueden colarse en tu código sin que te des cuenta… hasta que ya es demasiado tarde. Un pequeño error de cálculo puede provocar desajustes financieros importantes, mal reparto de recompensas o incluso abrir la puerta a vulnerabilidades. Entender cómo y por qué ocurre es clave para escribir contratos inteligentes seguros y fiables.
Pero, ¿por qué Solidity tiene este problema de precisión en primer lugar?
La razón está en que Solidity se ejecuta sobre la Ethereum Virtual Machine (EVM), la cual no soporta operaciones con números decimales (punto flotante). Esto es una decisión de diseño, ya que en blockchain todas las operaciones deben ser deterministas para que todos los nodos de la red lleguen al mismo resultado.
Para solucionar esto, los desarrolladores trabajamos con aritmética de punto fijo. Es decir, usamos enteros para representar números decimales, escalándolos previamente según una precisión determinada.
Por ejemplo, si un token tiene 18 decimales, en lugar de guardar 1.5
, almacenamos:
1.5 * 10**18 = 1_500_000_000_000_000_000;
Esto permite cálculos deterministas, sí, pero también introduce riesgos. Si no se gestiona bien, la pérdida de precisión se puede ir acumulando, provocando discrepancias financieras notables con el tiempo. Vamos a ver los errores más comunes y cómo evitarlos.
Divide primero, llora después
La división entre enteros en Solidity trunca los decimales en lugar de redondear. Es decir, siempre redondea hacia abajo. Por eso, si divides demasiado pronto en un cálculo, es fácil perder precisión.
Aunque este patrón es bastante conocido, sigue apareciendo constantemente—especialmente en protocolos grandes donde los cálculos se reparten entre varias funciones o incluso contratos distintos, dificultando su detección en las auditorías.
Un ejemplo real de un contest de Code4rena
Veamos un ejemplo sutil de un bug detectado durante una competición de Code4rena:
Prueba de concepto
El resultado: el usuario recibe casi un 50% menos de lo que le corresponde, simplemente por dividir antes de multiplicar.
Lección: siempre multiplica antes de dividir
Como Solidity redondea hacia abajo, lo correcto es multiplicar antes de dividir para conservar la máxima precisión posible. En sistemas donde se manejan grandes volúmenes o millones de operaciones, esos pequeños errores pueden suponer pérdidas enormes.
La trampa del Redondeo a Cero
Incluso multiplicando antes de dividir, puedes encontrarte con que valores muy pequeños se acaban redondeando a cero. Esto ocurre sobre todo cuando estás trabajando con cantidades muy pequeñas en contextos de baja precisión.
¿Cómo evitarlo?
- Asegúrate de probar tus cálculos con casos límite, especialmente con valores muy pequeños.
- Pregúntate si redondear a cero es aceptable en tu lógica, y si no lo es, haz que el contrato revierta la operación si se llega a ese punto.
Cuidado con los tokens con distinta precisión
No todos los tokens usan la misma cantidad de decimales. ETH y la mayoría de los tokens ERC-20 usan 18 decimales, pero algunos como USDC solo usan 6. Si mezclas tokens en un cálculo sin tener en cuenta estas diferencias, vas a obtener resultados incorrectos.
Ejemplo de error:
Este código falla porque los valores están en escalas distintas. Siempre debes convertirlos a una precisión común antes de operar:
Los peligros del type casting inseguro
Solidity es un lenguaje fuertemente tipado, lo que significa que cada variable debe tener un tipo de dato definido en tiempo de compilación. Para los enteros, Solidity ofrece variantes con signo (int
) y sin signo (uint
) que van desde 8 hasta 256 bits. Esto permite optimizar el uso de memoria y gas, pero también introduce ciertos riesgos.
Una fuente común de errores está en el type casting, o conversión de tipos. Muchos desarrolladores asumen que, si un valor no cabe en el nuevo tipo, Solidity lanzará un error. Sin embargo, eso no es así: las conversiones no están comprobadas por defecto. Solidity realiza la conversión de manera silenciosa, recortando bits o interpretándolos de forma diferente según el tipo destino. Esto puede generar resultados inesperados y difíciles de depurar.
Veamos tres casos clásicos donde el casting puede dar lugar a problemas de precisión y lógica.
Desbordamiento al reducir el tamaño
Cuando conviertes un entero de un tipo más grande a otro más pequeño (por ejemplo, de uint16
a uint8
), Solidity no lanza ningún error. En su lugar, recorta los bits más significativos (los de la izquierda), dejando solo los bits que caben en el nuevo tipo. Esto puede provocar resultados inesperados si no se controla:
¿Qué está pasando aquí?
3241
en binario ocupa más de 8 bits.- Al convertir a
uint8
, se eliminan los 8 bits más a la izquierda. - El resultado es
10101001
(169 en decimal), algo que no tiene relación aparente con el valor original.
Este tipo de casting puede colarse fácilmente si no revisamos el tamaño real de los datos que manipulamos, sobre todo cuando usamos optimizaciones de gas o estructuras struct
empaquetadas.
Conversión entre tipos de bytes
Aunque parecidos a los enteros, los tipos bytes
se comportan de forma distinta cuando se hace casting entre ellos. En este caso, lo que se trunca o se expande no son los bits más significativos, sino los menos (los de la derecha o los del final):
¿Qué diferencia hay aquí?
- En los
uint
, al reducir el tamaño, se conservan los bits del final. - En los
bytes
, se conservan los del principio y se rellenan los que faltan con ceros.
Este matiz es importante si estás manipulando directamente datos binarios (como hashes, direcciones o identificadores) y necesitas hacer conversiones entre tipos de bytes
.
Convertir números negativos a enteros sin signo
Otra trampa habitual ocurre al convertir un número con signo (int
) a uno sin signo (uint
). Si el número es negativo, Solidity no lanza ningún error, sino que interpreta los bits resultantes como un valor positivo… pero completamente distinto al esperado.
¿Qué ocurre aquí?
-1
en binario (usando complemento a dos) es11111111
.- Cuando lo interpretas como
uint8
, esos mismos bits representan el número255
. - Este cambio puede pasar desapercibido si no se comprueba explícitamente que el valor no sea negativo antes de convertirlo.
La precisión no es una sugerencia, es una obligación
La pérdida de precisión en Solidity no es un pequeño inconveniente. Es un problema de fondo que puede hacer que tu contrato deje de funcionar como debería… y que pierdas mucho dinero. Para proteger tu código:
- Multiplica antes de dividir para evitar truncamientos innecesarios.
- Evita el redondeo a cero en operaciones con valores pequeños.
- Unifica las precisiones cuando trabajes con distintos tokens.
- Usa la librería SafeCast de OpenZeppelin para evitar conversiones peligrosas, y SafeMath si necesitas operaciones aritméticas seguras.
La seguridad en Solidity consiste en adelantarse a los errores. Lo que empieza como un pequeño fallo puede acabar siendo un exploit de millones. Así que, prueba tu código al detalle, usa librerías seguras y programa como si cada decimal importara—porque en Solidity, cada decimal cuenta.