When dealing with Solidity, precision loss is one of those subtle yet devastating issues that can sneak into your code unnoticed. A tiny miscalculation can snowball into major financial discrepancies, potentially draining funds, misallocating rewards, or causing a DoS. Understanding how and why this happens is critical for writing secure and reliable smart contracts.
But why does Solidity even have precision issues in the first place?
Solidity runs on the Ethereum Virtual Machine (EVM), which does not support floating-point arithmetic. This decision is by design because blockchain computations must be deterministic to ensure consensus across all nodes.
To compensate, Solidity developers work with fixed-point arithmetic, using integers to represent decimal numbers by scaling them up with a predefined precision.
For example, if a token operates with 18 decimals, instead of storing 1.5
, we store:
1.5 * 10**18 = 1_500_000_000_000_000_000;
This allows for deterministic calculations, but also introduces risks. If mishandled, precision loss can accumulate over time, creating significant financial discrepancies.
Let’s break down the most common pitfalls and how to avoid them.
Divide-Then-Cry
Solidity’s integer division truncates decimal values rather than rounding them. This means that if you perform division too early in a calculation, you risk losing precision.
Although this pattern is widely recognized, it continues to surface — particularly in complex protocols where arithmetic spans multiple functions or contracts, making these issues harder to spot during reviews.
Real-World Example: Code4rena Finding
Let’s examine a subtle bug found during a Code4rena contest:
Proof of Concept
In other words, the user receives nearly 50% less than what they’re entitled to — a direct consequence of dividing before multiplying.
Takeaway: Always Multiply Before You Divide
Since Solidity rounds down by default, the correct calculation should always multiply before dividing to retain as much precision as possible. In large-scale operations, small discrepancies can add up to massive losses over time.
The Rounding-to-Zero Trap
Even if you multiply before dividing, extremely small numbers can still get rounded down to zero. This often happens when handling fractional values in low-precision contexts. In Solidity, if the numerator is lower that the denominator, the result will be 0.
How to mitigate this?
- Always test computations with edge cases, especially very small values.
- Consider whether truncation to zero is acceptable, and if not, ensure the contract reverts when a critical value rounds to zero.
Precision Scaling Across Multiple Tokens
Not all tokens use the same number of decimal places. ETH and most ERC-20 tokens use 18 decimals, but some stablecoins like USDC use only 6 decimals. If you mix tokens in a calculation without properly handling their different precisions, errors will arise.
Example mistake:
This mix-up results in an incorrect total because the values are in different scales. Always convert to a common precision before performing operations:
The Dangers of Unsafe Type Casting
Solidity is a statically typed language, meaning every variable must have a defined type at compile time. For integers, Solidity offers both signed (int
) and unsigned (uint
) types, ranging from 8 to 256 bits. This allows developers to optimize for gas and memory, but it also introduces some subtle pitfalls.
One of the most common sources of bugs lies in type casting—converting one type to another. Many developers assume that if a value doesn’t fit into the new type, Solidity will throw an error. However, that’s not the case: casting is unchecked by default in Solidity. It will silently truncate or reinterpret the bits to fit the target type, which can lead to unexpected and dangerous behavior.
Let’s look at three classic examples of how unsafe casting can lead to incorrect logic and subtle vulnerabilities.
Downcasting Overflow
When converting from a larger integer type to a smaller one (e.g., from uint16
to uint8
), Solidity won’t throw any errors. Instead, it will simply discard the most significant bits (on the left) and keep only the bits that fit in the target type. This can lead to strange and unintended results:
What’s going on here?
3241
in binary takes more than 8 bits to represent.- When casting to
uint8
, the leftmost 8 bits are discarded. - The result is
10101001
(which equals 169 in decimal), a value that has no apparent connection to the original.
This kind of mistake can easily slip through, especially when trying to optimize for gas or working with packed structs
.
Casting Between bytes Types
Although bytes
types resemble integers, their casting behavior is different. When casting from one bytesN
type to another, Solidity will truncate or pad from the opposite side: it keeps the leftmost bytes (most significant) and cuts off or fills in the right:
What’s the difference here?
- For integers, truncation keeps the least significant bits (on the right).
- For
bytes
, it keeps the most significant bytes (on the left).
This difference matters a lot when you’re working with raw data (like addresses, hashes, or identifiers), and could lead to misinterpretation or data loss if not handled properly.
Negative Numbers Cast to Unsigned Integers
Another subtle bug comes from casting a signed number (int
) to an unsigned one (uint
). If the number is negative, Solidity won’t throw an error—it will just reinterpret the binary bits as a positive number, but one with a completely different meaning.
What’s happening here?
-1
is represented in two’s complement as11111111
.- When interpreted as
uint8
, this same binary pattern becomes255
. - This silent reinterpretation can lead to bugs that are very hard to detect.
Precision Is Not a Suggestion—It’s a Requirement
Precision loss in Solidity isn’t just an annoyance—it’s a fundamental issue that can break your smart contract’s logic and cause significant financial loss. To protect your code:
- Always multiply before dividing to minimize truncation errors.
- Guard against rounding-to-zero issues in small value calculations.
- Normalize decimal precision when working with multiple tokens.
- Use OpenZeppelin SafeCast library to avoid silent overflows and unexpected conversions. You can also use SafeMath library for basic arithmetic operations.
Security in Solidity is about thinking ahead. Small mistakes can have massive financial consequences, and bad precision handling can be as dangerous as a full-fledged exploit. So, test thoroughly, use safe libraries, and write code as if every decimal place matters—because in Solidity, it absolutely does.